mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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: '注册成功',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user