diff --git a/web/src/components/ui/toggle-list.tsx b/web/src/components/ui/toggle-list.tsx index aebb3da882..0478216f83 100644 --- a/web/src/components/ui/toggle-list.tsx +++ b/web/src/components/ui/toggle-list.tsx @@ -79,6 +79,18 @@ export interface ToggleListProps { * (e.g. lazy-fetching list data) on first open. */ onOpenChange?: (open: boolean) => void; + /** + * Optional footer slot rendered at the bottom of the dropdown, OUTSIDE + * the scrollable items area. Stays pinned to the bottom of the panel + * regardless of how many options are scrolled. Use it for actions + * that should always be reachable (e.g. an "Add custom" button). + * + * The footer receives the current `open` state as a render prop so + * callers can render a different node when the panel is collapsed. + */ + footer?: React.ReactNode | ((state: { open: boolean }) => React.ReactNode); + /** Class applied to the footer wrapper (e.g. for borders / background). */ + footerClassName?: string; } /** @@ -93,6 +105,9 @@ export interface ToggleListProps { * API mode (with `onSearch`) only emits the query and lets the caller refetch. * - Optional load-more pagination. The caller owns the data; the component * renders the trigger button. + * - Optional `footer` slot rendered outside the scrollable items area, + * pinned to the bottom of the dropdown panel. Use it for actions that + * should always be reachable regardless of how many options are scrolled. * * Behavior notes: * - Clicking a list item calls that item's `onClick`. The component does not @@ -118,6 +133,8 @@ export function ToggleList({ searchClassName, loadMore, onOpenChange, + footer, + footerClassName, }: ToggleListProps) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); @@ -146,10 +163,14 @@ export function ToggleList({ const filteredOptions = React.useMemo(() => { if (isApiSearch || !query.trim()) return options; const q = query.trim().toLowerCase(); - return options.filter((opt) => - String(opt.label ?? '') - .toLowerCase() - .includes(q), + return options.filter( + (opt) => + String(opt.label ?? '') + .toLowerCase() + .includes(q) || + String(opt.value ?? '') + .toLowerCase() + .includes(q), ); }, [options, query, isApiSearch]); @@ -165,6 +186,10 @@ export function ToggleList({ searchable && query.trim().length > 0 && filteredOptions.length === 0; const showEmpty = !showNoResults && options.length === 0; + // Resolve the footer node. Render-prop form gives callers the current + // `open` state so they can render a different node when collapsed. + const footerNode = typeof footer === 'function' ? footer({ open }) : footer; + return (
)}
+ {footerNode && ( +
+ {footerNode} +
+ )} )} diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 01fc383fc3..871631413f 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1552,9 +1552,9 @@ Example: Virtual Hosted Style`, paddleocrApiUrlMessage: 'Please input the PaddleOCR API URL!', paddleocrApiUrlPlaceholder: 'e.g. https://paddleocr-server.com/layout-parsing', - paddleocrAccessToken: 'PaddleOCR Access Token', - paddleocrAccessTokenMessage: 'Please input the PaddleOCR access token!', - paddleocrAccessTokenPlaceholder: 'Your PaddleOCR access token (optional)', + paddleocrAccessToken: 'AI Studio Access Token', + paddleocrAccessTokenMessage: 'Access token for PaddleOCR API (optional)', + paddleocrAccessTokenPlaceholder: 'Your AI Studio token (optional)', paddleocrAlgorithm: 'PaddleOCR Algorithm', paddleocrAlgorithmMessage: 'Please select a PaddleOCR algorithm', mineruApiserver: 'MinerU API Server', @@ -1774,6 +1774,17 @@ Example: Virtual Hosted Style`, listModelsSearchPlaceholder: 'Search models…', listModelsEmpty: 'No models available', listModelsLoading: 'Loading models…', + addCustomModel: 'Add custom model', + addCustomModelTitle: 'Add custom model', + modelMaxTokens: 'Max tokens', + modelFeatures: 'Model features', + modelFeatureToolCall: 'Tool call', + modelFeatureFunctionCall: 'Function call', + modelNameRequired: 'Model name is required', + modelNameDuplicate: 'Model name already exists', + modelTypeRequired: 'Please select at least one model type', + modelMaxTokensMessage: 'Max tokens must be a number', + modelMaxTokensMinMessage: 'Max tokens must be at least 0', }, message: { registered: 'Registered!', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 36777a6c05..5b7ed95d00 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1276,9 +1276,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 paddleocrApiUrlMessage: '请输入 PaddleOCR API URL!', paddleocrApiUrlPlaceholder: '例如:https://paddleocr-server.com/layout-parsing', - paddleocrAccessToken: 'PaddleOCR 访问令牌', - paddleocrAccessTokenMessage: '请输入 PaddleOCR 访问令牌!', - paddleocrAccessTokenPlaceholder: '您的 PaddleOCR 访问令牌(可选)', + paddleocrAccessToken: 'AI Studio 访问令牌', + paddleocrAccessTokenMessage: 'PaddleOCR API 的访问令牌(可选)', + paddleocrAccessTokenPlaceholder: '您的 AI Studio 令牌(可选)', paddleocrAlgorithm: 'PaddleOCR 算法', paddleocrAlgorithmMessage: '请选择 PaddleOCR 算法', mineruApiserver: 'MinerU API 服务器', @@ -1462,6 +1462,24 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 listModelsSearchPlaceholder: '搜索模型…', listModelsEmpty: '暂无可用模型', listModelsLoading: '正在加载模型…', + addCustomModel: '添加自定义模型', + addCustomModelTitle: '添加自定义模型', + modelMaxTokens: '最大 Token 数', + modelTypes: { + chat: 'Chat', + embedding: 'Embedding', + rerank: 'Rerank', + sequence2text: 'sequence2text', + tts: 'TTS', + image2text: 'OCR', + speech2text: 'ASR', + }, + modelFeatures: '模型特性', + modelNameRequired: '请输入模型名称', + modelNameDuplicate: '模型名称已存在', + modelTypeRequired: '请至少选择一个模型类型', + modelMaxTokensMessage: '最大 Token 数必须为数字', + modelMaxTokensMinMessage: '最大 Token 数不能小于 0', }, message: { registered: '注册成功', diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/components/add-custom-model-dialog.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/components/add-custom-model-dialog.tsx new file mode 100644 index 0000000000..00ce90788b --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/components/add-custom-model-dialog.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { + DynamicForm, + DynamicFormRef, + FormFieldConfig, + FormFieldType, +} from '@/components/dynamic-form'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useTranslate } from '@/hooks/common-hooks'; +import { IProviderModelItem } from '@/interfaces/request/llm'; +import { Loader2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { z } from 'zod'; + +export interface AddCustomModelDialogFields { + /** Field name (maps to IProviderModelItem key) */ + name: string; + /** Display label */ + label: string; + /** Form field type */ + type: 'text' | 'number' | 'multi-select' | 'switch-group'; + /** Options for multi-select / switch-group types */ + options?: { label: string; value: string }[]; + /** Whether the field is required */ + required?: boolean; + /** Default value */ + defaultValue?: unknown; + /** Minimum value for number type */ + min?: number; +} + +interface AddCustomModelDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Dialog title */ + title: React.ReactNode; + /** Form field definitions */ + fields: AddCustomModelDialogFields[]; + /** Called when form is submitted with valid data */ + onSubmit: (item: IProviderModelItem) => void | Promise; + /** Submit button text */ + submitText?: React.ReactNode; + /** Cancel button text */ + cancelText?: React.ReactNode; + /** Loading state (submit in progress) */ + loading?: boolean; + /** Existing model names for uniqueness validation */ + existingNames: string[]; +} + +type FormValues = Record; + +/** + * Dynamic form dialog for adding a custom model. + * Fields are driven by the `fields` prop and validated via Zod. + */ +export const AddCustomModelDialog = ({ + open, + onOpenChange, + title, + fields, + onSubmit, + submitText, + cancelText, + loading = false, + existingNames, +}: AddCustomModelDialogProps) => { + const { t } = useTranslate('setting'); + const formRef = useRef(null); + + // Translate AddCustomModelDialogFields -> FormFieldConfig for DynamicForm. + // The custom `switch-group` type falls back to FormFieldType.Custom with + // a render prop that re-implements the bordered switch list. + const dynamicFields = useMemo(() => { + return fields.map((field) => { + const isArrayType = + field.type === 'multi-select' || field.type === 'switch-group'; + const defaultValue = + field.defaultValue ?? + (field.type === 'number' ? 0 : isArrayType ? [] : ''); + + if (field.type === 'switch-group') { + return { + name: field.name, + label: field.label, + type: FormFieldType.Custom, + required: field.required, + defaultValue, + schema: field.required + ? z.array(z.string()).min(1, t('modelTypeRequired')) + : z.array(z.string()).optional(), + render: (fieldProps) => { + const currentValues = (fieldProps.value as string[]) ?? []; + return ( +
+ {field.options?.map((opt) => { + const isChecked = currentValues.includes(opt.value); + const switchId = `${field.name}-${opt.value}`; + return ( +
+ + { + const next = checked + ? [...currentValues, opt.value] + : currentValues.filter((v) => v !== opt.value); + fieldProps.onChange(next); + }} + /> +
+ ); + })} +
+ ); + }, + }; + } + + const typeMap = { + text: FormFieldType.Text, + number: FormFieldType.Number, + 'multi-select': FormFieldType.MultiSelect, + } as const; + + return { + name: field.name, + label: field.label, + type: typeMap[field.type as 'text' | 'number' | 'multi-select'], + required: field.required, + defaultValue, + options: field.options, + placeholder: field.label, + ...(field.min !== undefined + ? { + validation: { + min: field.min, + message: t('modelMaxTokensMinMessage'), + }, + } + : {}), + ...(field.name === 'name' + ? { + customValidate: (value: unknown) => { + if ( + typeof value === 'string' && + value && + existingNames.includes(value) + ) { + return t('modelNameDuplicate'); + } + return true; + }, + } + : {}), + }; + }); + }, [fields, t, existingNames]); + + const handleSubmit = useCallback( + (values: FormValues) => { + const features = values.features; + const item: IProviderModelItem = { + name: (values.name as string) ?? '', + max_tokens: (values.max_tokens as number) ?? 0, + model_types: (values.model_types as string[]) ?? [], + features: + Array.isArray(features) && features.length > 0 + ? (features as string[]) + : null, + }; + onSubmit(item); + }, + [onSubmit], + ); + + // Reset form whenever the dialog closes, so the next open starts fresh. + useEffect(() => { + if (!open) { + formRef.current?.reset(); + } + }, [open]); + + return ( + + e.stopPropagation()}> + + {title} + + + + + + + + + + + ); +}; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/components/addable-toggle-list.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/components/addable-toggle-list.tsx new file mode 100644 index 0000000000..86a0773fa8 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/components/addable-toggle-list.tsx @@ -0,0 +1,162 @@ +'use client'; + +import type { ToggleListOption } from '@/components/ui/toggle-list'; +import { ToggleList } from '@/components/ui/toggle-list'; +import { useTranslate } from '@/hooks/common-hooks'; +import { IProviderModelItem } from '@/interfaces/request/llm'; +import { Plus } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import type { AddCustomModelDialogFields } from './add-custom-model-dialog'; +import { AddCustomModelDialog } from './add-custom-model-dialog'; + +export interface AddableToggleListProps { + /** Trigger button text */ + btnText: React.ReactNode; + /** ToggleList options */ + options: ToggleListOption[]; + /** Show search input */ + searchable?: boolean; + /** Search placeholder */ + searchPlaceholder?: string; + /** Empty state text */ + emptyText?: React.ReactNode; + /** Search loading indicator */ + searchLoading?: boolean; + /** Max height of the list */ + maxHeight?: number; + /** ToggleList expand/collapse notification */ + onOpenChange?: (open: boolean) => void; + /** Called when user submits a new custom model */ + onAdd: (item: IProviderModelItem) => void | Promise; + /** Dialog title */ + dialogTitle: React.ReactNode; + /** Fields for the dialog form */ + dialogFields: AddCustomModelDialogFields[]; + /** Submit button text */ + dialogSubmitText?: React.ReactNode; + /** Cancel button text */ + dialogCancelText?: React.ReactNode; + /** Container className */ + className?: string; + /** Trigger button className */ + buttonClassName?: string; + /** Handle selection of models (for auto-checking new items) */ + handleSelectModel: (model: IProviderModelItem) => void; + /** Existing model names for uniqueness validation */ + existingNames: string[]; +} + +/** + * Wrapper around ToggleList that renders a pinned "Add custom model" footer + * below the scrollable options. The footer slot is provided by ToggleList, + * so the action stays visible regardless of how many options are scrolled. + * + * Clicking the footer button opens a dialog; submitting the dialog pushes + * the new model through `onAdd` and then auto-selects it via + * `handleSelectModel` (which is the sole owner of selection state). + */ +export const AddableToggleList = ({ + btnText, + options, + searchable, + searchPlaceholder, + emptyText, + searchLoading, + maxHeight, + onOpenChange, + onAdd, + dialogTitle, + dialogFields, + dialogSubmitText, + dialogCancelText, + className, + buttonClassName, + handleSelectModel, + existingNames, +}: AddableToggleListProps) => { + const { t } = useTranslate('setting'); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogLoading, setDialogLoading] = useState(false); + + // Pinned footer rendered below the scrollable items. The footer is part + // of ToggleList's dropdown panel (outside the scrollable area) so it + // never scrolls away. + const footerNode = useMemo( + () => ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDialogOpen(true); + } + }} + onClick={() => setDialogOpen(true)} + className="flex items-center justify-center gap-2 px-3 py-4 cursor-pointer bg-bg-card m-2 rounded-md outline-none hover:bg-border-button" + > +
+ ), + [t], + ); + + // Handle dialog submission + const handleDialogSubmit = useCallback( + async (item: IProviderModelItem) => { + setDialogLoading(true); + try { + const result = onAdd(item); + if (result instanceof Promise) { + await result; + } + // Auto-select the newly added model. The parent does NOT push the + // item into its own selection state during `onAdd`; this toggle + // is the sole writer of selection. + handleSelectModel(item); + setDialogOpen(false); + } catch { + // Error handling is done in the dialog + } finally { + setDialogLoading(false); + } + }, + [onAdd, handleSelectModel], + ); + + return ( + <> + + + + ); +}; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/components/use-custom-model-fields.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/components/use-custom-model-fields.tsx new file mode 100644 index 0000000000..01b1d05b69 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/components/use-custom-model-fields.tsx @@ -0,0 +1,127 @@ +import { useTranslate } from '@/hooks/common-hooks'; +import { IProviderModelItem } from '@/interfaces/request/llm'; +import { useMemo } from 'react'; +import type { AddCustomModelDialogFields } from './add-custom-model-dialog'; + +/** + * Allowed values for `IProviderModelItem['model_types']`. Kept in sync + * with the backend model registry; `LabelKey` matches the i18n namespace + * the descriptor below uses for each option. + */ +const MODEL_TYPE_VALUES = [ + 'chat', + 'embedding', + 'rerank', + 'tts', + 'image2text', + 'speech2text', +] as const; + +/** + * Allowed values for `IProviderModelItem['features']`. + */ +const FEATURE_VALUES = ['tool_call'] as const; + +/** + * Descriptor for a single IProviderModelItem property. Each descriptor + * tells the dialog how to render the form input for that property. + * + * - `type` : the form input type + * - `required` : whether the field must be non-empty + * - `min` : for `number` types, the minimum allowed value + * - `options` : for select-style types, the allowed value set + * - `labelKey` : i18n key for the field label + * - `optionLabelKey` : i18n key prefix for each option's label + */ +type ModelFieldDescriptor = { + type: AddCustomModelDialogFields['type']; + required: boolean; + min?: number; + options?: readonly string[]; + labelKey: string; + optionLabelKey?: (value: string) => string; +}; + +/** + * Single source of truth for the custom-model dialog schema. Mirrors + * the shape of `IProviderModelItem` 1:1 — adding a new property to the + * interface means adding an entry here, and the dialog auto-adapts. + */ +const MODEL_FIELD_SCHEMA: Record< + keyof IProviderModelItem, + ModelFieldDescriptor +> = { + name: { + type: 'text', + required: true, + labelKey: 'modelName', + }, + model_types: { + type: 'multi-select', + required: false, + options: MODEL_TYPE_VALUES, + labelKey: 'modelType', + optionLabelKey: (v) => `setting.modelTypes.${v}`, + }, + max_tokens: { + type: 'number', + required: false, + min: 0, + labelKey: 'modelMaxTokens', + }, + features: { + type: 'switch-group', + required: false, + options: FEATURE_VALUES, + labelKey: 'modelFeatures', + optionLabelKey: (v) => + v === 'tool_call' + ? 'modelFeatureToolCall' + : v === 'function_call' + ? 'modelFeatureFunctionCall' + : v, + }, +}; + +/** + * Dialog field schema for adding a custom model. Derived from + * `IProviderModelItem` via `MODEL_FIELD_SCHEMA`, so the form is in + * lockstep with the model interface. + */ +export const useCustomModelFields = (): AddCustomModelDialogFields[] => { + const { t } = useTranslate('setting'); + + return useMemo(() => { + return ( + Object.entries(MODEL_FIELD_SCHEMA) as Array< + [keyof IProviderModelItem, ModelFieldDescriptor] + > + ).map(([prop, desc]) => { + const defaultValue = + desc.type === 'number' + ? 0 + : desc.type === 'multi-select' || desc.type === 'switch-group' + ? [] + : ''; + + return { + name: String(prop), + label: t(desc.labelKey), + type: desc.type, + required: desc.required, + defaultValue, + ...(desc.min !== undefined ? { min: desc.min } : {}), + ...(desc.options + ? { + options: desc.options.map((value) => ({ + value, + label: t( + desc.optionLabelKey ? desc.optionLabelKey(value) : value, + ), + })), + } + : {}), + }; + }); + }, [t]); +}; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts index a4480f2a76..15f5d2ca9f 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts @@ -81,11 +81,11 @@ export const useListModelsPicker = ({ config, formRef, }: UseListModelsPickerParams) => { - const [models, setModels] = useState([]); + const [models, setModelsState] = useState([]); const [listLoading, setListLoading] = useState(false); // Items the user has checked in the picker. Carries the full descriptor // (including `features`) so we can derive is_tools per model at submit time. - const [selectedModelItems, setSelectedModelItems] = useState< + const [selectedModelItems, setSelectedModelItemsState] = useState< IProviderModelItem[] >([]); // Edit-mode seed: the model_info array stored on the existing instance. @@ -177,7 +177,7 @@ export const useListModelsPicker = ({ model_info: (verifyArgs as any).modelInfo ?? modelInfoList, }); if (res?.code === 0 && Array.isArray(res.data)) { - setModels(res.data); + setModelsState(res.data); // Edit-mode pre-check: match the initial model_info entries // against the freshly fetched list (by name + model_type set) // and seed `selectedModelItems`. @@ -191,7 +191,7 @@ export const useListModelsPicker = ({ ), ); if (matched.length > 0) { - setSelectedModelItems(matched); + setSelectedModelItemsState(matched); } } } @@ -219,7 +219,7 @@ export const useListModelsPicker = ({ const handleSelectModel = useCallback((model: IProviderModelItem) => { if (selectionLockRef.current) return; selectionLockRef.current = true; - setSelectedModelItems((prev) => { + setSelectedModelItemsState((prev) => { const idx = prev.findIndex((p) => p.name === model.name); if (idx >= 0) { const next = prev.slice(); @@ -243,7 +243,7 @@ export const useListModelsPicker = ({ const handleToggleAll = useCallback(() => { if (selectionLockRef.current) return; selectionLockRef.current = true; - setSelectedModelItems((prev) => { + setSelectedModelItemsState((prev) => { if (prev.length === models.length) { return []; } @@ -258,13 +258,35 @@ export const useListModelsPicker = ({ useEffect(() => { if (!visible) { formRef.current?.reset(); - setModels([]); - setSelectedModelItems([]); + setModelsState([]); + setSelectedModelItemsState([]); setListLoading(false); initialModelInfoRef.current = null; } }, [visible, formRef]); + const setModels = useCallback( + ( + updater: + | IProviderModelItem[] + | ((prev: IProviderModelItem[]) => IProviderModelItem[]), + ) => { + setModelsState(updater); + }, + [], + ); + + const setSelectedModelItems = useCallback( + ( + updater: + | IProviderModelItem[] + | ((prev: IProviderModelItem[]) => IProviderModelItem[]), + ) => { + setSelectedModelItemsState(updater); + }, + [], + ); + return { models, listLoading, @@ -274,5 +296,7 @@ export const useListModelsPicker = ({ handleListOpenChange, handleSelectModel, handleToggleAll, + setModels, + setSelectedModelItems, }; }; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx index 33f38fe79e..dacb400f83 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx @@ -1,15 +1,20 @@ import { DynamicForm, DynamicFormRef } from '@/components/dynamic-form'; import { Modal } from '@/components/ui/modal/modal'; -import { ToggleList } from '@/components/ui/toggle-list'; import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; +import { useAddInstanceModel } from '@/hooks/use-llm-request'; +import { IProviderModelItem } from '@/interfaces/request/llm'; +import { cn } from '@/lib/utils'; import { useFetchInstanceNameSet, useHideWhenInstanceExists, + VerifyResult, } from '@/pages/user-setting/setting-model/hooks'; -import { memo, useRef } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FieldValues } from 'react-hook-form'; import { LLMHeader } from '../../components/llm-header'; import VerifyButton from '../verify-button'; +import { AddableToggleList } from './components/addable-toggle-list'; +import { useCustomModelFields } from './components/use-custom-model-fields'; import { useListModelsOptions, useListModelsPicker, @@ -36,6 +41,13 @@ const ProviderModal = ({ const formRef = useRef(null); const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); + const [verifyResult, setVerifyResult] = useState(null); + useEffect(() => { + setVerifyResult(null); + return () => { + setVerifyResult(null); + }; + }, [visible]); // Field config, default values, doc link, and the LIST_MODEL_PROVIDERS // flag are all derived from the current llmFactory / mode / initialValues. @@ -69,6 +81,7 @@ const ProviderModal = ({ handleListOpenChange, handleSelectModel, handleToggleAll, + setModels, } = useListModelsPicker({ visible, hasModelNameField, @@ -80,6 +93,50 @@ const ProviderModal = ({ formRef, }); + // Mutation for adding a model to an existing instance (viewMode path) + const { addInstanceModel } = useAddInstanceModel(); + + // Dialog field schema for adding a custom model. Derived from + // `IProviderModelItem` (the shape of items in `listModelsOptions`), + // so the form automatically tracks the model interface. The hook lives + // next to the dialog so the schema is the single source of truth. + const customModelDialogFields = useCustomModelFields(); + + // Get existing model names for uniqueness validation + const existingNames = useMemo(() => models.map((m) => m.name), [models]); + + // Handle adding a custom model + // - In viewMode, call the API to persist the model on the existing instance. + // - Always update the local `models` catalog so the new option is visible. + // - Selection is owned by `AddableToggleList` (it calls `handleSelectModel` + // after `onAdd` resolves). Do NOT also push into `selectedModelItems` + // here — that would race with the wrapper's toggle and the new option + // would be inserted then immediately removed. + const handleAddCustomModel = useCallback( + async (item: IProviderModelItem) => { + if (viewMode && initialValues?.instance_name) { + await addInstanceModel({ + provider_name: llmFactory, + instance_name: initialValues.instance_name, + model_name: item.name, + model_type: item.model_types, + max_tokens: item.max_tokens, + extra: item.features + ? { + is_tools: + item.features.includes('tool_call') || + item.features.includes('function_call'), + } + : undefined, + }); + } + setModels((prev) => + prev.some((m) => m.name === item.name) ? prev : [...prev, item], + ); + }, + [viewMode, initialValues, llmFactory, addInstanceModel, setModels], + ); + // Render-only: turn the fetched model list into ToggleList options with // the "All models" sentinel row at the top. const listModelsOptions = useListModelsOptions({ @@ -125,28 +182,33 @@ const ProviderModal = ({ labelClassName="font-normal" > {hasModelNameField && ( - 10} - btnText={hasModelNameField ? t('listModels') : 'Select an option'} - options={ - hasModelNameField - ? listModelsOptions - : listModelsOptions.length > 0 - ? listModelsOptions - : [] - } + btnText={t('listModels')} + options={listModelsOptions} searchPlaceholder={t('listModelsSearchPlaceholder')} emptyText={t('listModelsEmpty')} searchLoading={listLoading} onOpenChange={handleListOpenChange} maxHeight={400} + dialogTitle={t('addCustomModelTitle')} + dialogFields={customModelDialogFields} + dialogSubmitText={tc('confirm')} + dialogCancelText={tc('cancel')} + onAdd={handleAddCustomModel} + handleSelectModel={handleSelectModel} + existingNames={existingNames} /> )} - - + { + setVerifyResult(result); + }} + />
{docLinkText} diff --git a/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx b/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx index a03cf4b7ce..a2861a8ee6 100644 --- a/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx +++ b/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx @@ -13,6 +13,7 @@ interface IVerifyButton { isAbsolute?: boolean; params?: any; className?: string; + verifyCallback?: (result: VerifyResult | null) => void; } const VerifyButton: React.FC = ({ @@ -20,6 +21,7 @@ const VerifyButton: React.FC = ({ isAbsolute = true, params, className, + verifyCallback, }) => { const { t, i18n } = useTranslate('setting'); const isArabic = (i18n.resolvedLanguage || i18n.language || '') @@ -43,6 +45,7 @@ const VerifyButton: React.FC = ({ ...params, } as ApiKeyPostBody & { verify: boolean }); setVerifyResult(result); + verifyCallback?.(result); } catch (error: any) { let logs = ''; @@ -56,11 +59,15 @@ const VerifyButton: React.FC = ({ isValid: false, logs: logs, }); + verifyCallback?.({ + isValid: false, + logs: logs, + }); } finally { // setVerifyLoading(false); } - }, [form, onVerify, params]); - const handleVerify = async () => { + }, [form, onVerify, params, verifyCallback]); + const handleVerify = useCallback(async () => { setVerifyResult({ isValid: null, logs: '', @@ -69,14 +76,16 @@ const VerifyButton: React.FC = ({ try { await onHandleVerify(); } catch (error) { - setVerifyResult({ + const res = { isValid: false, logs: (error as Error).message || 'Unknown error', - }); + }; + setVerifyResult(res); + verifyCallback?.(res); } finally { setIsVerifying(false); } - }; + }, [onHandleVerify, verifyCallback]); return (
= ({ type="button" onClick={handleVerify} disabled={isVerifying} - variant={'ghost'} + variant={'outline'} >