feat: support custom editing for model list (#15855)

### What problem does this PR solve?

feat: support custom editing for model list

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
chanx
2026-06-09 19:24:43 +08:00
committed by GitHub
parent 7ed1f1c865
commit 84482762d5
9 changed files with 723 additions and 40 deletions

View File

@@ -79,6 +79,18 @@ export interface ToggleListProps<V = unknown> {
* (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<V = unknown> {
* 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<V = unknown>({
searchClassName,
loadMore,
onOpenChange,
footer,
footerClassName,
}: ToggleListProps<V>) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
@@ -146,10 +163,14 @@ export function ToggleList<V = unknown>({
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<V = unknown>({
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 (
<div ref={containerRef} className={cn('flex flex-col', className)}>
<button
@@ -209,7 +234,7 @@ export function ToggleList<V = unknown>({
{searchable && (
<div
className={cn(
'flex items-center gap-2 px-2 h-9 border-b border-border-button',
'flex items-center gap-2 px-2 h-9 bg-bg-card m-2 rounded-md py-1',
searchClassName,
)}
>
@@ -270,7 +295,7 @@ export function ToggleList<V = unknown>({
option.onClick?.();
}}
className={cn(
'cursor-pointer px-3 py-2 text-sm hover:bg-border-button ',
'cursor-pointer px-3 py-4 text-sm hover:bg-border-button ',
itemClassName,
)}
>
@@ -301,6 +326,16 @@ export function ToggleList<V = unknown>({
</button>
)}
</div>
{footerNode && (
<div
className={cn(
'shrink-0 border-t border-border-button ',
footerClassName,
)}
>
{footerNode}
</div>
)}
</div>
)}
</div>

View File

@@ -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!',

View File

@@ -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: '注册成功',

View File

@@ -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<void>;
/** 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<string, unknown>;
/**
* 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<DynamicFormRef>(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<FormFieldConfig[]>(() => {
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 (
<div className="space-y-2 rounded-md border border-border-button p-3">
{field.options?.map((opt) => {
const isChecked = currentValues.includes(opt.value);
const switchId = `${field.name}-${opt.value}`;
return (
<div
key={opt.value}
className="flex items-center justify-between gap-3"
>
<Label
htmlFor={switchId}
className="text-sm font-normal cursor-pointer"
>
{opt.label}
</Label>
<Switch
id={switchId}
checked={isChecked}
onCheckedChange={(checked) => {
const next = checked
? [...currentValues, opt.value]
: currentValues.filter((v) => v !== opt.value);
fieldProps.onChange(next);
}}
/>
</div>
);
})}
</div>
);
},
};
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DynamicForm.Root
ref={formRef}
fields={dynamicFields}
onSubmit={handleSubmit}
>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelText ?? t('cancel')}
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitText ?? t('confirm')}
</Button>
</DialogFooter>
</DynamicForm.Root>
</DialogContent>
</Dialog>
);
};

View File

@@ -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<string | null>[];
/** 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<void>;
/** 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(
() => (
<div
role="button"
tabIndex={0}
aria-label={t('addCustomModel')}
onKeyDown={(e) => {
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"
>
<Plus
className="size-4 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<span className="text-sm text-text-secondary">
{t('addCustomModel')}
</span>
</div>
),
[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 (
<>
<ToggleList
className={className}
btnText={btnText}
options={options}
searchable={searchable}
searchPlaceholder={searchPlaceholder}
emptyText={emptyText}
searchLoading={searchLoading}
onOpenChange={onOpenChange}
maxHeight={maxHeight}
buttonClassName={buttonClassName}
footer={footerNode}
/>
<AddCustomModelDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={dialogTitle}
fields={dialogFields}
onSubmit={handleDialogSubmit}
submitText={dialogSubmitText}
cancelText={dialogCancelText}
loading={dialogLoading}
existingNames={existingNames}
/>
</>
);
};

View File

@@ -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<AddCustomModelDialogFields[]>(() => {
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]);
};

View File

@@ -81,11 +81,11 @@ export const useListModelsPicker = ({
config,
formRef,
}: UseListModelsPickerParams) => {
const [models, setModels] = useState<IProviderModelItem[]>([]);
const [models, setModelsState] = useState<IProviderModelItem[]>([]);
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,
};
};

View File

@@ -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<DynamicFormRef>(null);
const { instanceNameSet } = useFetchInstanceNameSet(llmFactory);
const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet);
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(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 && (
<ToggleList
<AddableToggleList
className="w-full"
buttonClassName="self-end"
searchable={listModelsOptions.length > 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}
/>
)}
<VerifyButton onVerify={handleVerify} />
<VerifyButton
onVerify={handleVerify}
verifyCallback={(result: VerifyResult | null) => {
setVerifyResult(result);
}}
/>
<div
className={
docLinkText
@@ -159,7 +221,9 @@ const ProviderModal = ({
href={config.docLink}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline ml-24"
className={cn('text-primary hover:underline', {
'ml-24': !verifyResult,
})}
>
{docLinkText}
</a>

View File

@@ -13,6 +13,7 @@ interface IVerifyButton {
isAbsolute?: boolean;
params?: any;
className?: string;
verifyCallback?: (result: VerifyResult | null) => void;
}
const VerifyButton: React.FC<IVerifyButton> = ({
@@ -20,6 +21,7 @@ const VerifyButton: React.FC<IVerifyButton> = ({
isAbsolute = true,
params,
className,
verifyCallback,
}) => {
const { t, i18n } = useTranslate('setting');
const isArabic = (i18n.resolvedLanguage || i18n.language || '')
@@ -43,6 +45,7 @@ const VerifyButton: React.FC<IVerifyButton> = ({
...params,
} as ApiKeyPostBody & { verify: boolean });
setVerifyResult(result);
verifyCallback?.(result);
} catch (error: any) {
let logs = '';
@@ -56,11 +59,15 @@ const VerifyButton: React.FC<IVerifyButton> = ({
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<IVerifyButton> = ({
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 (
<div
@@ -92,7 +101,7 @@ const VerifyButton: React.FC<IVerifyButton> = ({
type="button"
onClick={handleVerify}
disabled={isVerifying}
variant={'ghost'}
variant={'outline'}
>
<RefreshCcw
size={14}