From 144abbe2eb79a3d70007aa6533eb3785b8904d4b Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Mon, 8 Jun 2026 16:46:52 +0800 Subject: [PATCH] feat: Unify the 'Add Model Provider' modal (#15768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What problem does this PR solve? feat:Unify the 'Add Model Provider' modal ### Type of change - [x] New Feature (non-breaking change which adds functionality) - [x] Refactoring --- web/src/components/ui/input-select.tsx | 394 ++++---- web/src/components/ui/toggle-list.tsx | 308 ++++++ web/src/hooks/use-llm-request.tsx | 64 ++ web/src/interfaces/database/llm.ts | 6 + web/src/interfaces/request/llm.ts | 43 +- web/src/locales/en.ts | 38 + web/src/locales/zh.ts | 37 + .../setting-model/components/used-model.tsx | 82 +- .../user-setting/setting-model/hooks.tsx | 829 ++--------------- .../user-setting/setting-model/index.tsx | 678 +++++++------- .../modal/api-key-modal/index.tsx | 195 ---- .../modal/azure-openai-modal/index.tsx | 225 ----- .../modal/fish-audio-modal/index.tsx | 182 ---- .../modal/google-modal/index.tsx | 210 ----- .../modal/mineru-modal/index.tsx | 200 ---- .../modal/next-tencent-modal/index.tsx | 211 ----- .../modal/ollama-modal/index.tsx | 384 -------- .../modal/opendataloader-modal/index.tsx | 156 ---- .../modal/paddleocr-modal/index.tsx | 168 ---- .../modal/provider-modal/constants.ts | 49 + .../field-config/generic-api-key-config.ts | 82 ++ .../field-config/get-provider-config.ts | 28 + .../provider-modal/field-config/index.ts | 5 + .../field-config/local-llm-configs.ts | 202 ++++ .../field-config/provider-config-map.ts | 873 ++++++++++++++++++ .../provider-modal/field-config/utils.ts | 24 + .../modal/provider-modal/hooks/index.ts | 4 + .../hooks/use-list-models-options.tsx | 103 +++ .../hooks/use-list-models-picker.ts | 275 ++++++ .../hooks/use-provider-fields.tsx | 297 ++++++ .../hooks/use-provider-modal-actions.ts | 170 ++++ .../modal/provider-modal/index.tsx | 199 ++++ .../modal/provider-modal/types.ts | 194 ++++ .../setting-model/modal/spark-modal/index.tsx | 234 ----- .../modal/verify-button/index.tsx | 3 + .../modal/volcengine-modal/index.tsx | 197 ---- .../setting-model/modal/yiyan-modal/index.tsx | 188 ---- .../setting-model/payload-utils.ts | 2 + web/src/services/llm-service.ts | 5 + web/src/utils/api.ts | 2 + 40 files changed, 3706 insertions(+), 3840 deletions(-) create mode 100644 web/src/components/ui/toggle-list.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/api-key-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/azure-openai-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/fish-audio-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/google-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/mineru-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/next-tencent-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/opendataloader-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/generic-api-key-config.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/get-provider-config.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/index.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/provider-config-map.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/utils.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/index.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-fields.tsx create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-modal-actions.ts create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx create mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/types.ts delete mode 100644 web/src/pages/user-setting/setting-model/modal/spark-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/volcengine-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/yiyan-modal/index.tsx diff --git a/web/src/components/ui/input-select.tsx b/web/src/components/ui/input-select.tsx index 6a8d66a612..05d7eb04cf 100644 --- a/web/src/components/ui/input-select.tsx +++ b/web/src/components/ui/input-select.tsx @@ -1,17 +1,38 @@ import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { isEmpty } from 'lodash'; -import { X } from 'lucide-react'; +import { ChevronDown, X } from 'lucide-react'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; +/** + * Extracts text content from a ReactNode for filtering purposes. + * Handles strings, numbers, JSX elements with nested text, and arrays. + */ +const getNodeText = (node: React.ReactNode): string => { + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (React.isValidElement(node)) { + const children = (node.props as { children?: React.ReactNode }).children; + if (children) { + return getNodeText(children); + } + return ''; + } + if (Array.isArray(node)) { + return node.map(getNodeText).join(''); + } + return ''; +}; + /** Interface for tag select options */ export interface InputSelectOption { /** Value of the option */ value: string; /** Display label of the option */ - label: string; + label: string | React.ReactNode; } /** Properties for the InputSelect component */ @@ -36,20 +57,72 @@ export interface InputSelectProps { type?: 'text' | 'number' | 'date' | 'datetime'; } +/** Internal display for single-select selected value. Click label to re-edit (string labels only). */ +const SingleSelectDisplay: React.FC<{ + value: string | number | Date; + options: InputSelectOption[]; + type: 'text' | 'number' | 'date' | 'datetime'; + onEdit: (editText: string) => void; + onRemove: () => void; +}> = ({ value, options, type, onEdit, onRemove }) => { + const selectedOption = options.find((opt) => + type === 'number' + ? Number(opt.value) === Number(value) + : type === 'date' || type === 'datetime' + ? new Date(opt.value).getTime() === new Date(value as any).getTime() + : String(opt.value) === String(value), + ); + + const label = + selectedOption?.label ?? + (type === 'number' + ? String(value) + : type === 'date' || type === 'datetime' + ? new Date(value as any).toLocaleString() + : String(value)); + + const canEdit = typeof label === 'string'; + + return ( +
+
{ + if (!canEdit) return; + e.stopPropagation(); + onEdit(getNodeText(label)); + }} + > + {label} +
+ +
+ ); +}; + const InputSelect = React.forwardRef( - ( - { - options = [], - value = [], - onChange, - placeholder = 'Select tags...', - className, - style, - multi = false, - type = 'text', - }, - ref, - ) => { + ({ + options = [], + value = [], + onChange, + placeholder = 'Select tags...', + className, + style, + multi = false, + type = 'text', + }) => { const [inputValue, setInputValue] = React.useState(''); const [open, setOpen] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false); @@ -170,6 +243,50 @@ const InputSelect = React.forwardRef( setOpen(!!newValue); // Open popover when there's input }; + /** + * Commits the current inputValue to the selected values, matching by label first, + * then falling back to the typed value. No-op when inputValue is empty/whitespace. + * Used by Enter key handler and blur handler. + */ + const commitInputValue = () => { + if (inputValue.trim() === '') return; + + // Match by label text first + const matchedOption = options.find( + (opt) => + getNodeText(opt.label).toLowerCase() === inputValue.toLowerCase(), + ); + if (matchedOption) { + handleAddTag(matchedOption.value); + return; + } + + // Otherwise, validate by type and add as a new value + let valueToAdd: any; + if (type === 'number') { + const numValue = Number(inputValue); + if (isNaN(numValue)) return; + valueToAdd = numValue; + } else if (type === 'date' || type === 'datetime') { + const dateValue = new Date(inputValue); + if (isNaN(dateValue.getTime())) return; + valueToAdd = dateValue; + } else { + valueToAdd = inputValue; + } + + // Skip if value is already selected + const isAlreadySelected = normalizedValue.some((v) => + type === 'number' + ? Number(v) === Number(valueToAdd) + : type === 'date' || type === 'datetime' + ? new Date(v as any).getTime() === valueToAdd.getTime() + : String(v) === valueToAdd, + ); + if (!isAlreadySelected) { + handleAddTag(valueToAdd); + } + }; const handleKeyDown = (e: React.KeyboardEvent) => { if ( e.key === 'Backspace' && @@ -196,43 +313,7 @@ const InputSelect = React.forwardRef( onChange?.(result); } else if (e.key === 'Enter' && inputValue.trim() !== '') { e.preventDefault(); - - let valueToAdd: any; - - if (type === 'number') { - const numValue = Number(inputValue); - if (isNaN(numValue)) return; // Don't add invalid numbers - valueToAdd = numValue; - } else if (type === 'date' || type === 'datetime') { - const dateValue = new Date(inputValue); - if (isNaN(dateValue.getTime())) return; // Don't add invalid dates - valueToAdd = dateValue; - } else { - valueToAdd = inputValue; - } - - // Add input value as a new tag if it doesn't exist in options - const matchedOption = options.find( - (opt) => opt.label.toLowerCase() === inputValue.toLowerCase(), - ); - - if (matchedOption) { - handleAddTag(matchedOption.value); - } else { - // If not in options, create a new tag with the input value - if ( - !normalizedValue.some((v) => - type === 'number' - ? Number(v) === Number(valueToAdd) - : type === 'date' || type === 'datetime' - ? new Date(v as any).getTime() === valueToAdd.getTime() - : String(v) === valueToAdd, - ) && - inputValue.trim() !== '' - ) { - handleAddTag(valueToAdd); - } - } + commitInputValue(); } else if (e.key === 'Escape') { inputRef.current?.blur(); setOpen(false); @@ -254,8 +335,9 @@ const InputSelect = React.forwardRef( }; const handleInputBlur = () => { - // Delay closing to allow click on options + // Delay closing to allow click on options to register setTimeout(() => { + commitInputValue(); setOpen(false); setIsFocused(false); }, 150); @@ -279,7 +361,7 @@ const InputSelect = React.forwardRef( const filteredOptions = availableOptions.filter( (option) => !inputValue || - option.label + getNodeText(option.label) .toLowerCase() .includes(inputValue.toString().toLowerCase()), ); @@ -290,7 +372,8 @@ const InputSelect = React.forwardRef( const hasLabelMatch = options.some( (option) => - option.label.toLowerCase() === inputValue.toString().toLowerCase(), + getNodeText(option.label).toLowerCase() === + inputValue.toString().toLowerCase(), ); let isAlreadySelected = false; @@ -318,7 +401,7 @@ const InputSelect = React.forwardRef( const triggerElement = (
( style={style} onClick={handleContainerClick} > - {/* Render selected tags - only show tags if multi is true or if single select has a value */} - {multi && - normalizedValue.map((tagValue, index) => { - const option = options.find((opt) => - type === 'number' - ? Number(opt.value) === Number(tagValue) - : type === 'date' || type === 'datetime' - ? new Date(opt.value).getTime() === - new Date(tagValue).getTime() - : String(opt.value) === String(tagValue), - ) || { - value: String(tagValue), - label: String(tagValue), - }; - - return ( -
-
{option.label}
- -
- ); - })} - - {/* For single select, show the selected value as text instead of a tag */} - {!multi && !isEmpty(normalizedValue[0]) && ( -
-
- {options.find((opt) => + {/* Wrapper for tags and input - this part wraps */} +
+ {/* Render selected tags - only show tags if multi is true or if single select has a value */} + {multi && + normalizedValue.map((tagValue, index) => { + const option = options.find((opt) => type === 'number' - ? Number(opt.value) === Number(normalizedValue[0]) + ? Number(opt.value) === Number(tagValue) : type === 'date' || type === 'datetime' ? new Date(opt.value).getTime() === - new Date(normalizedValue[0]).getTime() - : String(opt.value) === String(normalizedValue[0]), - )?.label || - (type === 'number' - ? String(normalizedValue[0]) - : type === 'date' || type === 'datetime' - ? new Date(normalizedValue[0] as any).toLocaleString() - : String(normalizedValue[0]))} -
- -
- )} + new Date(tagValue).getTime() + : String(opt.value) === String(tagValue), + ) || { + value: String(tagValue), + label: String(tagValue), + }; - {/* Input field for adding new tags - hide if single select and value is already selected, or in multi select when not focused */} - {(multi ? isFocused : multi || isEmpty(normalizedValue[0])) && ( - e.stopPropagation()} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - /> - )} + return ( +
+
{option.label}
+ +
+ ); + })} + + {/* For single select, show the selected value as text instead of a tag */} + {!multi && !isEmpty(normalizedValue[0]) && ( + { + handleRemoveTag(normalizedValue[0]); + setInputValue(editText); + setIsFocused(true); + setOpen(true); + requestAnimationFrame(() => { + const input = inputRef.current; + if (input) { + input.focus(); + input.setSelectionRange(editText.length, editText.length); + } + }); + }} + onRemove={() => handleRemoveTag(normalizedValue[0])} + /> + )} + + {/* Input field for adding new tags - hide if single select and value is already selected, or in multi select when not focused */} + {(multi ? isFocused : multi || isEmpty(normalizedValue[0])) && ( + e.stopPropagation()} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + /> + )} +
+
); diff --git a/web/src/components/ui/toggle-list.tsx b/web/src/components/ui/toggle-list.tsx new file mode 100644 index 0000000000..e663847c86 --- /dev/null +++ b/web/src/components/ui/toggle-list.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { ChevronsDown, Loader2, Search, X } from 'lucide-react'; +import * as React from 'react'; + +export interface ToggleListOption { + /** Arbitrary value, including objects. Used as the React key when primitive. */ + value: V; + /** Display content. Strings participate in local search filtering. */ + label: React.ReactNode; + /** Per-item click handler. Whether the list closes afterwards is up to the caller. */ + onClick?: () => void; +} + +export interface ToggleListLoadMore { + /** Whether more items are available to load. When false, the button is hidden. */ + hasMore: boolean; + /** Triggered when the user clicks the "Load more" button at the bottom of the list. */ + onLoadMore: () => void; + /** Show a loading spinner inside the button and disable interaction. */ + loading?: boolean; + /** Custom label for the button. Defaults to "Load more". */ + text?: React.ReactNode; +} + +export interface ToggleListProps { + /** Class applied to the outer container that wraps the button and the list. */ + className?: string; + /** Text (or any node) shown inside the trigger button. */ + btnText?: React.ReactNode; + /** List items rendered inside the scrollable area. */ + options: ToggleListOption[]; + /** + * When true (default), clicking anywhere outside the component will close the list. + * Set to false if the list should only be toggled by the button itself. + */ + closeOnOutsideClick?: boolean; + /** Max height (in px) of the scrollable list area. Defaults to 500. */ + maxHeight?: number; + /** Class applied to the list container (the box around the search input + items). */ + listClassName?: string; + /** Class applied to each list item. */ + itemClassName?: string; + /** Class applied to the trigger button (merged with the default styles). */ + buttonClassName?: string; + /** Placeholder rendered when `options` is empty (and no query is active). */ + emptyText?: React.ReactNode; + /** Placeholder rendered when a search query yields no matches. */ + noResultsText?: React.ReactNode; + + /** Show a search input above the list. */ + searchable?: boolean; + /** Placeholder for the search input. */ + searchPlaceholder?: string; + /** + * If provided, switches to API search mode: every query change calls this + * callback. The component does NOT filter `options` locally — the caller is + * expected to update `options` (typically after a debounce + fetch). + * If omitted, the component performs a case-insensitive substring filter + * locally against the stringified label. + */ + onSearch?: (query: string) => void; + /** Show a spinner inside the search input. Useful for API search loading state. */ + searchLoading?: boolean; + /** Class applied to the search input wrapper. */ + searchClassName?: string; + + /** + * Load-more pagination config. The caller owns the data; the component just + * renders a button at the bottom of the list when `hasMore` is true or a + * load is in flight. + */ + loadMore?: ToggleListLoadMore; + /** + * Optional callback fired whenever the list is opened (`true`) or + * closed (`false`). The component still owns the open/close state; this + * is just a notification hook so callers can trigger side effects + * (e.g. lazy-fetching list data) on first open. + */ + onOpenChange?: (open: boolean) => void; +} + +/** + * ToggleList — a button that toggles a vertically stacked, scrollable list area + * rendered directly below it (no portal/overlay). Click the button to expand, + * click again to collapse. + * + * Features: + * - The list occupies real DOM space and stretches to the parent's width + * (the trigger button keeps its natural width). + * - Optional search input. Local mode (no `onSearch`) filters options client-side; + * 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. + * + * Behavior notes: + * - Clicking a list item calls that item's `onClick`. The component does not + * auto-close after a click — let the caller's onClick decide via controlled + * state if needed. + * - External-click closing is opt-out via `closeOnOutsideClick={false}`. + */ +export function ToggleList({ + className, + btnText, + options, + closeOnOutsideClick = true, + maxHeight = 500, + listClassName, + itemClassName, + buttonClassName, + emptyText = 'No options', + noResultsText = 'No matching results', + searchable = false, + searchPlaceholder = 'Search…', + onSearch, + searchLoading = false, + searchClassName, + loadMore, + onOpenChange, +}: ToggleListProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(''); + const containerRef = React.useRef(null); + const listId = React.useId(); + + // Close on outside click + React.useEffect(() => { + if (!open || !closeOnOutsideClick) return; + const handlePointerDown = (event: MouseEvent) => { + const node = containerRef.current; + if (node && !node.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + }; + }, [open, closeOnOutsideClick]); + + const isApiSearch = Boolean(onSearch); + + // Local search: case-insensitive substring filter on the stringified label. + // In API search mode the caller is responsible for filtering, so we pass through. + 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), + ); + }, [options, query, isApiSearch]); + + const handleQueryChange = React.useCallback( + (next: string) => { + setQuery(next); + if (onSearch) onSearch(next); + }, + [onSearch], + ); + + const showNoResults = + searchable && query.trim().length > 0 && filteredOptions.length === 0; + const showEmpty = !showNoResults && options.length === 0; + + return ( +
+ + {open && ( +
+ {searchable && ( +
+ + handleQueryChange(e.target.value)} + placeholder={searchPlaceholder} + className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-text-secondary/60" + /> + {searchLoading && ( + + )} + {query && !searchLoading && ( + + )} +
+ )} +
+ {showEmpty && ( +
+ {emptyText} +
+ )} + {showNoResults && ( +
+ {noResultsText} +
+ )} + {!showEmpty && + !showNoResults && + filteredOptions.map((option, index) => { + const raw = option.value; + // Use index as the key for objects (no stable identity) and + // for nullish values; stringify primitives for a stable key. + const key = + raw === null || + raw === undefined || + (typeof raw === 'object' && raw !== null) + ? index + : String(raw); + return ( +
{ + option.onClick?.(); + }} + className={cn( + 'cursor-pointer px-3 py-2 text-sm hover:bg-border-button ', + itemClassName, + )} + > + {option.label} +
+ ); + })} + {loadMore && + (loadMore.hasMore || loadMore.loading) && + filteredOptions.length > 0 && ( + + )} +
+
+ )} +
+ ); +} diff --git a/web/src/hooks/use-llm-request.tsx b/web/src/hooks/use-llm-request.tsx index 761c92b14c..5566e5e93d 100644 --- a/web/src/hooks/use-llm-request.tsx +++ b/web/src/hooks/use-llm-request.tsx @@ -14,7 +14,9 @@ import { IAddProviderRequestBody, IDeleteProviderInstanceRequestBody, IListAllModelsRequestParams, + IListProviderModelsRequestBody, IListProvidersRequestParams, + IModelInfo, ISetDefaultModelRequestBody, IUpdateModelStatusRequestBody, } from '@/interfaces/request/llm'; @@ -33,6 +35,7 @@ export const enum LLMApiAction { AddProvider = 'addProvider', AddProviderInstance = 'addProviderInstance', VerifyProviderConnection = 'verifyProviderConnection', + ListProviderModels = 'listProviderModels', AddInstanceModel = 'addInstanceModel', DeleteProviderInstance = 'deleteProviderInstance', ListDefaultModels = 'listDefaultModels', @@ -46,6 +49,13 @@ export const LlmKeys = { [LLMApiAction.AllModels, modelType] as const, providerInstances: (providerName: string) => [LLMApiAction.AddedProviders, providerName, 'instances'] as const, + providerInstance: (providerName: string, instanceName: string) => + [ + LLMApiAction.AddedProviders, + providerName, + instanceName, + 'instance', + ] as const, instanceModels: (providerName: string, instanceName: string) => [ LLMApiAction.AddedProviders, @@ -141,6 +151,31 @@ export const useFetchProviderInstances = (providerName: string) => { return { data, loading }; }; +/** + * Fetch full details of a single provider instance (used in viewMode to + * retrieve fields like `baseUrl` that the list endpoint does not return). + * Disabled by default; call from an event handler (e.g. onClick) and + * rely on the returned `refetch` to actually trigger the request. + */ +export const useFetchProviderInstance = ( + providerName: string, + instanceName: string, +) => { + return useQuery({ + queryKey: LlmKeys.providerInstance(providerName, instanceName), + initialData: undefined as unknown as IProviderInstance, + gcTime: 0, + enabled: false, + queryFn: async () => { + const { data } = await llmService.showProviderInstance( + { provider_name: providerName, instance_name: instanceName }, + true, + ); + return (data?.data ?? {}) as IProviderInstance; + }, + }); +}; + export const useFetchInstanceModels = ( providerName: string, instanceName: string, @@ -251,6 +286,7 @@ export const useVerifyProviderConnection = () => { api_key: string; base_url?: string; region?: string; + model_info?: IModelInfo[]; }) => { const { data } = await llmService.verifyProviderConnection(params); return data; @@ -260,6 +296,34 @@ export const useVerifyProviderConnection = () => { return { data, loading, verifyProviderConnection: mutateAsync }; }; +export const useListProviderModels = () => { + const { isPending: loading, mutateAsync } = useMutation({ + mutationKey: [LLMApiAction.ListProviderModels], + mutationFn: async (params: IListProviderModelsRequestBody) => { + const { provider_name, api_key, base_url } = params; + // GET /api/v1/providers//models + // The API accepts api_key and base_url as optional query parameters. + // api_key is expected as a string; values in {} object form must be + // JSON-stringified before being sent. + const queryParams: Record = {}; + if (api_key) { + queryParams.api_key = + typeof api_key === 'string' ? api_key : JSON.stringify(api_key); + } + if (base_url) { + queryParams.base_url = base_url; + } + const { data } = await llmService.listProviderModels( + { provider_name, params: queryParams }, + true, + ); + return data; + }, + }); + + return { loading, listProviderModels: mutateAsync }; +}; + export const useAddInstanceModel = () => { const queryClient = useQueryClient(); const { diff --git a/web/src/interfaces/database/llm.ts b/web/src/interfaces/database/llm.ts index 98be63eb2c..ae4119e0be 100644 --- a/web/src/interfaces/database/llm.ts +++ b/web/src/interfaces/database/llm.ts @@ -54,6 +54,12 @@ export interface IProviderInstance { provider_id: string; region: string; status: string; + /** + * Optional: only returned by the showProviderInstance endpoint. Used + * to pre-fill the base_url/api_base form field in the ProviderModal + * (e.g. when opening an existing instance in viewMode). + */ + base_url?: string; } export interface IAddedModel { model_type: string[]; diff --git a/web/src/interfaces/request/llm.ts b/web/src/interfaces/request/llm.ts index dda7a45f79..f17a656c61 100644 --- a/web/src/interfaces/request/llm.ts +++ b/web/src/interfaces/request/llm.ts @@ -1,12 +1,25 @@ export interface IAddLlmRequestBody { llm_factory: string; // Ollama - llm_name: string; - model_type: string | string[]; - api_base?: string; // chat|embedding|speech2text|image2text + // model_name: string; + // model_type: string | string[]; + base_url?: string; // chat|embedding|speech2text|image2text api_key?: string | Record; max_tokens: number; is_tools?: boolean; region?: string; + model_info: IModelInfo[]; +} + +export interface IModelInfo { + model_name: string; + model_type: string | string[]; + max_tokens: number; + /** + * Per-model extras (e.g. `is_tools` derived from the model descriptor's + * `features`). Optional for backward compatibility with legacy + * single-model payloads. + */ + extra?: Record; } export interface IDeleteLlmRequestBody { @@ -61,3 +74,27 @@ export interface ISetDefaultModelRequestBody { model_type: string; model_name: string; } + +/** + * Item shape returned by the list-provider-models endpoint. + * Fields match the backend's available-model descriptor. + */ +export interface IProviderModelItem { + name: string; + max_tokens: number; + model_types: string[]; + features: string[] | null; +} + +/** + * Request payload for the list-provider-models endpoint. + * Mirrors the verifyProviderConnection payload so the same form + * fields (api_key, base_url, region, model_info) can be reused. + */ +export interface IListProviderModelsRequestBody { + provider_name: string; + api_key: string; + base_url?: string; + region?: string; + model_info?: IModelInfo[]; +} diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 0246dc54fb..01fc383fc3 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1543,6 +1543,39 @@ Example: Virtual Hosted Style`, 'International users only: use https://api.minimax.io/v1', minimaxBaseUrlPlaceholder: '(International users only, fill in https://api.minimax.io/v1)', + openaiBaseUrlPlaceholder: 'https://api.openai.com/v1', + anthropicBaseUrlPlaceholder: 'https://api.anthropic.com/v1', + siliconflowBaseUrlPlaceholder: 'https://api.siliconflow.cn/v1', + groupId: 'Group ID', + providerOrder: 'Provider order', + paddleocrApiUrl: 'PaddleOCR API URL', + 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)', + paddleocrAlgorithm: 'PaddleOCR Algorithm', + paddleocrAlgorithmMessage: 'Please select a PaddleOCR algorithm', + mineruApiserver: 'MinerU API Server', + mineruApiserverMessage: 'Please input the MinerU API Server URL!', + mineruApiserverPlaceholder: 'e.g. http://host.docker.internal:9987', + mineruOutputDir: 'MinerU Output Directory', + mineruOutputDirMessage: 'Please input the MinerU output directory!', + mineruOutputDirPlaceholder: '/tmp/mineru', + mineruBackend: 'MinerU Backend', + mineruBackendMessage: 'Please select a MinerU backend!', + mineruSelectBackend: 'Select processing backend', + mineruServerUrl: 'MinerU Server URL', + mineruServerUrlMessage: 'Please input the MinerU Server URL!', + mineruServerUrlPlaceholder: 'e.g. http://your-vllm-server:30000', + mineruDeleteOutput: 'Delete Output Files', + mineruDeleteOutputMessage: 'Invalid value for delete output', + opendataloaderApiserver: 'OpenDataLoader API Server', + opendataloaderApiserverMessage: + 'Please input the OpenDataLoader API Server!', + opendataloaderApiserverPlaceholder: + 'http://your-opendataloader-service:9383', modify: 'Modify', systemModelSettings: 'Set default models', chatModel: 'LLM', @@ -1736,6 +1769,11 @@ Example: Virtual Hosted Style`, }, showToc: 'Show content', hideToc: 'Hide content', + listModels: 'List models', + allModels: 'All models', + listModelsSearchPlaceholder: 'Search models…', + listModelsEmpty: 'No models available', + listModelsLoading: 'Loading models…', }, message: { registered: 'Registered!', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index feee40a5f4..36777a6c05 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1267,6 +1267,38 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 tongyiBaseUrlPlaceholder: '(仅国际用户需要)', minimaxBaseUrlTip: '仅国际用户:使用 https://api.minimax.io/v1。', minimaxBaseUrlPlaceholder: '(仅国际用户填写 https://api.minimax.io/v1)', + openaiBaseUrlPlaceholder: 'https://api.openai.com/v1', + anthropicBaseUrlPlaceholder: 'https://api.anthropic.com/v1', + siliconflowBaseUrlPlaceholder: 'https://api.siliconflow.cn/v1', + groupId: 'Group ID', + providerOrder: 'Provider 顺序', + paddleocrApiUrl: 'PaddleOCR API URL', + paddleocrApiUrlMessage: '请输入 PaddleOCR API URL!', + paddleocrApiUrlPlaceholder: + '例如:https://paddleocr-server.com/layout-parsing', + paddleocrAccessToken: 'PaddleOCR 访问令牌', + paddleocrAccessTokenMessage: '请输入 PaddleOCR 访问令牌!', + paddleocrAccessTokenPlaceholder: '您的 PaddleOCR 访问令牌(可选)', + paddleocrAlgorithm: 'PaddleOCR 算法', + paddleocrAlgorithmMessage: '请选择 PaddleOCR 算法', + mineruApiserver: 'MinerU API 服务器', + mineruApiserverMessage: '请输入 MinerU API 服务器地址!', + mineruApiserverPlaceholder: '例如:http://host.docker.internal:9987', + mineruOutputDir: 'MinerU 输出目录', + mineruOutputDirMessage: '请输入 MinerU 输出目录!', + mineruOutputDirPlaceholder: '/tmp/mineru', + mineruBackend: 'MinerU 后端', + mineruBackendMessage: '请选择 MinerU 后端!', + mineruSelectBackend: '选择处理后端', + mineruServerUrl: 'MinerU 服务器 URL', + mineruServerUrlMessage: '请输入 MinerU 服务器 URL!', + mineruServerUrlPlaceholder: '例如:http://your-vllm-server:30000', + mineruDeleteOutput: '处理完成后删除输出文件', + mineruDeleteOutputMessage: '删除输出值无效', + opendataloaderApiserver: 'OpenDataLoader API 服务器', + opendataloaderApiserverMessage: '请输入 OpenDataLoader API 服务器!', + opendataloaderApiserverPlaceholder: + 'http://your-opendataloader-service:9383', modify: '修改', systemModelSettings: '设置默认模型', chatModel: 'LLM', @@ -1425,6 +1457,11 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 }, showToc: '显示目录', hideToc: '隐藏目录', + listModels: '模型列表', + allModels: '所有模型', + listModelsSearchPlaceholder: '搜索模型…', + listModelsEmpty: '暂无可用模型', + listModelsLoading: '正在加载模型…', }, message: { registered: '注册成功', diff --git a/web/src/pages/user-setting/setting-model/components/used-model.tsx b/web/src/pages/user-setting/setting-model/components/used-model.tsx index 0c8c83201d..23d2f21d6a 100644 --- a/web/src/pages/user-setting/setting-model/components/used-model.tsx +++ b/web/src/pages/user-setting/setting-model/components/used-model.tsx @@ -12,6 +12,7 @@ import { useDeleteProviderInstance, useFetchAddedProviders, useFetchInstanceModels, + useFetchProviderInstance, useFetchProviderInstances, useUpdateModelStatus, } from '@/hooks/use-llm-request'; @@ -20,15 +21,21 @@ import { IInstanceModel, IProviderInstance, } from '@/interfaces/database/llm'; -import { ChevronsDown, ChevronsUp, Trash2 } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { ChevronsDown, ChevronsUp, Settings, Trash2 } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { mapModelKey } from './un-add-model'; export function UsedModel({ handleAddModel, + onEditInstance, }: { handleAddModel: (factory: string) => void; + onEditInstance?: ( + providerName: string, + instance: IProviderInstance, + models: IInstanceModel[], + ) => void; }) { const { t } = useTranslation(); const { data: providerList } = useFetchAddedProviders(); @@ -46,6 +53,7 @@ export function UsedModel({ key={provider.name} provider={provider} handleAddModel={handleAddModel} + onEditInstance={onEditInstance} /> ))} @@ -55,9 +63,15 @@ export function UsedModel({ function ProviderCard({ provider, handleAddModel, + onEditInstance, }: { provider: IAvailableProvider; handleAddModel: (factory: string) => void; + onEditInstance?: ( + providerName: string, + instance: IProviderInstance, + models: IInstanceModel[], + ) => void; }) { const { data: instances } = useFetchProviderInstances(provider.name); @@ -86,6 +100,7 @@ function ProviderCard({ instance={instance} providerName={provider.name} handleAddModel={handleAddModel} + onEditInstance={onEditInstance} /> ))} @@ -98,10 +113,16 @@ function InstanceRow({ instance, providerName, // handleAddModel, + onEditInstance, }: { instance: IProviderInstance; providerName: string; handleAddModel: (factory: string) => void; + onEditInstance?: ( + providerName: string, + instance: IProviderInstance, + models: IInstanceModel[], + ) => void; }) { const { t } = useTranslation(); const [visible, setVisible] = useState(false); @@ -157,6 +178,8 @@ function InstanceRow({ @@ -167,11 +190,41 @@ function InstanceRow({ function InstanceModelList({ providerName, instanceName, + instance, + onEditInstance, }: { providerName: string; instanceName: string; + instance: IProviderInstance; + onEditInstance?: ( + providerName: string, + instance: IProviderInstance, + models: IInstanceModel[], + ) => void; }) { const { data: models } = useFetchInstanceModels(providerName, instanceName); + // Lazily fetches the full instance details (incl. baseUrl) only when + // the user opens the settings dialog — keeps the collapsed section + // cheap and avoids the extra request for users who never click it. + const { refetch: fetchInstanceDetails } = useFetchProviderInstance( + providerName, + instanceName, + ); + + const handleSettingsClick = useCallback(async () => { + let details: IProviderInstance = instance; + try { + const ret = await fetchInstanceDetails(); + if (ret.data) { + details = { ...instance, ...(ret.data as IProviderInstance) }; + } + } catch { + // Fall back to the list-level instance data if the show request + // fails (e.g. network error) — the modal still gets a usable + // baseline. + } + onEditInstance?.(providerName, details, models); + }, [fetchInstanceDetails, instance, models, onEditInstance, providerName]); const modelTypes = useMemo(() => { const types = new Set(); @@ -187,15 +240,22 @@ function InstanceModelList({
{/* Model type tags */} {modelTypes.length > 0 && ( -
- {modelTypes.map((type) => ( - - {mapModelKey[type.trim() as keyof typeof mapModelKey] || type} - - ))} +
+
+ {modelTypes.map((type) => ( + + {mapModelKey[type.trim() as keyof typeof mapModelKey] || type} + + ))} +
+ {false && ( + + )}
)} diff --git a/web/src/pages/user-setting/setting-model/hooks.tsx b/web/src/pages/user-setting/setting-model/hooks.tsx index 07dfc1737a..c0d5713ca7 100644 --- a/web/src/pages/user-setting/setting-model/hooks.tsx +++ b/web/src/pages/user-setting/setting-model/hooks.tsx @@ -1,4 +1,3 @@ -import { LLMFactory } from '@/constants/llm'; import { useSetModalState } from '@/hooks/common-hooks'; import { useAddInstanceModel, @@ -7,25 +6,22 @@ import { useFetchProviderInstances, useVerifyProviderConnection, } from '@/hooks/use-llm-request'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { getRealModelName } from '@/utils/llm-util'; +import { + IAddProviderInstanceRequestBody, + IModelInfo, +} from '@/interfaces/request/llm'; import { useCallback, useMemo, useState } from 'react'; -import { ApiKeyPostBody } from '../interface'; -import { MinerUFormValues } from './modal/mineru-modal'; import { splitProviderPayload } from './payload-utils'; -type SavingParamsState = { - llm_factory: string; - llm_name?: string; - model_type?: string; - instance_name?: string; - base_url?: string; -}; export type VerifyResult = { isValid: boolean | null; logs: string; }; +/** + * Unified Provider instance submission hook + * Internally handles both verify and save modes + */ const useSubmitProviderInstance = () => { const { addProviderInstance } = useAddProviderInstance(); const { addInstanceModel } = useAddInstanceModel(); @@ -36,6 +32,13 @@ const useSubmitProviderInstance = () => { return addProviderInstance({ ...payload, verify: true }); } + // Multi-model flow: when model_info is provided as an array, the + // backend is expected to create the instance and all listed models + // in a single addProviderInstance call. Skip the instance/model split. + if (Array.isArray((payload as any).model_info)) { + return addProviderInstance(payload as IAddProviderInstanceRequestBody); + } + const { instancePayload, modelPayload } = splitProviderPayload(payload); const hasModelPayload = !!modelPayload.model_name && !!modelPayload.model_type; @@ -88,6 +91,7 @@ export const useHideWhenInstanceExists = (instanceNameSet: Set) => { [instanceNameSet], ); }; + export const useVerifyConnection = () => { const { verifyProviderConnection } = useVerifyProviderConnection(); @@ -97,12 +101,14 @@ export const useVerifyConnection = () => { apiKey: string, baseUrl?: string, region?: string, + modelInfo?: IModelInfo[], ) => { const ret = await verifyProviderConnection({ provider_name: providerName, api_key: apiKey, base_url: baseUrl, region: region, + model_info: modelInfo, }); if (ret.code === 0) { @@ -121,494 +127,13 @@ export const useVerifyConnection = () => { ); }; -export const useSubmitApiKey = () => { - const [savingParams, setSavingParams] = useState( - {} as SavingParamsState, - ); - const [editMode, setEditMode] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const verifyConnection = useVerifyConnection(); - const [saveLoading, setSaveLoading] = useState(false); - const { - visible: apiKeyVisible, - hideModal: hideApiKeyModal, - showModal: showApiKeyModal, - } = useSetModalState(); - - const onApiKeySavingOk = useCallback( - async (postBody: ApiKeyPostBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const apiKey: string = postBody.api_key || ''; - - let region: string | undefined; - if (savingParams.llm_factory === LLMFactory.SILICONFLOW) { - const baseUrl = postBody.base_url; - if (baseUrl) { - try { - const parsed = new URL(baseUrl); - const host = parsed.hostname.toLowerCase(); - if ( - host === 'api.siliconflow.com' || - host.endsWith('.api.siliconflow.com') - ) { - region = 'intl'; - } - } catch { - // ignore invalid URL - } - } - } - - // Use dedicated verify API for verification - if (isVerify) { - const res = await verifyConnection( - savingParams.llm_factory, - postBody.api_key, - postBody.base_url, - region, - ); - return res; - } - - const req: IAddProviderInstanceRequestBody = { - instance_name: - postBody.instance_name || savingParams.instance_name || '', - llm_factory: savingParams.llm_factory, - llm_name: savingParams.llm_name || '', - model_type: savingParams.model_type || '', - api_key: apiKey, - api_base: postBody.base_url || '', - max_tokens: 0, - ...(region ? { region } : {}), - }; - - const ret = await submitProviderInstance(req, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideApiKeyModal(); - setEditMode(false); - } - } - }, - [hideApiKeyModal, submitProviderInstance, savingParams, verifyConnection], - ); - - const onShowApiKeyModal = useCallback( - (savingParams: SavingParamsState, isEdit = false) => { - setSavingParams(savingParams); - setEditMode(isEdit); - showApiKeyModal(); - }, - [showApiKeyModal, setSavingParams], - ); - - return { - saveApiKeyLoading: saveLoading, - initialApiKey: '', - llmFactory: savingParams.llm_factory, - editMode, - onApiKeySavingOk, - apiKeyVisible, - hideApiKeyModal, - showApiKeyModal: onShowApiKeyModal, - }; -}; - -export const useSubmitOllama = () => { - const [selectedLlmFactory, setSelectedLlmFactory] = useState(''); - const [editMode, setEditMode] = useState(false); - const [initialValues, setInitialValues] = useState< - Partial & { provider_order?: string } - >(); - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: llmAddingVisible, - hideModal: hideLlmAddingModal, - showModal: showLlmAddingModal, - } = useSetModalState(); - - const onLlmAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const cleanedPayload = { ...payload }; - // if ( - // !cleanedPayload.api_key || - // (typeof cleanedPayload.api_key === 'string' && - // cleanedPayload.api_key.trim() === '') - // ) { - // delete cleanedPayload.api_key; - // } - - const ret = await submitProviderInstance(cleanedPayload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideLlmAddingModal(); - setEditMode(false); - setInitialValues(undefined); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideLlmAddingModal, submitProviderInstance, setSaveLoading], - ); - - const handleShowLlmAddingModal = ( - llmFactory: string, - isEdit = false, - modelData?: any, - detailedData?: any, - ) => { - setSelectedLlmFactory(llmFactory); - setEditMode(isEdit); - - if (isEdit && detailedData) { - const initialVals = { - instance_name: - detailedData.instance_name || getRealModelName(detailedData.name), - llm_name: getRealModelName(detailedData.name), - model_type: detailedData.type, - api_base: detailedData.api_base || '', - max_tokens: detailedData.max_tokens || 8192, - api_key: '', - is_tools: detailedData.is_tools || false, - }; - setInitialValues(initialVals); - } else { - setInitialValues(undefined); - } - showLlmAddingModal(); - }; - - return { - llmAddingLoading: saveLoading, - editMode, - initialValues, - onLlmAddingOk, - llmAddingVisible, - hideLlmAddingModal, - showLlmAddingModal: handleShowLlmAddingModal, - selectedLlmFactory, - }; -}; - -export const useSubmitVolcEngine = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: volcAddingVisible, - hideModal: hideVolcAddingModal, - showModal: showVolcAddingModal, - } = useSetModalState(); - - const onVolcAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideVolcAddingModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideVolcAddingModal, submitProviderInstance, setSaveLoading], - ); - - return { - volcAddingLoading: saveLoading, - onVolcAddingOk, - volcAddingVisible, - hideVolcAddingModal, - showVolcAddingModal, - }; -}; - -export const useSubmitTencentCloud = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: TencentCloudAddingVisible, - hideModal: hideTencentCloudAddingModal, - showModal: showTencentCloudAddingModal, - } = useSetModalState(); - - const onTencentCloudAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideTencentCloudAddingModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideTencentCloudAddingModal, submitProviderInstance, setSaveLoading], - ); - - return { - TencentCloudAddingLoading: saveLoading, - onTencentCloudAddingOk, - TencentCloudAddingVisible, - hideTencentCloudAddingModal, - showTencentCloudAddingModal, - }; -}; - -export const useSubmitSpark = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: SparkAddingVisible, - hideModal: hideSparkAddingModal, - showModal: showSparkAddingModal, - } = useSetModalState(); - - const onSparkAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideSparkAddingModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideSparkAddingModal, submitProviderInstance, setSaveLoading], - ); - - return { - SparkAddingLoading: saveLoading, - onSparkAddingOk, - SparkAddingVisible, - hideSparkAddingModal, - showSparkAddingModal, - }; -}; - -export const useSubmityiyan = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: yiyanAddingVisible, - hideModal: hideyiyanAddingModal, - showModal: showyiyanAddingModal, - } = useSetModalState(); - - const onyiyanAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideyiyanAddingModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideyiyanAddingModal, submitProviderInstance, setSaveLoading], - ); - - return { - yiyanAddingLoading: saveLoading, - onyiyanAddingOk, - yiyanAddingVisible, - hideyiyanAddingModal, - showyiyanAddingModal, - }; -}; - -export const useSubmitFishAudio = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: FishAudioAddingVisible, - hideModal: hideFishAudioAddingModal, - showModal: showFishAudioAddingModal, - } = useSetModalState(); - - const onFishAudioAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideFishAudioAddingModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideFishAudioAddingModal, submitProviderInstance, setSaveLoading], - ); - - return { - FishAudioAddingLoading: saveLoading, - onFishAudioAddingOk, - FishAudioAddingVisible, - hideFishAudioAddingModal, - showFishAudioAddingModal, - }; -}; - -export const useSubmitGoogle = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: GoogleAddingVisible, - hideModal: hideGoogleAddingModal, - showModal: showGoogleAddingModal, - } = useSetModalState(); - - const onGoogleAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideGoogleAddingModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideGoogleAddingModal, submitProviderInstance, setSaveLoading], - ); - - return { - GoogleAddingLoading: saveLoading, - onGoogleAddingOk, - GoogleAddingVisible, - hideGoogleAddingModal, - showGoogleAddingModal, - }; -}; +// ============ Hooks for the 4 retained special modals ============ +// Bedrock / MinerU / PaddleOCR / OpenDataLoader are not yet merged into ProviderModal export const useSubmitBedrock = () => { const [saveLoading, setSaveLoading] = useState(false); const submitProviderInstance = useSubmitProviderInstance(); + const verifyConnection = useVerifyConnection(); const { visible: bedrockAddingVisible, hideModal: hideBedrockAddingModal, @@ -620,30 +145,45 @@ export const useSubmitBedrock = () => { if (!isVerify) { setSaveLoading(true); } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideBedrockAddingModal(); - } - } if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; + const legacyPayload = payload as any; + const modelType = Array.isArray(legacyPayload.model_type) + ? (legacyPayload.model_type as string[]) + : legacyPayload.model_type + ? [legacyPayload.model_type as string] + : []; + const apiKey = JSON.stringify({ + auth_mode: legacyPayload.auth_mode, + bedrock_ak: legacyPayload.bedrock_ak, + bedrock_sk: legacyPayload.bedrock_sk, + aws_role_arn: legacyPayload.aws_role_arn, + }); + return verifyConnection( + payload.llm_factory as string, + apiKey, + legacyPayload.bedrock_region, + undefined, + [ + { + model_type: modelType, + model_name: (legacyPayload.model_name as string) ?? '', + max_tokens: (legacyPayload.max_tokens as number) ?? 0, + }, + ], + ); + } + const ret = await submitProviderInstance(payload, false); + setSaveLoading(false); + if (ret.code === 0) { + hideBedrockAddingModal(); } }, - [hideBedrockAddingModal, submitProviderInstance, setSaveLoading], + [ + hideBedrockAddingModal, + submitProviderInstance, + setSaveLoading, + verifyConnection, + ], ); return { @@ -655,260 +195,13 @@ export const useSubmitBedrock = () => { }; }; -export const useSubmitAzure = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: AzureAddingVisible, - hideModal: hideAzureAddingModal, - showModal: showAzureAddingModal, - } = useSetModalState(); - - const onAzureAddingOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const ret = await submitProviderInstance(payload, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideAzureAddingModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [hideAzureAddingModal, submitProviderInstance, setSaveLoading], - ); - - return { - AzureAddingLoading: saveLoading, - onAzureAddingOk, - AzureAddingVisible, - hideAzureAddingModal, - showAzureAddingModal, - }; -}; - -export const useSubmitMinerU = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: mineruVisible, - hideModal: hideMineruModal, - showModal: showMineruModal, - } = useSetModalState(); - - const onMineruOk = useCallback( - async ( - payload: MinerUFormValues & { instance_name: string }, - isVerify = false, - ) => { - if (!isVerify) { - setSaveLoading(true); - } - const cfg: any = { - ...payload, - mineru_delete_output: - (payload.mineru_delete_output ?? true) ? '1' : '0', - }; - delete cfg.instance_name; - if (payload.mineru_backend !== 'vlm-http-client') { - delete cfg.mineru_server_url; - } - const req: IAddProviderInstanceRequestBody = { - instance_name: payload.instance_name, - llm_factory: LLMFactory.MinerU, - llm_name: payload.llm_name, - model_type: 'ocr', - api_key: cfg, - api_base: '', - max_tokens: 0, - }; - const ret = await submitProviderInstance(req, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideMineruModal(); - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - }, - [submitProviderInstance, hideMineruModal, setSaveLoading], - ); - - return { - mineruVisible, - hideMineruModal, - showMineruModal, - onMineruOk, - mineruLoading: saveLoading, - }; -}; - -export const useSubmitPaddleOCR = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: paddleocrVisible, - hideModal: hidePaddleOCRModal, - showModal: showPaddleOCRModal, - } = useSetModalState(); - - const onPaddleOCROk = useCallback( - async (payload: any, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const cfg: any = { - ...payload, - }; - delete cfg.instance_name; - const req: IAddProviderInstanceRequestBody = { - instance_name: payload.instance_name, - llm_factory: LLMFactory.PaddleOCR, - llm_name: payload.llm_name, - model_type: 'ocr', - api_key: cfg, - api_base: '', - max_tokens: 0, - }; - const ret = await submitProviderInstance(req, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hidePaddleOCRModal(); - return true; - } - } - if (isVerify) { - let res = {} as VerifyResult; - if (ret.data?.success) { - res = { - isValid: true, - logs: ret.data?.message, - }; - } else { - res = { - isValid: false, - logs: ret.data?.message, - }; - } - return res; - } - return false; - }, - [submitProviderInstance, hidePaddleOCRModal, setSaveLoading], - ); - - return { - paddleocrVisible, - hidePaddleOCRModal, - showPaddleOCRModal, - onPaddleOCROk, - paddleocrLoading: saveLoading, - }; -}; - -export const useSubmitOpenDataLoader = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const { - visible: opendataloaderVisible, - hideModal: hideOpenDataLoaderModal, - showModal: showOpenDataLoaderModal, - } = useSetModalState(); - - const onOpenDataLoaderOk = useCallback( - async (payload: any, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const cfg: any = { ...payload }; - delete cfg.instance_name; - const req: IAddProviderInstanceRequestBody = { - instance_name: payload.instance_name, - llm_factory: LLMFactory.OpenDataLoader, - llm_name: payload.llm_name, - model_type: 'ocr', - api_key: cfg, - api_base: '', - max_tokens: 0, - }; - const ret = await submitProviderInstance(req, isVerify); - if (!isVerify) { - setSaveLoading(false); - if (ret.code === 0) { - hideOpenDataLoaderModal(); - return true; - } - } - if (isVerify) { - return { - isValid: !!ret.data?.success, - logs: ret.data?.message, - } as VerifyResult; - } - return false; - }, - [submitProviderInstance, hideOpenDataLoaderModal, setSaveLoading], - ); - - return { - opendataloaderVisible, - hideOpenDataLoaderModal, - showOpenDataLoaderModal, - onOpenDataLoaderOk, - opendataloaderLoading: saveLoading, - }; -}; - +/** + * Wraps the verify callback: provides a unified call with isVerify=true for the Verify button + */ export const useVerifySettings = ({ onVerify, }: { - onVerify: - | (( - postBody: ApiKeyPostBody, - isVerify?: boolean, - ) => Promise) - | (( - payload: IAddProviderInstanceRequestBody, - isVerify?: boolean, - ) => Promise) - | (( - payload: MinerUFormValues, - isVerify?: boolean, - ) => Promise) - | ((payload: any, isVerify?: boolean) => Promise) - | (() => void); + onVerify: (postBody: any, isVerify?: boolean) => Promise; }) => { const onApiKeyVerifying = useCallback( async (postBody: any) => { diff --git a/web/src/pages/user-setting/setting-model/index.tsx b/web/src/pages/user-setting/setting-model/index.tsx index f32e8fd26c..581fcd6d78 100644 --- a/web/src/pages/user-setting/setting-model/index.tsx +++ b/web/src/pages/user-setting/setting-model/index.tsx @@ -1,111 +1,28 @@ import Spotlight from '@/components/spotlight'; import { LLMFactory } from '@/constants/llm'; -// import { LlmItem, useFetchMyLlmListDetailed } from '@/hooks/use-llm-request'; -import { useCallback, useMemo } from 'react'; +import { + useAddInstanceModel, + useAddProviderInstance, + useFetchAvailableProviders, + useVerifyProviderConnection, +} from '@/hooks/use-llm-request'; +import { IInstanceModel, IProviderInstance } from '@/interfaces/database/llm'; +import { + IAddProviderInstanceRequestBody, + IModelInfo, +} from '@/interfaces/request/llm'; +import { useCallback, useMemo, useState } from 'react'; import { isLocalLlmFactory } from '../utils'; import SystemSetting from './components/system-setting'; import { AvailableModels } from './components/un-add-model'; import { UsedModel } from './components/used-model'; -import { - useSubmitApiKey, - useSubmitAzure, - useSubmitBedrock, - useSubmitFishAudio, - useSubmitGoogle, - useSubmitMinerU, - useSubmitOllama, - useSubmitOpenDataLoader, - useSubmitPaddleOCR, - useSubmitSpark, - useSubmitTencentCloud, - useSubmitVolcEngine, - useSubmityiyan, - useVerifySettings, -} from './hooks'; -import ApiKeyModal from './modal/api-key-modal'; -import AzureOpenAIModal from './modal/azure-openai-modal'; +import { useSubmitBedrock } from './hooks'; import BedrockModal from './modal/bedrock-modal'; -import FishAudioModal from './modal/fish-audio-modal'; -import GoogleModal from './modal/google-modal'; -import MinerUModal from './modal/mineru-modal'; -import TencentCloudModal from './modal/next-tencent-modal'; -import OllamaModal from './modal/ollama-modal'; -import OpenDataLoaderModal from './modal/opendataloader-modal'; -import PaddleOCRModal from './modal/paddleocr-modal'; -import SparkModal from './modal/spark-modal'; -import VolcEngineModal from './modal/volcengine-modal'; -import YiyanModal from './modal/yiyan-modal'; +import ProviderModal, { IViewModeOkPayload } from './modal/provider-modal'; +import { splitProviderPayload } from './payload-utils'; + const ModelProviders = () => { - // const { data: detailedLlmList } = useFetchMyLlmListDetailed(); - const { - saveApiKeyLoading, - initialApiKey, - llmFactory, - editMode, - onApiKeySavingOk, - apiKeyVisible, - hideApiKeyModal, - showApiKeyModal, - } = useSubmitApiKey(); - const { - llmAddingVisible, - hideLlmAddingModal, - showLlmAddingModal, - onLlmAddingOk, - llmAddingLoading, - editMode: llmEditMode, - initialValues: llmInitialValues, - selectedLlmFactory, - } = useSubmitOllama(); - - const { - volcAddingVisible, - hideVolcAddingModal, - showVolcAddingModal, - onVolcAddingOk, - volcAddingLoading, - } = useSubmitVolcEngine(); - - const { - GoogleAddingVisible, - hideGoogleAddingModal, - showGoogleAddingModal, - onGoogleAddingOk, - GoogleAddingLoading, - } = useSubmitGoogle(); - - const { - TencentCloudAddingVisible, - hideTencentCloudAddingModal, - showTencentCloudAddingModal, - onTencentCloudAddingOk, - TencentCloudAddingLoading, - } = useSubmitTencentCloud(); - - const { - SparkAddingVisible, - hideSparkAddingModal, - showSparkAddingModal, - onSparkAddingOk, - SparkAddingLoading, - } = useSubmitSpark(); - - const { - yiyanAddingVisible, - hideyiyanAddingModal, - showyiyanAddingModal, - onyiyanAddingOk, - yiyanAddingLoading, - } = useSubmityiyan(); - - const { - FishAudioAddingVisible, - hideFishAudioAddingModal, - showFishAudioAddingModal, - onFishAudioAddingOk, - FishAudioAddingLoading, - } = useSubmitFishAudio(); - + // 4 retained special modals const { bedrockAddingLoading, onBedrockAddingOk, @@ -114,299 +31,354 @@ const ModelProviders = () => { showBedrockAddingModal, } = useSubmitBedrock(); - const { - AzureAddingVisible, - hideAzureAddingModal, - showAzureAddingModal, - onAzureAddingOk, - AzureAddingLoading, - } = useSubmitAzure(); + // Unified ProviderModal state + const [providerVisible, setProviderVisible] = useState(false); + const [currentLlmFactory, setCurrentLlmFactory] = useState(''); + const [providerLoading, setProviderLoading] = useState(false); - const { - mineruVisible, - hideMineruModal, - showMineruModal, - onMineruOk, - mineruLoading, - } = useSubmitMinerU(); + // viewMode (edit-models) state: when true, ProviderModal opens in + // read-only mode for everything except the model-related fields. + // `viewModeInitialValues` carries the existing instance + model data. + const [viewMode, setViewMode] = useState(false); + const [viewModeInitialValues, setViewModeInitialValues] = useState< + Record | undefined + >(undefined); - const { - paddleocrVisible, - hidePaddleOCRModal, - showPaddleOCRModal, - onPaddleOCROk, - paddleocrLoading, - } = useSubmitPaddleOCR(); + // ProviderModal submission logic: calls addProviderInstance + addInstanceModel + const { addProviderInstance } = useAddProviderInstance(); + const { addInstanceModel } = useAddInstanceModel(); + const { verifyProviderConnection } = useVerifyProviderConnection(); + const { data: availableProviders } = useFetchAvailableProviders(); - const { - opendataloaderVisible, - hideOpenDataLoaderModal, - showOpenDataLoaderModal, - onOpenDataLoaderOk, - opendataloaderLoading, - } = useSubmitOpenDataLoader(); + // Convert IAvailableProvider.url to baseUrlOptions + // IAvailableProvider.url looks like { default?: string; cn?: string; intl?: string; ... } + // Mapped to [{ value: 'https://...', regionKey: 'default', label: https://...default }, ...] + // `regionKey` carries the original key so the modal can map the currently + // selected URL back to its key for the `region` submit field. + const buildBaseUrlOptions = useCallback( + (urlObj?: Record) => { + if (!urlObj) return undefined; + const options = Object.keys(urlObj) + .filter((k) => !!urlObj[k]) + .map((k) => { + const v = urlObj[k] as string; + // if (k === 'default') { + // return { value: v, label: v }; + // } + return { + value: v, + regionKey: k, + label: ( +
+ {v} + + {k} + +
+ ), + }; + }); + return options.length > 0 ? options : undefined; + }, + [], + ); + + // baseUrlOptions for the current factory (looked up from availableProviders) + const currentProvider = useMemo( + () => + currentLlmFactory + ? availableProviders.find((p) => p.name === currentLlmFactory) + : undefined, + [availableProviders, currentLlmFactory], + ); + const currentBaseUrlOptions = useMemo( + () => buildBaseUrlOptions(currentProvider?.url), + [buildBaseUrlOptions, currentProvider], + ); + + const handleProviderOk = useCallback( + async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { + if (!isVerify) setProviderLoading(true); + try { + if (isVerify) { + // Verify mode: call verify API + const ret = await addProviderInstance({ ...payload, verify: true }); + return ret; + } + // Normal submission + const { instancePayload, modelPayload } = splitProviderPayload(payload); + const hasModelPayload = + !!modelPayload.model_name && !!modelPayload.model_type; + const instanceRet = await addProviderInstance({ + ...instancePayload, + llm_factory: payload.llm_factory, + instance_name: payload.instance_name, + } as IAddProviderInstanceRequestBody); + if (instanceRet.code !== 0) { + return instanceRet; + } + // When model information has been submitted nested in the instance via model_info + // (e.g., VolcEngine / LocalLLM), addInstanceModel is no longer called separately; + // close the modal directly. + if (!hasModelPayload) { + setProviderVisible(false); + return instanceRet; + } + const modelRet = await addInstanceModel({ + provider_name: payload.llm_factory, + instance_name: payload.instance_name, + ...modelPayload, + }); + if (modelRet.code === 0) { + setProviderVisible(false); + } + return modelRet; + } finally { + if (!isVerify) setProviderLoading(false); + } + }, + [addProviderInstance, addInstanceModel], + ); + + const handleProviderVerify = useCallback( + async (params: any) => { + // ProviderModal's handleVerify flattens verifyArgs onto params + // verifyArgs comes from config.verifyTransform, fields are apiKey/baseUrl/region/modelInfo + const apiKey = params.apiKey ?? params.api_key ?? params._apiKey ?? ''; + const baseUrl = params.baseUrl ?? params.base_url ?? params._baseUrl; + const region = params.region ?? params._region; + const modelInfo = + params.modelInfo ?? params.model_info ?? params._modelInfo; + const ret = await verifyProviderConnection({ + provider_name: params.llm_factory ?? currentLlmFactory, + api_key: apiKey, + base_url: baseUrl, + region: region, + model_info: modelInfo, + }); + if (ret.code === 0) { + return { isValid: true, logs: ret.message }; + } + return { isValid: false, logs: ret.message }; + }, + [verifyProviderConnection, currentLlmFactory], + ); const ModalMap = useMemo( () => ({ [LLMFactory.Bedrock]: showBedrockAddingModal, - [LLMFactory.VolcEngine]: showVolcAddingModal, - [LLMFactory.XunFeiSpark]: showSparkAddingModal, - [LLMFactory.BaiduYiYan]: showyiyanAddingModal, - [LLMFactory.FishAudio]: showFishAudioAddingModal, - [LLMFactory.TencentCloud]: showTencentCloudAddingModal, - [LLMFactory.GoogleCloud]: showGoogleAddingModal, - [LLMFactory.AzureOpenAI]: showAzureAddingModal, - [LLMFactory.MinerU]: showMineruModal, - [LLMFactory.PaddleOCR]: showPaddleOCRModal, - [LLMFactory.OpenDataLoader]: showOpenDataLoaderModal, + [LLMFactory.VolcEngine]: () => { + setCurrentLlmFactory(LLMFactory.VolcEngine); + setProviderVisible(true); + }, + [LLMFactory.XunFeiSpark]: () => { + setCurrentLlmFactory(LLMFactory.XunFeiSpark); + setProviderVisible(true); + }, + [LLMFactory.BaiduYiYan]: () => { + setCurrentLlmFactory(LLMFactory.BaiduYiYan); + setProviderVisible(true); + }, + [LLMFactory.FishAudio]: () => { + setCurrentLlmFactory(LLMFactory.FishAudio); + setProviderVisible(true); + }, + [LLMFactory.TencentCloud]: () => { + setCurrentLlmFactory(LLMFactory.TencentCloud); + setProviderVisible(true); + }, + [LLMFactory.GoogleCloud]: () => { + setCurrentLlmFactory(LLMFactory.GoogleCloud); + setProviderVisible(true); + }, + [LLMFactory.AzureOpenAI]: () => { + setCurrentLlmFactory(LLMFactory.AzureOpenAI); + setProviderVisible(true); + }, + [LLMFactory.MinerU]: () => { + setCurrentLlmFactory(LLMFactory.MinerU); + setProviderVisible(true); + }, + [LLMFactory.PaddleOCR]: () => { + setCurrentLlmFactory(LLMFactory.PaddleOCR); + setProviderVisible(true); + }, + [LLMFactory.OpenDataLoader]: () => { + setCurrentLlmFactory(LLMFactory.OpenDataLoader); + setProviderVisible(true); + }, }), - [ - showBedrockAddingModal, - showVolcAddingModal, - showSparkAddingModal, - showyiyanAddingModal, - showFishAudioAddingModal, - showTencentCloudAddingModal, - showGoogleAddingModal, - showAzureAddingModal, - showMineruModal, - showPaddleOCRModal, - showOpenDataLoaderModal, - ], + [showBedrockAddingModal], ); const handleAddModel = useCallback( (llmFactory: string) => { - console.log('handleAddModel', llmFactory); if (isLocalLlmFactory(llmFactory)) { - showLlmAddingModal(llmFactory); + setCurrentLlmFactory(llmFactory); + setProviderVisible(true); } else if (llmFactory in ModalMap) { ModalMap[llmFactory as keyof typeof ModalMap](); } else { - showApiKeyModal({ llm_factory: llmFactory }); + setCurrentLlmFactory(llmFactory); + setProviderVisible(true); } }, - [showApiKeyModal, showLlmAddingModal, ModalMap], + [ModalMap], ); - // const handleEditModel = useCallback( - // (model: any, factory: LlmItem) => { - // if (factory) { - // const detailedFactory = detailedLlmList[factory.name]; - // const detailedModel = detailedFactory?.llm?.find( - // (m: any) => m.name === model.name, - // ); + // Open the ProviderModal in viewMode (read-only) for an existing + // instance so the user can edit its model list. The instance's + // `api_key`, `baseUrl` and `model_info` are passed as initial values; + // the list picker uses `model_info` to pre-check the already-added + // models. + const handleEditInstance = useCallback( + ( + providerName: string, + instance: IProviderInstance, + models: IInstanceModel[], + ) => { + setCurrentLlmFactory(providerName); + const modelInfos: IModelInfo[] = models.map((m) => ({ + model_name: m.name, + model_type: m.model_type, + max_tokens: m.max_tokens ?? 0, + })); + // For non-LIST_MODEL_PROVIDERS, the modal renders model_name / + // model_type / max_tokens / is_tools as form fields, so seed + // them from the first existing model to match what the user sees + // in the instance list. + const firstModel = models[0]; + setViewModeInitialValues({ + instance_name: instance.instance_name, + api_key: instance.api_key, + // baseUrl is only present when the showProviderInstance endpoint + // returned it; pass it as both `base_url` and `api_base` so it + // fills the form field regardless of which name the provider + // config uses. + ...(instance.base_url + ? { base_url: instance.base_url, api_base: instance.base_url } + : {}), + ...(firstModel + ? { + model_name: firstModel.name, + model_type: firstModel.model_type, + max_tokens: firstModel.max_tokens, + } + : {}), + model_info: modelInfos, + }); + setViewMode(true); + setProviderVisible(true); + }, + [], + ); - // const editData = { - // llm_factory: factory.name, - // llm_name: model.name, - // model_type: model.type, - // }; + // viewMode save handler: receives the list of selected models (or + // the editable model fields for non-LIST_MODEL_PROVIDERS) from the + // modal and adds them via `addInstanceModel`. Does NOT call + // `addProviderInstance` because the instance itself is unchanged. + const handleViewModeOk = useCallback( + async (payload: IViewModeOkPayload) => { + setProviderLoading(true); + try { + if (payload.modelInfos.length > 0) { + // LIST_MODEL_PROVIDERS: full sync — call addInstanceModel for + // every selected model. The backend is idempotent so re-adding + // an already-present model is a no-op. + for (const model of payload.modelInfos) { + const modelType = Array.isArray(model.model_type) + ? model.model_type + : model.model_type + ? [model.model_type as string] + : []; + const ret = await addInstanceModel({ + provider_name: payload.llmFactory, + instance_name: payload.instanceName, + model_name: model.model_name, + model_type: modelType, + max_tokens: model.max_tokens ?? 0, + ...(model.extra ? { extra: model.extra } : {}), + }); + if (ret.code !== 0) { + return ret; + } + } + } else if (payload.formValues) { + // Non-LIST_MODEL_PROVIDERS: add/update the single model + // described by the form values. + const fv = payload.formValues; + const modelType = Array.isArray(fv.model_type) + ? fv.model_type + : fv.model_type + ? [fv.model_type as string] + : []; + const ret = await addInstanceModel({ + provider_name: payload.llmFactory, + instance_name: payload.instanceName, + model_name: fv.model_name, + model_type: modelType, + max_tokens: fv.max_tokens ?? 0, + ...(fv.is_tools !== undefined + ? { extra: { is_tools: !!fv.is_tools } } + : {}), + }); + if (ret.code !== 0) { + return ret; + } + } + setProviderVisible(false); + } finally { + setProviderLoading(false); + } + }, + [addInstanceModel], + ); - // if (isLocalLlmFactory(factory.name)) { - // showLlmAddingModal(factory.name, true, editData, detailedModel); - // } else if (factory.name in ModalMap) { - // ModalMap[factory.name as keyof typeof ModalMap](); - // } else { - // showApiKeyModal(editData, true); - // } - // } - // }, - // [showApiKeyModal, showLlmAddingModal, ModalMap, detailedLlmList], - // ); - - const handleOk = useMemo(() => { - if (apiKeyVisible) { - return onApiKeySavingOk; - } - if (llmAddingVisible) { - return onLlmAddingOk; - } - if (volcAddingVisible) { - return onVolcAddingOk; - } - if (TencentCloudAddingVisible) { - return onTencentCloudAddingOk; - } - if (SparkAddingVisible) { - return onSparkAddingOk; - } - if (yiyanAddingVisible) { - return onyiyanAddingOk; - } - if (FishAudioAddingVisible) { - return onFishAudioAddingOk; - } - if (bedrockAddingVisible) { - return onBedrockAddingOk; - } - if (AzureAddingVisible) { - return onAzureAddingOk; - } - if (mineruVisible) { - return onMineruOk; - } - if (paddleocrVisible) { - return onPaddleOCROk; - } - if (opendataloaderVisible) { - return onOpenDataLoaderOk; - } - if (GoogleAddingVisible) { - return onGoogleAddingOk; - } - return () => {}; - }, [ - GoogleAddingVisible, - onGoogleAddingOk, - apiKeyVisible, - onApiKeySavingOk, - llmAddingVisible, - onLlmAddingOk, - volcAddingVisible, - onVolcAddingOk, - TencentCloudAddingVisible, - onTencentCloudAddingOk, - SparkAddingVisible, - onSparkAddingOk, - yiyanAddingVisible, - onyiyanAddingOk, - FishAudioAddingVisible, - onFishAudioAddingOk, - bedrockAddingVisible, - onBedrockAddingOk, - AzureAddingVisible, - onAzureAddingOk, - mineruVisible, - onMineruOk, - paddleocrVisible, - onPaddleOCROk, - opendataloaderVisible, - onOpenDataLoaderOk, - ]); - - const { onApiKeyVerifying } = useVerifySettings({ - onVerify: handleOk, - }); + // Closing the modal also clears the viewMode flag so the next open + // starts in the default (add) mode. + const hideProviderModal = useCallback(() => { + setProviderVisible(false); + setViewMode(false); + }, []); return (
- +
- - {llmAddingVisible && ( - - )} - - - - - - + + {/* Unified ProviderModal (replaces 9 independent modals) */} + + ({ isValid: null, logs: '' })} > - - - -
); }; + export default ModelProviders; diff --git a/web/src/pages/user-setting/setting-model/modal/api-key-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/api-key-modal/index.tsx deleted file mode 100644 index 09b766d60b..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/api-key-modal/index.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { IModalManagerChildrenProps } from '@/components/modal-manager'; -import { RAGFlowFormItem } from '@/components/ragflow-form'; -import { Form } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Modal } from '@/components/ui/modal/modal'; -import { LLMFactory } from '@/constants/llm'; -import { useTranslate } from '@/hooks/common-hooks'; -import { KeyboardEventHandler, useCallback, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { ApiKeyPostBody } from '../../../interface'; -import { LLMHeader } from '../../components/llm-header'; -import { VerifyResult } from '../../hooks'; -import VerifyButton from '../verify-button'; - -interface IProps extends Omit { - loading: boolean; - initialValue: string; - llmFactory: string; - editMode?: boolean; - onOk: (postBody: ApiKeyPostBody) => void; - onVerify: ( - postBody: any, - ) => Promise; - showModal?(): void; -} - -type FieldType = { - instance_name?: string; - api_key?: string; - base_url?: string; - group_id?: string; -}; - -const modelsWithBaseUrl = [ - LLMFactory.OpenAI, - LLMFactory.AzureOpenAI, - LLMFactory.TongYiQianWen, - LLMFactory.MiniMax, - LLMFactory.SILICONFLOW, -]; - -const ApiKeyModal = ({ - visible, - hideModal, - llmFactory, - loading, - initialValue, - // editMode = false, - onOk, - onVerify, -}: IProps) => { - const form = useForm(); - const { t } = useTranslate('setting'); - - const handleOk = useCallback(async () => { - await form.handleSubmit((values) => onOk(values as ApiKeyPostBody))(); - }, [form, onOk]); - - const handleKeyDown: KeyboardEventHandler = useCallback( - async (e) => { - if (e.key === 'Enter') { - await handleOk(); - } - }, - [handleOk], - ); - - useEffect(() => { - if (visible) { - form.setValue('api_key', initialValue); - } else { - form.reset(); - } - }, [initialValue, form, visible]); - - return ( - } - open={visible} - onOpenChange={(open) => !open && hideModal()} - onOk={handleOk} - onCancel={hideModal} - confirmLoading={loading} - okText={t('save')} - cancelText={t('cancel')} - className="!w-[600px]" - testId="apikey-modal" - okButtonTestId="apikey-save" - > -
-
- - {(field) => ( - - )} - - - - {(field) => ( - - )} - - - {modelsWithBaseUrl.some((x) => x === llmFactory) && ( - - {(field) => ( - - )} - - )} - - {llmFactory?.toLowerCase() === 'Anthropic'.toLowerCase() && ( - - {(field) => ( - - )} - - )} - - {llmFactory?.toLowerCase() === 'Minimax'.toLowerCase() && ( - - {(field) => } - - )} - - -
-
-
- ); -}; - -export default ApiKeyModal; diff --git a/web/src/pages/user-setting/setting-model/modal/azure-openai-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/azure-openai-modal/index.tsx deleted file mode 100644 index fd4500024b..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/azure-openai-modal/index.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -const AzureOpenAIModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, -}: IModalProps & { - llmFactory: string; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tg } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const formRef = useRef(null); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const fields: FormFieldConfig[] = useMemo( - () => [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { - message: t('instanceNameMessage'), - }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: buildModelTypeOptions(['chat', 'embedding', 'image2text']), - defaultValue: ['embedding'], - }, - { - name: 'api_base', - label: t('addLlmBaseUrl'), - type: FormFieldType.Text, - required: true, - placeholder: t('baseUrlNameMessage'), - validation: { - message: t('baseUrlNameMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'api_key', - label: t('apiKey'), - type: FormFieldType.Text, - required: false, - placeholder: t('apiKeyMessage'), - shouldRender: hideWhenInstanceExists, - }, - { - name: 'llm_name', - label: t('modelName'), - type: FormFieldType.Text, - required: true, - placeholder: t('modelNameMessage'), - defaultValue: 'gpt-3.5-turbo', - validation: { - message: t('modelNameMessage'), - }, - }, - { - name: 'api_version', - label: t('apiVersion'), - type: FormFieldType.Text, - required: false, - placeholder: t('apiVersionMessage'), - defaultValue: '2024-02-01', - }, - { - name: 'max_tokens', - label: t('maxTokens'), - type: FormFieldType.Number, - required: true, - placeholder: t('maxTokensTip'), - validation: { - min: 0, - message: t('maxTokensMessage'), - }, - }, - { - name: 'vision', - label: t('vision'), - type: FormFieldType.Switch, - defaultValue: false, - dependencies: ['model_type'], - shouldRender: (formValues: any) => { - const modelType = formValues?.model_type; - if (Array.isArray(modelType)) { - return modelType.includes('chat'); - } - return modelType === 'chat'; - }, - }, - ], - [t, buildModelTypeOptions, hideWhenInstanceExists], - ); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const modelType = values.model_type.map((t: string) => - t === 'chat' && values.vision ? 'image2text' : t, - ); - - const data: IAddProviderInstanceRequestBody & { api_version?: string } = { - instance_name: values.instance_name as string, - llm_factory: llmFactory, - llm_name: values.llm_name as string, - model_type: modelType, - api_base: values.api_base as string, - api_key: values.api_key as string | undefined, - max_tokens: values.max_tokens as number, - api_version: values.api_version as string, - }; - - await onOk?.(data); - }; - - const verifyParamsFunc = useCallback(() => { - const values = formRef.current?.getValues(); - return { - llm_factory: llmFactory, - model_type: values.model_type.map((t: string) => - t === 'chat' && values.vision ? 'image2text' : t, - ), - }; - }, [llmFactory]); - - const handleVerify = useCallback( - async (params: any) => { - const verifyParams = verifyParamsFunc(); - const res = await onVerify?.({ ...params, ...verifyParams }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [verifyParamsFunc, onVerify], - ); - - useEffect(() => { - if (!visible) { - formRef.current?.reset(); - } - }, [visible]); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - footer={
} - > - { - console.log(data); - }} - ref={formRef} - defaultValues={ - { - instance_name: '', - model_type: ['embedding'], - llm_name: 'gpt-3.5-turbo', - api_version: '2024-02-01', - vision: false, - max_tokens: 8192, - } as FieldValues - } - labelClassName="font-normal" - > - <> - {onVerify && } -
- { - hideModal?.(); - }} - /> - { - handleOk(values); - }} - /> -
- -
-
- ); -}; - -export default memo(AzureOpenAIModal); diff --git a/web/src/pages/user-setting/setting-model/modal/fish-audio-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/fish-audio-modal/index.tsx deleted file mode 100644 index 368ff5bd0e..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/fish-audio-modal/index.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -const FishAudioModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, -}: IModalProps & { - llmFactory: string; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tc } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const formRef = useRef(null); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const fields: FormFieldConfig[] = useMemo( - () => [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { message: t('instanceNameMessage') }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: buildModelTypeOptions(['tts']), - defaultValue: ['tts'], - }, - { - name: 'llm_name', - label: t('modelName'), - type: FormFieldType.Text, - required: true, - placeholder: t('FishAudioModelNameMessage'), - validation: { message: t('FishAudioModelNameMessage') }, - }, - { - name: 'fish_audio_ak', - label: t('addFishAudioAK'), - type: FormFieldType.Text, - required: true, - placeholder: t('FishAudioAKMessage'), - validation: { message: t('FishAudioAKMessage') }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'fish_audio_refid', - label: t('addFishAudioRefID'), - type: FormFieldType.Text, - required: true, - placeholder: t('FishAudioRefIDMessage'), - validation: { message: t('FishAudioRefIDMessage') }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'max_tokens', - label: t('maxTokens'), - type: FormFieldType.Number, - required: true, - placeholder: t('maxTokensTip'), - validation: { - min: 0, - message: t('maxTokensInvalidMessage'), - }, - }, - ], - [t, buildModelTypeOptions, hideWhenInstanceExists], - ); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const data: IAddProviderInstanceRequestBody & { - fish_audio_ak: string; - fish_audio_refid: string; - } = { - instance_name: values.instance_name as string, - llm_factory: llmFactory, - llm_name: values.llm_name as string, - model_type: values.model_type, - fish_audio_ak: values.fish_audio_ak, - fish_audio_refid: values.fish_audio_refid, - max_tokens: values.max_tokens as number, - }; - - await onOk?.(data); - }; - - const handleVerify = useCallback( - async (params: any) => { - const res = await onVerify?.({ ...params, llm_factory: llmFactory }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [llmFactory, onVerify], - ); - - useEffect(() => { - if (!visible) { - formRef.current?.reset(); - } - }, [visible]); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - footerClassName="py-1" - footer={
} - > - console.log(data)} - ref={formRef} - defaultValues={{ - instance_name: '', - model_type: ['tts'], - max_tokens: 8192, - }} - labelClassName="font-normal" - > - {onVerify && ( - - )} -
- - {t('FishAudioLink')} - -
- hideModal?.()} /> - handleOk(values)} - /> -
-
-
-
- ); -}; - -export default memo(FishAudioModal); diff --git a/web/src/pages/user-setting/setting-model/modal/google-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/google-modal/index.tsx deleted file mode 100644 index 07dd1c8770..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/google-modal/index.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -const GoogleModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, -}: IModalProps & { - llmFactory: string; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tc } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - const formRef = useRef(null); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const fields: FormFieldConfig[] = useMemo( - () => [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { message: t('instanceNameMessage') }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: buildModelTypeOptions(['chat', 'image2text']), - defaultValue: ['chat'], - }, - { - name: 'llm_name', - label: t('modelID'), - type: FormFieldType.Text, - required: true, - placeholder: t('GoogleModelIDMessage'), - validation: { - message: t('GoogleModelIDMessage'), - }, - }, - { - name: 'google_project_id', - label: t('addGoogleProjectID'), - type: FormFieldType.Text, - required: true, - placeholder: t('GoogleProjectIDMessage'), - validation: { - message: t('GoogleProjectIDMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'google_region', - label: t('addGoogleRegion'), - type: FormFieldType.Text, - required: true, - placeholder: t('GoogleRegionMessage'), - validation: { - message: t('GoogleRegionMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'google_service_account_key', - label: t('addGoogleServiceAccountKey'), - type: FormFieldType.Text, - required: true, - placeholder: t('GoogleServiceAccountKeyMessage'), - validation: { - message: t('GoogleServiceAccountKeyMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'max_tokens', - label: t('maxTokens'), - type: FormFieldType.Number, - required: true, - placeholder: t('maxTokensTip'), - validation: { - min: 0, - message: t('maxTokensMinMessage'), - }, - customValidate: (value: any) => { - if (value === undefined || value === null || value === '') { - return t('maxTokensMessage'); - } - if (value < 0) { - return t('maxTokensMinMessage'); - } - return true; - }, - }, - ], - [t, buildModelTypeOptions, hideWhenInstanceExists], - ); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const data = { - instance_name: values.instance_name as string, - llm_factory: llmFactory, - model_type: values.model_type, - llm_name: values.llm_name, - google_project_id: values.google_project_id, - google_region: values.google_region, - google_service_account_key: values.google_service_account_key, - max_tokens: values.max_tokens, - } as IAddProviderInstanceRequestBody; - - await onOk?.(data); - }; - - const verifyParamsFunc = useCallback(() => { - return { - llm_factory: llmFactory, - }; - }, [llmFactory]); - - const handleVerify = useCallback( - async (params: any) => { - const verifyParams = verifyParamsFunc(); - const res = await onVerify?.({ ...params, ...verifyParams }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [verifyParamsFunc, onVerify], - ); - - useEffect(() => { - if (!visible) { - formRef.current?.reset(); - } - }, [visible]); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - footer={
} - > - { - // Form submission is handled by SavingButton - }} - ref={formRef} - defaultValues={ - { - instance_name: '', - model_type: ['chat'], - max_tokens: 8192, - } as FieldValues - } - labelClassName="font-normal" - > - {onVerify && } -
- { - hideModal?.(); - }} - /> - { - handleOk(values); - }} - /> -
-
-
- ); -}; - -export default memo(GoogleModal); diff --git a/web/src/pages/user-setting/setting-model/modal/mineru-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/mineru-modal/index.tsx deleted file mode 100644 index e3d15ebbf6..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/mineru-modal/index.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { RAGFlowFormItem } from '@/components/ragflow-form'; -import { ButtonLoading } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Form } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { RAGFlowSelect } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { LLMFactory } from '@/constants/llm'; -import { IModalProps } from '@/interfaces/common'; -import { VerifyResult } from '@/pages/user-setting/setting-model/hooks'; -import { buildOptions } from '@/utils/form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { t } from 'i18next'; -import { memo, useEffect } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../verify-button'; - -const FormSchema = z.object({ - instance_name: z.string().min(1, { - message: t('setting.instanceNameMessage'), - }), - llm_name: z.string().min(1, { - message: t('setting.mineru.modelNameRequired'), - }), - mineru_apiserver: z.string().url(), - mineru_output_dir: z.string().optional(), - mineru_backend: z.enum([ - 'pipeline', - 'vlm-transformers', - 'vlm-vllm-engine', - 'vlm-http-client', - 'vlm-mlx-engine', - 'vlm-vllm-async-engine', - 'vlm-lmdeploy-engine', - ]), - mineru_server_url: z.string().url().optional(), - mineru_delete_output: z.boolean(), -}); - -export type MinerUFormValues = z.infer; - -const MinerUModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, -}: IModalProps & { - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslation(); - - const backendOptions = buildOptions([ - 'pipeline', - 'vlm-transformers', - 'vlm-vllm-engine', - 'vlm-http-client', - 'vlm-mlx-engine', - 'vlm-vllm-async-engine', - 'vlm-lmdeploy-engine', - ]); - - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - instance_name: '', - mineru_backend: 'pipeline', - mineru_delete_output: true, - }, - }); - - const backend = useWatch({ - control: form.control, - name: 'mineru_backend', - }); - - const handleOk = async (values: MinerUFormValues) => { - const ret = await onOk?.(values as any); - if (ret) { - hideModal?.(); - } - }; - - useEffect(() => { - if (!visible) { - form.reset(); - } - }, [visible, form]); - - return ( - - - - - - - -
- - - - - - - - - - - - - - - {(field) => ( - { - field.onChange(value); - if (value !== 'vlm-http-client') { - form.setValue('mineru_server_url', undefined); - } - }} - options={backendOptions} - placeholder={t('setting.mineru.selectBackend')} - /> - )} - - {backend === 'vlm-http-client' && ( - - - - )} - - {(field) => ( - - )} - - {onVerify && ( - Promise} - /> - )} - - - -
- - {t('common.save', 'Save')} - -
-
-
-
- ); -}; - -export default memo(MinerUModal); diff --git a/web/src/pages/user-setting/setting-model/modal/next-tencent-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/next-tencent-modal/index.tsx deleted file mode 100644 index f95d3ef1d2..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/next-tencent-modal/index.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -const TencentCloudModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, -}: IModalProps> & { - llmFactory: string; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tc } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const formRef = useRef(null); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const fields: FormFieldConfig[] = useMemo( - () => [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { message: t('instanceNameMessage') }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: buildModelTypeOptions(['speech2text']), - defaultValue: ['speech2text'], - }, - { - name: 'llm_name', - label: t('modelName'), - type: FormFieldType.Select, - required: true, - options: [ - { label: '16k_zh', value: '16k_zh' }, - { label: '16k_zh_large', value: '16k_zh_large' }, - { label: '16k_multi_lang', value: '16k_multi_lang' }, - { label: '16k_zh_dialect', value: '16k_zh_dialect' }, - { label: '16k_en', value: '16k_en' }, - { label: '16k_yue', value: '16k_yue' }, - { label: '16k_zh-PY', value: '16k_zh-PY' }, - { label: '16k_ja', value: '16k_ja' }, - { label: '16k_ko', value: '16k_ko' }, - { label: '16k_vi', value: '16k_vi' }, - { label: '16k_ms', value: '16k_ms' }, - { label: '16k_id', value: '16k_id' }, - { label: '16k_fil', value: '16k_fil' }, - { label: '16k_th', value: '16k_th' }, - { label: '16k_pt', value: '16k_pt' }, - { label: '16k_tr', value: '16k_tr' }, - { label: '16k_ar', value: '16k_ar' }, - { label: '16k_es', value: '16k_es' }, - { label: '16k_hi', value: '16k_hi' }, - { label: '16k_fr', value: '16k_fr' }, - { label: '16k_zh_medical', value: '16k_zh_medical' }, - { label: '16k_de', value: '16k_de' }, - ], - defaultValue: '16k_zh', - validation: { - message: t('SparkModelNameMessage'), - }, - }, - { - name: 'TencentCloud_sid', - label: t('addTencentCloudSID'), - type: FormFieldType.Text, - required: true, - placeholder: t('TencentCloudSIDMessage'), - validation: { - message: t('TencentCloudSIDMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'TencentCloud_sk', - label: t('addTencentCloudSK'), - type: FormFieldType.Text, - required: true, - placeholder: t('TencentCloudSKMessage'), - validation: { - message: t('TencentCloudSKMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - ], - [t, buildModelTypeOptions, hideWhenInstanceExists], - ); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const data = { - instance_name: values.instance_name as string, - model_type: values.model_type, - llm_name: values.llm_name as string, - TencentCloud_sid: values.TencentCloud_sid as string, - TencentCloud_sk: values.TencentCloud_sk as string, - llm_factory: llmFactory, - } as Omit; - - await onOk?.(data); - }; - - const verifyParamsFunc = useCallback(() => { - return { - llm_factory: llmFactory, - }; - }, [llmFactory]); - - const handleVerify = useCallback( - async (params: any) => { - const verifyParams = verifyParamsFunc(); - const res = await onVerify?.({ ...params, ...verifyParams }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [verifyParamsFunc, onVerify], - ); - - useEffect(() => { - if (!visible) { - formRef.current?.reset(); - } - }, [visible]); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - footer={
} - > - {}} - ref={formRef} - defaultValues={ - { - instance_name: '', - model_type: ['speech2text'], - llm_name: '16k_zh', - } as FieldValues - } - labelClassName="font-normal" - > - {onVerify && ( - - )} -
- - {t('TencentCloudLink')} - -
- { - hideModal?.(); - }} - /> - { - handleOk(values); - }} - /> -
-
-
-
- ); -}; - -export default memo(TencentCloudModal); diff --git a/web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx deleted file mode 100644 index 08d31b3095..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { LLMFactory } from '@/constants/llm'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -const llmFactoryToUrlMap: Partial> = { - [LLMFactory.Ollama]: - 'https://github.com/infiniflow/ragflow/blob/main/docs/guides/models/deploy_local_llm.mdx', - [LLMFactory.Xinference]: - 'https://inference.readthedocs.io/en/latest/user_guide', - [LLMFactory.ModelScope]: - 'https://www.modelscope.cn/docs/model-service/API-Inference/intro', - [LLMFactory.LocalAI]: 'https://localai.io/docs/getting-started/models/', - [LLMFactory.LMStudio]: 'https://lmstudio.ai/docs/basics', - [LLMFactory.OpenAiAPICompatible]: - 'https://platform.openai.com/docs/models/gpt-4', - [LLMFactory.RAGcon]: 'https://www.ragcon.ai/erste-schritte-mit-ragflow/', - [LLMFactory.TogetherAI]: 'https://docs.together.ai/docs/deployment-options', - [LLMFactory.Replicate]: 'https://replicate.com/docs/topics/deployments', - [LLMFactory.OpenRouter]: 'https://openrouter.ai/docs', - [LLMFactory.HuggingFace]: - 'https://huggingface.co/docs/text-embeddings-inference/quick_tour', - [LLMFactory.GPUStack]: 'https://docs.gpustack.ai/latest/quickstart', - [LLMFactory.VLLM]: 'https://docs.vllm.ai/en/latest/', - [LLMFactory.TokenPony]: 'https://docs.tokenpony.cn/#/', -}; - -function buildModelTypesWithVision( - modelType: string[] | string, - vision = false, -): string[] { - const modelTypeArray = Array.isArray(modelType) ? modelType : [modelType]; - - if (modelTypeArray.includes('chat') && vision) { - return [...modelTypeArray, 'image2text']; - } - - return modelTypeArray; -} - -const OllamaModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, - editMode = false, - initialValues, -}: IModalProps< - Partial & { provider_order?: string } -> & { - llmFactory: string; - editMode?: boolean; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tc } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const formRef = useRef(null); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const optionsMap: Partial< - Record - > & { - Default: { label: string; value: string }[]; - } = { - [LLMFactory.HuggingFace]: buildModelTypeOptions([ - 'embedding', - 'chat', - 'rerank', - ]), - [LLMFactory.LMStudio]: buildModelTypeOptions([ - 'chat', - 'embedding', - 'image2text', - ]), - [LLMFactory.Xinference]: buildModelTypeOptions([ - 'chat', - 'embedding', - 'rerank', - 'image2text', - 'speech2text', - 'tts', - ]), - [LLMFactory.RAGcon]: buildModelTypeOptions([ - 'chat', - 'embedding', - 'rerank', - 'image2text', - 'speech2text', - 'tts', - ]), - [LLMFactory.ModelScope]: buildModelTypeOptions(['chat']), - [LLMFactory.GPUStack]: buildModelTypeOptions([ - 'chat', - 'embedding', - 'rerank', - 'speech2text', - 'tts', - ]), - [LLMFactory.OpenRouter]: buildModelTypeOptions(['chat', 'image2text']), - Default: buildModelTypeOptions([ - 'chat', - 'embedding', - 'rerank', - 'image2text', - ]), - }; - - const url = - llmFactoryToUrlMap[llmFactory as LLMFactory] || - 'https://github.com/infiniflow/ragflow/blob/main/docs/guides/models/deploy_local_llm.mdx'; - - const fields = useMemo(() => { - const getOptions = (factory: string) => { - return optionsMap[factory as LLMFactory] || optionsMap.Default; - }; - const defaultToolCallEnabled = initialValues?.is_tools ?? false; - - const baseFields: FormFieldConfig[] = [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { - message: t('instanceNameMessage'), - }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: getOptions(llmFactory), - }, - { - name: 'llm_name', - label: t(llmFactory === 'Xinference' ? 'modelUid' : 'modelName'), - type: FormFieldType.Text, - required: true, - placeholder: t('modelNameMessage'), - validation: { - message: t('modelNameMessage'), - }, - }, - { - name: 'api_base', - label: t('addLlmBaseUrl'), - type: FormFieldType.Text, - required: true, - placeholder: t('baseUrlNameMessage'), - validation: { - message: t('baseUrlNameMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'api_key', - label: t('apiKey'), - type: FormFieldType.Text, - required: false, - placeholder: t('apiKeyMessage'), - shouldRender: hideWhenInstanceExists, - }, - { - name: 'max_tokens', - label: t('maxTokens'), - type: FormFieldType.Number, - required: true, - placeholder: t('maxTokensTip'), - validation: { - message: t('maxTokensMessage'), - }, - customValidate: (value: any) => { - if (value !== undefined && value !== null && value !== '') { - if (typeof value !== 'number') { - return t('maxTokensInvalidMessage'); - } - if (value < 0) { - return t('maxTokensMinMessage'); - } - } - return true; - }, - }, - ]; - - baseFields.push({ - name: 'is_tools', - label: t('enableToolCall'), - type: FormFieldType.Switch, - required: false, - dependencies: ['model_type'], - shouldRender: (formValues: any) => { - const modelType = formValues?.model_type; - if (Array.isArray(modelType)) { - return modelType.includes('chat') || modelType.includes('image2text'); - } - return modelType === 'chat' || modelType === 'image2text'; - }, - tooltip: t('enableToolCallTip'), - defaultValue: defaultToolCallEnabled, - }); - - // Add provider_order field only for OpenRouter - if (llmFactory === 'OpenRouter') { - baseFields.push({ - name: 'provider_order', - label: 'Provider Order', - type: FormFieldType.Text, - required: false, - tooltip: 'Comma-separated provider list, e.g. Groq,Fireworks', - placeholder: 'Groq,Fireworks', - }); - } - - // Add vision switch (conditional on model_type === 'chat') - baseFields.push({ - name: 'vision', - label: t('vision'), - type: FormFieldType.Switch, - required: false, - dependencies: ['model_type'], - shouldRender: (formValues: any) => { - const modelType = formValues?.model_type; - if (Array.isArray(modelType)) { - return modelType.includes('chat'); - } - return modelType === 'chat'; - }, - }); - - return baseFields; - }, [llmFactory, t, hideWhenInstanceExists, initialValues?.is_tools]); - - const defaultValues: FieldValues = useMemo(() => { - if (editMode && initialValues) { - return { - instance_name: initialValues.instance_name || '', - llm_name: initialValues.llm_name || '', - model_type: initialValues.model_type - ? initialValues.model_type - : ['chat'], - api_base: initialValues.api_base || '', - max_tokens: initialValues.max_tokens || 8192, - api_key: '', - vision: initialValues.model_type === 'image2text', - provider_order: initialValues.provider_order || '', - is_tools: initialValues.is_tools || false, - }; - } - return { - instance_name: '', - model_type: [ - llmFactory === LLMFactory.Ollama || llmFactory === LLMFactory.VLLM - ? 'chat' - : llmFactory in optionsMap - ? optionsMap[llmFactory as LLMFactory]?.at(0)?.value - : 'embedding', - ], - vision: false, - is_tools: false, - max_tokens: 8192, - }; - }, [editMode, initialValues, llmFactory]); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const modelTypeArray: string[] = Array.isArray(values.model_type) - ? values.model_type - : [values.model_type]; - const supportsToolCall = - modelTypeArray.includes('chat') || modelTypeArray.includes('image2text'); - - const data: IAddProviderInstanceRequestBody & { provider_order?: string } = - { - instance_name: values.instance_name as string, - llm_factory: llmFactory, - llm_name: values.llm_name as string, - model_type: buildModelTypesWithVision(values.model_type, values.vision), - api_base: values.api_base as string, - api_key: values.api_key as string, - max_tokens: values.max_tokens as number, - }; - if (supportsToolCall) { - data.is_tools = Boolean(values.is_tools); - } - - // Add provider_order only if it exists (for OpenRouter) - if (values.provider_order) { - data.provider_order = values.provider_order as string; - } - - await onOk?.(data); - }; - - const verifyParamsFunc = useCallback(() => { - const values = formRef.current?.getValues(); - return { - llm_factory: llmFactory, - model_type: buildModelTypesWithVision(values.model_type, values.vision), - }; - }, [llmFactory]); - - const handleVerify = useCallback( - async (params: any) => { - const verifyParams = verifyParamsFunc(); - const res = await onVerify?.({ ...params, ...verifyParams }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [verifyParamsFunc, onVerify], - ); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - footer={<>} - footerClassName="py-1" - > - {}} - defaultValues={defaultValues} - labelClassName="font-normal" - > - {onVerify && ( - - )} -
- - {t('ollamaLink', { name: llmFactory })} - -
- { - hideModal?.(); - }} - /> - { - handleOk(values); - }} - /> -
-
-
-
- ); -}; - -export default memo(OllamaModal); diff --git a/web/src/pages/user-setting/setting-model/modal/opendataloader-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/opendataloader-modal/index.tsx deleted file mode 100644 index 7b919aeb65..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/opendataloader-modal/index.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { RAGFlowFormItem } from '@/components/ragflow-form'; -import { Button, ButtonLoading } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Form } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { LLMFactory } from '@/constants/llm'; -import { VerifyResult } from '@/pages/user-setting/setting-model/hooks'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { memo, useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../verify-button'; - -export type OpenDataLoaderFormValues = { - instance_name: string; - llm_name: string; - opendataloader_apiserver: string; - opendataloader_api_key?: string; -}; - -export interface IModalProps { - visible: boolean; - hideModal: () => void; - onOk?: (data: T) => Promise; - onVerify?: ( - postBody: any, - ) => Promise; - loading?: boolean; -} - -const OpenDataLoaderModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, -}: IModalProps) => { - const { t } = useTranslation(); - - const FormSchema = useMemo( - () => - z.object({ - instance_name: z.string().min(1, { - message: t('setting.instanceNameMessage'), - }), - llm_name: z.string().min(1, { - message: t('setting.modelNameMessage'), - }), - opendataloader_apiserver: z.string().min(1, { - message: t('setting.apiServerMessage'), - }), - opendataloader_api_key: z.string().optional(), - }), - [t], - ); - - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - instance_name: '', - opendataloader_apiserver: '', - opendataloader_api_key: '', - }, - }); - - const handleOk = async (values: OpenDataLoaderFormValues) => { - const ret = await onOk?.(values as any); - if (ret) { - hideModal?.(); - } - }; - - useEffect(() => { - if (!visible) { - form.reset(); - } - }, [visible, form]); - - return ( - - - - - - - -
- - - - - - - - - - - - - - {onVerify && ( - Promise} - /> - )} - - - - - - {t('common.add')} - - -
-
- ); -}; - -export default memo(OpenDataLoaderModal); diff --git a/web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx deleted file mode 100644 index b39fce9434..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { RAGFlowFormItem } from '@/components/ragflow-form'; -import { Button, ButtonLoading } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Form } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { RAGFlowSelect, RAGFlowSelectOptionType } from '@/components/ui/select'; -import { LLMFactory } from '@/constants/llm'; -import { VerifyResult } from '@/pages/user-setting/setting-model/hooks'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { t } from 'i18next'; -import { memo, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../verify-button'; - -const FormSchema = z.object({ - instance_name: z.string().min(1, { - message: t('setting.instanceNameMessage'), - }), - llm_name: z.string().min(1, { - message: t('setting.paddleocr.modelNameRequired'), - }), - paddleocr_api_url: z.string().min(1, { - message: t('setting.paddleocr.apiUrlRequired'), - }), - paddleocr_access_token: z.string().optional(), - paddleocr_algorithm: z.string().default('PaddleOCR-VL'), -}); - -export type PaddleOCRFormValues = z.infer; - -export interface IModalProps { - visible: boolean; - hideModal: () => void; - onOk?: (data: T) => Promise; - onVerify?: ( - postBody: any, - ) => Promise; - loading?: boolean; -} - -const algorithmOptions: RAGFlowSelectOptionType[] = [ - { label: 'PaddleOCR-VL-1.5', value: 'PaddleOCR-VL-1.5' }, - { label: 'PaddleOCR-VL', value: 'PaddleOCR-VL' }, - { label: 'PP-OCRv5', value: 'PP-OCRv5' }, - { label: 'PP-StructureV3', value: 'PP-StructureV3' }, -]; - -const PaddleOCRModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, -}: IModalProps) => { - const { t } = useTranslation(); - - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - instance_name: '', - paddleocr_algorithm: 'PaddleOCR-VL', - }, - }); - - const handleOk = async (values: PaddleOCRFormValues) => { - const ret = await onOk?.(values as any); - if (ret) { - hideModal?.(); - } - }; - - useEffect(() => { - if (!visible) { - form.reset(); - } - }, [visible, form]); - - return ( - - - - - - - -
- - - - - - - - - - - - - - - {(field) => ( - - )} - - {onVerify && ( - Promise} - /> - )} - -
- - - {t('common.ok')} - -
-
- - -
-
- ); -}; - -export default memo(PaddleOCRModal); diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts new file mode 100644 index 0000000000..751788d673 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts @@ -0,0 +1,49 @@ +import { LLMFactory } from '@/constants/llm'; + +/** + * Provider factories that opt into the "List Models" picker UI. + * + * For these factories, the modal hides the traditional model_name, + * model_type, max_tokens, and is_tools form fields and instead shows a + * "List Models" button that fetches available models from the provider's + * `/providers//models` endpoint. The user can multi-select models + * from the response; each selected model is converted to an `IModelInfo` + * entry and submitted as `model_info`. + * + * For all other factories the picker is hidden and the form renders the + * 4 model_* fields directly. + */ +export const LIST_MODEL_PROVIDERS = new Set([ + LLMFactory.HuggingFace, + LLMFactory.Ollama, + LLMFactory.GoogleCloud, + LLMFactory.OpenRouter, + LLMFactory.VLLM, + LLMFactory.OpenAiAPICompatible, + LLMFactory.LMStudio, + LLMFactory.VolcEngine, + LLMFactory.Xinference, + LLMFactory.LocalAI, + LLMFactory.BaiduYiYan, + LLMFactory.TencentCloud, + LLMFactory.XunFeiSpark, + LLMFactory.GPUStack, + LLMFactory.FishAudio, + LLMFactory.MinerU, + LLMFactory.PaddleOCR, +]); + +/** + * The set of form-field names that are owned by the list-models picker + * (not registered in the dynamic form when the picker is active). + * + * Doubles as the whitelist of fields that remain editable in viewMode — + * in viewMode every other field is disabled so only model-related edits + * are possible. + */ +export const LIST_MODEL_FIELD_NAMES = new Set([ + 'model_name', + 'model_type', + 'max_tokens', + 'is_tools', +]); diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/generic-api-key-config.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/generic-api-key-config.ts new file mode 100644 index 0000000000..38c947ef9d --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/generic-api-key-config.ts @@ -0,0 +1,82 @@ +import { FormFieldType } from '@/components/dynamic-form'; +import { LLMFactory } from '@/constants/llm'; +import type { ProviderConfig } from '../types'; + +/** + * Generic ApiKey configuration (used for factories not in ProviderConfigMap) + */ +export const GenericApiKeyConfig: ProviderConfig = { + llmFactory: '__generic__', + title: 'API Key', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'api_key', + label: 'apiKey', + type: FormFieldType.Text, + required: true, + placeholder: 'apiKeyMessage', + validation: { message: 'apiKeyMessage' }, + }, + { + name: 'base_url', + label: 'baseUrl', + type: 'inputSelect', + required: false, + tooltip: (factory) => { + if (factory === LLMFactory.MiniMax) return 'minimaxBaseUrlTip'; + if (factory === LLMFactory.TongYiQianWen) return 'tongyiBaseUrlTip'; + if (factory === LLMFactory.SILICONFLOW) return 'siliconBaseUrlTip'; + return 'baseUrlTip'; + }, + placeholder: (factory) => { + if (factory === LLMFactory.MiniMax) return 'minimaxBaseUrlPlaceholder'; + if (factory === LLMFactory.TongYiQianWen) + return 'tongyiBaseUrlPlaceholder'; + if (factory === LLMFactory.SILICONFLOW) + return 'siliconflowBaseUrlPlaceholder'; + if (factory?.toLowerCase() === 'Anthropic') + return 'anthropicBaseUrlPlaceholder'; + return 'openaiBaseUrlPlaceholder'; + }, + shouldRender: 'showBaseUrl', + }, + { + name: 'group_id', + label: 'groupId', + type: FormFieldType.Text, + required: false, + shouldRender: 'showGroupId', + }, + ], + verifyTransform: (values) => ({ + apiKey: values.api_key, + baseUrl: values.base_url, + }), + submitTransform: (values) => ({ + instance_name: values.instance_name, + api_key: values.api_key, + api_base: values.base_url || '', + group_id: values.group_id, + max_tokens: 0, + }), +}; + +/** + * List of factories supporting base_url (used for the generic ApiKey modal) + */ +export const FACTORIES_WITH_BASE_URL = [ + LLMFactory.OpenAI, + LLMFactory.AzureOpenAI, + LLMFactory.TongYiQianWen, + LLMFactory.MiniMax, + LLMFactory.SILICONFLOW, +]; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/get-provider-config.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/get-provider-config.ts new file mode 100644 index 0000000000..027e1619d4 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/get-provider-config.ts @@ -0,0 +1,28 @@ +import type { ProviderConfig } from '../types'; +import { GenericApiKeyConfig } from './generic-api-key-config'; +import { LocalLlmConfigs } from './local-llm-configs'; +import { ProviderConfigMap } from './provider-config-map'; + +/** + * Get the configuration for the given factory + * First look up in ProviderConfigMap, then LocalLlmConfigs, finally fall back to GenericApiKeyConfig + */ +export function getProviderConfig(llmFactory: string): ProviderConfig { + // Check whether it is a special factory (11 in ModalMap) + // Among which AzureOpenAI/VolcEngine/GoogleCloud/TencentCloud/XunFeiSpark/BaiduYiYan/FishAudio are in ProviderConfigMap + // Bedrock/MinerU/PaddleOCR/OpenDataLoader are out of the merge scope and use the original modal + + if (ProviderConfigMap[llmFactory]) { + return ProviderConfigMap[llmFactory]; + } + + if (LocalLlmConfigs[llmFactory]) { + return LocalLlmConfigs[llmFactory]; + } + + // Generic ApiKey modal + return { + ...GenericApiKeyConfig, + llmFactory, + }; +} diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/index.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/index.ts new file mode 100644 index 0000000000..1ef256145a --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/index.ts @@ -0,0 +1,5 @@ +// Public entry point for the field-config folder. +// Preserves the previous `./field-config` import path used by provider-modal/index.tsx. + +export { FACTORIES_WITH_BASE_URL } from './generic-api-key-config'; +export { getProviderConfig } from './get-provider-config'; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts new file mode 100644 index 0000000000..5f175c3874 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts @@ -0,0 +1,202 @@ +import { FormFieldType } from '@/components/dynamic-form'; +import { LLMFactory } from '@/constants/llm'; +import type { FieldConfig, ProviderConfig } from '../types'; +import { capitalize } from './utils'; + +/** + * Factory configuration for local/compatible factories + * Used for scenarios after OllamaModal merge + */ +export const LocalLlmConfigs: Record = { + [LLMFactory.Ollama]: buildLocalConfig(LLMFactory.Ollama, 'Ollama', [ + 'chat', + 'embedding', + 'rerank', + 'image2text', + ]), + [LLMFactory.Xinference]: buildLocalConfig( + LLMFactory.Xinference, + 'Xinference', + ['chat', 'embedding', 'rerank', 'image2text', 'speech2text', 'tts'], + 'modelUid', + ), + [LLMFactory.ModelScope]: buildLocalConfig( + LLMFactory.ModelScope, + 'ModelScope', + ['chat'], + ), + [LLMFactory.LocalAI]: buildLocalConfig(LLMFactory.LocalAI, 'LocalAI', [ + 'chat', + 'embedding', + 'rerank', + 'image2text', + ]), + [LLMFactory.LMStudio]: buildLocalConfig(LLMFactory.LMStudio, 'LMStudio', [ + 'chat', + 'embedding', + 'image2text', + ]), + [LLMFactory.OpenAiAPICompatible]: buildLocalConfig( + LLMFactory.OpenAiAPICompatible, + 'OpenAiAPICompatible', + ['chat', 'embedding', 'rerank', 'image2text'], + ), + [LLMFactory.RAGcon]: buildLocalConfig(LLMFactory.RAGcon, 'RAGcon', [ + 'chat', + 'embedding', + 'rerank', + 'image2text', + 'speech2text', + 'tts', + ]), + [LLMFactory.TogetherAI]: buildLocalConfig( + LLMFactory.TogetherAI, + 'TogetherAI', + ['chat', 'embedding', 'rerank', 'image2text'], + ), + [LLMFactory.Replicate]: buildLocalConfig(LLMFactory.Replicate, 'Replicate', [ + 'chat', + 'embedding', + 'rerank', + 'image2text', + ]), + [LLMFactory.OpenRouter]: buildLocalConfig( + LLMFactory.OpenRouter, + 'OpenRouter', + ['chat', 'image2text'], + undefined, + true, + ), + [LLMFactory.HuggingFace]: buildLocalConfig( + LLMFactory.HuggingFace, + 'HuggingFace', + ['embedding', 'chat', 'rerank'], + ), + [LLMFactory.GPUStack]: buildLocalConfig(LLMFactory.GPUStack, 'GPUStack', [ + 'chat', + 'embedding', + 'rerank', + 'speech2text', + 'tts', + ]), + [LLMFactory.VLLM]: buildLocalConfig(LLMFactory.VLLM, 'VLLM', [ + 'chat', + 'embedding', + 'rerank', + 'image2text', + ]), + [LLMFactory.TokenPony]: buildLocalConfig(LLMFactory.TokenPony, 'TokenPony', [ + 'chat', + 'embedding', + 'rerank', + 'image2text', + ]), +}; + +/** + * Build the default configuration for local factories + */ +function buildLocalConfig( + llmFactory: string, + title: string, + modelTypes: string[], + modelNameLabel?: string, + addProviderOrder = false, +): ProviderConfig { + const fields: FieldConfig[] = [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: modelTypes.map((t) => ({ label: capitalize(t), value: t })), + }, + { + name: 'model_name', + label: modelNameLabel ?? 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'modelNameMessage', + }, + { + name: 'base_url', + label: 'addLlmBaseUrl', + type: 'inputSelect', + required: true, + placeholder: 'baseUrlNameMessage', + shouldRender: 'hideWhenInstanceExists', + }, + { + name: 'api_key', + label: 'apiKey', + type: FormFieldType.Text, + required: false, + placeholder: 'apiKeyMessage', + shouldRender: 'hideWhenInstanceExists', + }, + { + name: 'max_tokens', + label: 'maxTokens', + type: FormFieldType.Number, + required: true, + placeholder: 'maxTokensTip', + defaultValue: 8192, + validation: { min: 0, message: 'maxTokensMessage' }, + }, + { + name: 'is_tools', + label: 'enableToolCall', + type: FormFieldType.Switch, + required: false, + shouldRender: 'modelTypeSupportsToolCall', + defaultValue: false, + }, + ]; + + if (addProviderOrder) { + fields.push({ + name: 'provider_order', + label: 'providerOrder', + type: FormFieldType.Text, + required: false, + }); + } + + fields.push({ + name: 'vision', + label: 'vision', + type: FormFieldType.Switch, + required: false, + defaultValue: false, + shouldRender: 'modelTypeIncludesChat', + }); + + return { + llmFactory, + title, + fields, + verifyTransform: (values, modelInfo) => ({ + apiKey: values.api_key || '', + baseUrl: values.base_url, + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: llmFactory, + model_info: modelInfo, + api_base: values.base_url, + api_key: values.api_key, + ...(values.provider_order + ? { provider_order: values.provider_order } + : {}), + }), + }; +} diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/provider-config-map.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/provider-config-map.ts new file mode 100644 index 0000000000..ce18a048f5 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/provider-config-map.ts @@ -0,0 +1,873 @@ +import { FormFieldType } from '@/components/dynamic-form'; +import { LLMFactory } from '@/constants/llm'; +import type { ProviderConfig } from '../types'; + +/** + * Factory configuration mapping table + * key: LLMFactory value + * value: ProviderConfig + */ +export const ProviderConfigMap: Record = { + // ============ Azure OpenAI ============ + [LLMFactory.AzureOpenAI]: { + llmFactory: LLMFactory.AzureOpenAI, + title: 'Azure OpenAI', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: [ + { label: 'Chat', value: 'chat' }, + { label: 'Embedding', value: 'embedding' }, + { label: 'Image2Text', value: 'image2text' }, + ], + defaultValue: ['embedding'], + }, + { + name: 'api_base', + label: 'addLlmBaseUrl', + type: 'inputSelect', + required: true, + placeholder: 'baseUrlNameMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'baseUrlNameMessage' }, + }, + { + name: 'api_key', + label: 'apiKey', + type: FormFieldType.Text, + required: false, + placeholder: 'apiKeyMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'apiKeyMessage' }, + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'modelNameMessage', + defaultValue: 'gpt-3.5-turbo', + validation: { message: 'modelNameMessage' }, + }, + { + name: 'api_version', + label: 'apiVersion', + type: FormFieldType.Text, + required: false, + placeholder: 'apiVersionMessage', + defaultValue: '2024-02-01', + }, + { + name: 'max_tokens', + label: 'maxTokens', + type: FormFieldType.Number, + required: true, + placeholder: 'maxTokensTip', + defaultValue: 8192, + validation: { min: 0, message: 'maxTokensMessage' }, + }, + { + name: 'vision', + label: 'vision', + type: FormFieldType.Switch, + defaultValue: false, + shouldRender: 'modelTypeIncludesChat', + }, + ], + verifyTransform: (values, modelInfo) => ({ + apiKey: values.api_key, + baseUrl: values.api_base, + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: LLMFactory.AzureOpenAI, + api_base: values.api_base, + api_key: values.api_key, + api_version: values.api_version, + model_info: modelInfo, + }), + }, + + // ============ VolcEngine ============ + [LLMFactory.VolcEngine]: { + llmFactory: LLMFactory.VolcEngine, + title: 'VolcEngine', + docLink: 'https://www.volcengine.com/docs/82379/1302008', + docLinkI18nKey: 'ollamaLink', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: [ + { label: 'Chat', value: 'chat' }, + { label: 'Embedding', value: 'embedding' }, + { label: 'Image2Text', value: 'image2text' }, + ], + defaultValue: ['chat'], + }, + // { + // name: 'model_name', + // label: 'modelName', + // type: 'text', + // required: true, + // placeholder: 'volcModelNameMessage', + // validation: { message: 'volcModelNameMessage' }, + // }, + { + name: 'model_name', + label: 'addEndpointID', + type: FormFieldType.Text, + required: true, + placeholder: 'endpointIDMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'endpointIDMessage' }, + }, + { + name: 'api_key', + label: 'addArkApiKey', + type: FormFieldType.Text, + required: true, + placeholder: 'ArkApiKeyMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'ArkApiKeyMessage' }, + }, + { + name: 'max_tokens', + label: 'maxTokens', + type: FormFieldType.Number, + required: true, + placeholder: 'maxTokensTip', + defaultValue: 8192, + validation: { min: 0 }, + }, + ], + verifyTransform: (values, modelInfo) => ({ + apiKey: JSON.stringify({ + ark_api_key: values.api_key, + endpoint_id: values.endpoint_id, + }), + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: LLMFactory.VolcEngine, + endpoint_id: values.endpoint_id, + ark_api_key: values.api_key, + model_info: modelInfo, + }), + }, + + // ============ Google Cloud ============ + [LLMFactory.GoogleCloud]: { + llmFactory: LLMFactory.GoogleCloud, + title: 'Google Cloud', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: [ + { label: 'Chat', value: 'chat' }, + { label: 'Image2Text', value: 'image2text' }, + ], + defaultValue: ['chat'], + }, + { + name: 'model_name', + label: 'modelID', + type: FormFieldType.Text, + required: true, + placeholder: 'GoogleModelIDMessage', + validation: { message: 'GoogleModelIDMessage' }, + }, + { + name: 'google_project_id', + label: 'addGoogleProjectID', + type: FormFieldType.Text, + required: true, + placeholder: 'GoogleProjectIDMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'GoogleProjectIDMessage' }, + }, + { + name: 'google_region', + label: 'addGoogleRegion', + type: FormFieldType.Text, + required: true, + placeholder: 'GoogleRegionMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'GoogleRegionMessage' }, + }, + { + name: 'google_service_account_key', + label: 'addGoogleServiceAccountKey', + type: FormFieldType.Text, + required: true, + placeholder: 'GoogleServiceAccountKeyMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'GoogleServiceAccountKeyMessage' }, + }, + { + name: 'max_tokens', + label: 'maxTokens', + type: FormFieldType.Number, + required: true, + placeholder: 'maxTokensTip', + defaultValue: 8192, + validation: { min: 0, message: 'maxTokensMinMessage' }, + }, + ], + verifyTransform: (values, modelInfo) => ({ + apiKey: JSON.stringify({ + google_project_id: values.google_project_id, + google_region: values.google_region, + google_service_account_key: values.google_service_account_key, + }), + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: LLMFactory.GoogleCloud, + google_project_id: values.google_project_id, + google_region: values.google_region, + google_service_account_key: values.google_service_account_key, + model_info: modelInfo, + }), + }, + + // ============ Tencent Cloud ============ + [LLMFactory.TencentCloud]: { + llmFactory: LLMFactory.TencentCloud, + title: 'Tencent Cloud', + docLink: 'https://cloud.tencent.com/document/api/1093/37823', + docLinkI18nKey: 'TencentCloudLink', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: [{ label: 'Speech2Text', value: 'speech2text' }], + defaultValue: ['speech2text'], + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Select, + required: true, + options: [ + { label: '16k_zh', value: '16k_zh' }, + { label: '16k_zh_large', value: '16k_zh_large' }, + { label: '16k_multi_lang', value: '16k_multi_lang' }, + { label: '16k_zh_dialect', value: '16k_zh_dialect' }, + { label: '16k_en', value: '16k_en' }, + { label: '16k_yue', value: '16k_yue' }, + { label: '16k_zh-PY', value: '16k_zh-PY' }, + { label: '16k_ja', value: '16k_ja' }, + { label: '16k_ko', value: '16k_ko' }, + { label: '16k_vi', value: '16k_vi' }, + { label: '16k_ms', value: '16k_ms' }, + { label: '16k_id', value: '16k_id' }, + { label: '16k_fil', value: '16k_fil' }, + { label: '16k_th', value: '16k_th' }, + { label: '16k_pt', value: '16k_pt' }, + { label: '16k_tr', value: '16k_tr' }, + { label: '16k_ar', value: '16k_ar' }, + { label: '16k_es', value: '16k_es' }, + { label: '16k_hi', value: '16k_hi' }, + { label: '16k_fr', value: '16k_fr' }, + { label: '16k_zh_medical', value: '16k_zh_medical' }, + { label: '16k_de', value: '16k_de' }, + ], + defaultValue: '16k_zh', + validation: { message: 'modelNameMessage' }, + }, + { + name: 'TencentCloud_sid', + label: 'addTencentCloudSID', + type: FormFieldType.Text, + required: true, + placeholder: 'TencentCloudSIDMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'TencentCloudSIDMessage' }, + }, + { + name: 'TencentCloud_sk', + label: 'addTencentCloudSK', + type: FormFieldType.Text, + required: true, + placeholder: 'TencentCloudSKMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'TencentCloudSKMessage' }, + }, + ], + verifyTransform: (values, modelInfo) => ({ + apiKey: JSON.stringify({ + TencentCloud_sid: values.TencentCloud_sid, + TencentCloud_sk: values.TencentCloud_sk, + }), + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: LLMFactory.TencentCloud, + TencentCloud_sid: values.TencentCloud_sid, + TencentCloud_sk: values.TencentCloud_sk, + model_info: modelInfo, + }), + }, + + // ============ XunFei Spark ============ + [LLMFactory.XunFeiSpark]: { + llmFactory: LLMFactory.XunFeiSpark, + title: 'XunFei Spark', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: [ + { label: 'Chat', value: 'chat' }, + { label: 'TTS', value: 'tts' }, + ], + defaultValue: ['chat'], + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'modelNameMessage', + validation: { message: 'modelNameMessage' }, + }, + { + name: 'spark_api_password', + label: 'addSparkAPIPassword', + type: FormFieldType.Text, + required: true, + placeholder: 'SparkAPIPasswordMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'SparkAPIPasswordMessage' }, + }, + { + name: 'spark_app_id', + label: 'addSparkAPPID', + type: FormFieldType.Text, + required: true, + placeholder: 'SparkAPPIDMessage', + shouldRender: 'modelTypeIncludesTtsAndNotExists', + validation: { message: 'SparkAPPIDMessage' }, + }, + { + name: 'spark_api_secret', + label: 'addSparkAPISecret', + type: FormFieldType.Text, + required: true, + placeholder: 'SparkAPISecretMessage', + shouldRender: 'modelTypeIncludesTtsAndNotExists', + validation: { message: 'SparkAPISecretMessage' }, + }, + { + name: 'spark_api_key', + label: 'addSparkAPIKey', + type: FormFieldType.Text, + required: true, + placeholder: 'SparkAPIKeyMessage', + shouldRender: 'modelTypeIncludesTtsAndNotExists', + validation: { message: 'SparkAPIKeyMessage' }, + }, + { + name: 'max_tokens', + label: 'maxTokens', + type: FormFieldType.Number, + required: true, + placeholder: 'maxTokensTip', + defaultValue: 8192, + validation: { min: 0, message: 'maxTokensInvalidMessage' }, + }, + ], + verifyTransform: (values, modelInfo) => ({ + apiKey: JSON.stringify({ + spark_api_password: values.spark_api_password, + spark_app_id: values.spark_app_id, + spark_api_secret: values.spark_api_secret, + spark_api_key: values.spark_api_key, + }), + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: LLMFactory.XunFeiSpark, + model_info: modelInfo, + }), + }, + + // ============ Baidu YiYan ============ + [LLMFactory.BaiduYiYan]: { + llmFactory: LLMFactory.BaiduYiYan, + title: 'Baidu YiYan', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: [ + { label: 'Chat', value: 'chat' }, + { label: 'Embedding', value: 'embedding' }, + { label: 'Rerank', value: 'rerank' }, + ], + defaultValue: ['chat'], + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'yiyanModelNameMessage', + validation: { message: 'yiyanModelNameMessage' }, + }, + { + name: 'yiyan_ak', + label: 'addyiyanAK', + type: FormFieldType.Text, + required: true, + placeholder: 'yiyanAKMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'yiyanAKMessage' }, + }, + { + name: 'yiyan_sk', + label: 'addyiyanSK', + type: FormFieldType.Text, + required: true, + placeholder: 'yiyanSKMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'yiyanSKMessage' }, + }, + { + name: 'max_tokens', + label: 'maxTokens', + type: FormFieldType.Number, + required: true, + placeholder: 'maxTokensTip', + defaultValue: 8192, + validation: { min: 0 }, + }, + ], + verifyTransform: (values, modelInfo) => ({ + apiKey: JSON.stringify({ + yiyan_ak: values.yiyan_ak, + yiyan_sk: values.yiyan_sk, + }), + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: LLMFactory.BaiduYiYan, + api_key: { + yiyan_ak: values.yiyan_ak, + yiyan_sk: values.yiyan_sk, + }, + model_info: modelInfo, + }), + }, + + // ============ Fish Audio ============ + [LLMFactory.FishAudio]: { + llmFactory: LLMFactory.FishAudio, + title: 'Fish Audio', + docLink: 'https://fish.audio', + docLinkI18nKey: 'FishAudioLink', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_type', + label: 'modelType', + type: FormFieldType.MultiSelect, + required: true, + options: [{ label: 'TTS', value: 'tts' }], + defaultValue: ['tts'], + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'FishAudioModelNameMessage', + validation: { message: 'FishAudioModelNameMessage' }, + }, + { + name: 'fish_audio_ak', + label: 'addFishAudioAK', + type: FormFieldType.Text, + required: true, + placeholder: 'FishAudioAKMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'FishAudioAKMessage' }, + }, + { + name: 'fish_audio_refid', + label: 'addFishAudioRefID', + type: FormFieldType.Text, + required: true, + placeholder: 'FishAudioRefIDMessage', + shouldRender: 'hideWhenInstanceExists', + validation: { message: 'FishAudioRefIDMessage' }, + }, + { + name: 'max_tokens', + label: 'maxTokens', + type: FormFieldType.Number, + required: true, + placeholder: 'maxTokensTip', + defaultValue: 8192, + validation: { min: 0, message: 'maxTokensInvalidMessage' }, + }, + ], + verifyTransform: (values, modelInfo) => ({ + apiKey: JSON.stringify({ + fish_audio_ak: values.fish_audio_ak, + fish_audio_refid: values.fish_audio_refid, + }), + modelInfo, + }), + submitTransform: (values, modelInfo) => ({ + instance_name: values.instance_name, + llm_factory: LLMFactory.FishAudio, + fish_audio_ak: values.fish_audio_ak, + fish_audio_refid: values.fish_audio_refid, + model_info: modelInfo, + }), + }, + + // ============ OpenDataLoader ============ + [LLMFactory.OpenDataLoader]: { + llmFactory: LLMFactory.OpenDataLoader, + title: 'OpenDataLoader', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'modelNameMessage', + validation: { message: 'modelNameMessage' }, + }, + { + name: 'opendataloader_apiserver', + label: 'opendataloaderApiserver', + type: FormFieldType.Text, + required: true, + placeholder: 'opendataloaderApiserverPlaceholder', + validation: { message: 'opendataloaderApiserverMessage' }, + }, + { + name: 'opendataloader_api_key', + label: 'apiKey', + type: FormFieldType.Text, + required: false, + placeholder: 'apiKeyPlaceholder', + }, + ], + verifyTransform: (values, modelInfo) => { + const cfg: Record = {}; + if (values.opendataloader_apiserver) { + cfg.opendataloader_apiserver = values.opendataloader_apiserver; + } + if (values.opendataloader_api_key) { + cfg.opendataloader_api_key = values.opendataloader_api_key; + } + return { + apiKey: JSON.stringify(cfg), + baseUrl: values.opendataloader_apiserver, + modelInfo, + }; + }, + submitTransform: (values, modelInfo) => { + const cfg: Record = {}; + if (values.opendataloader_apiserver) { + cfg.opendataloader_apiserver = values.opendataloader_apiserver; + } + if (values.opendataloader_api_key) { + cfg.opendataloader_api_key = values.opendataloader_api_key; + } + return { + instance_name: values.instance_name, + llm_factory: LLMFactory.OpenDataLoader, + api_key: JSON.stringify(cfg), + api_base: '', + model_info: modelInfo, + }; + }, + }, + + // ============ PaddleOCR ============ + [LLMFactory.PaddleOCR]: { + llmFactory: LLMFactory.PaddleOCR, + title: 'PaddleOCR', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'modelNameMessage', + validation: { message: 'modelNameMessage' }, + }, + { + name: 'paddleocr_api_url', + label: 'paddleocrApiUrl', + type: FormFieldType.Text, + required: true, + placeholder: 'paddleocrApiUrlPlaceholder', + validation: { message: 'paddleocrApiUrlMessage' }, + }, + { + name: 'paddleocr_access_token', + label: 'paddleocrAccessToken', + type: FormFieldType.Text, + required: false, + placeholder: 'paddleocrAccessTokenPlaceholder', + validation: { message: 'paddleocrAccessTokenMessage' }, + }, + { + name: 'paddleocr_algorithm', + label: 'paddleocrAlgorithm', + type: FormFieldType.Select, + required: false, + defaultValue: 'PaddleOCR-VL', + placeholder: 'paddleocrSelectAlgorithm', + options: [ + { label: 'PaddleOCR-VL-1.5', value: 'PaddleOCR-VL-1.5' }, + { label: 'PaddleOCR-VL', value: 'PaddleOCR-VL' }, + { label: 'PP-OCRv5', value: 'PP-OCRv5' }, + { label: 'PP-StructureV3', value: 'PP-StructureV3' }, + ], + }, + ], + verifyTransform: (values, modelInfo) => { + const cfg: Record = {}; + if (values.paddleocr_api_url) + cfg.paddleocr_api_url = values.paddleocr_api_url; + if (values.paddleocr_access_token) + cfg.paddleocr_access_token = values.paddleocr_access_token; + if (values.paddleocr_algorithm) + cfg.paddleocr_algorithm = values.paddleocr_algorithm; + return { + apiKey: JSON.stringify(cfg), + baseUrl: values.paddleocr_api_url, + modelInfo, + }; + }, + submitTransform: (values, modelInfo) => { + const cfg: Record = {}; + if (values.paddleocr_api_url) + cfg.paddleocr_api_url = values.paddleocr_api_url; + if (values.paddleocr_access_token) + cfg.paddleocr_access_token = values.paddleocr_access_token; + if (values.paddleocr_algorithm) + cfg.paddleocr_algorithm = values.paddleocr_algorithm; + return { + instance_name: values.instance_name, + llm_factory: LLMFactory.PaddleOCR, + api_key: JSON.stringify(cfg), + api_base: '', + model_info: modelInfo, + }; + }, + }, + + // ============ MinerU ============ + [LLMFactory.MinerU]: { + llmFactory: LLMFactory.MinerU, + title: 'MinerU', + fields: [ + { + name: 'instance_name', + label: 'instanceName', + type: FormFieldType.Text, + required: true, + placeholder: 'instanceNameMessage', + tooltip: 'instanceNameTip', + validation: { message: 'instanceNameMessage' }, + }, + { + name: 'model_name', + label: 'modelName', + type: FormFieldType.Text, + required: true, + placeholder: 'modelNameMessage', + validation: { message: 'modelNameMessage' }, + }, + { + name: 'mineru_apiserver', + label: 'mineruApiserver', + type: FormFieldType.Text, + required: true, + placeholder: 'mineruApiserverPlaceholder', + validation: { message: 'mineruApiserverMessage' }, + }, + { + name: 'mineru_output_dir', + label: 'mineruOutputDir', + type: FormFieldType.Text, + required: false, + placeholder: 'mineruOutputDirPlaceholder', + }, + { + name: 'mineru_backend', + label: 'mineruBackend', + type: FormFieldType.Select, + required: true, + defaultValue: 'pipeline', + placeholder: 'mineruSelectBackend', + options: [ + { label: 'pipeline', value: 'pipeline' }, + { label: 'vlm-transformers', value: 'vlm-transformers' }, + { label: 'vlm-vllm-engine', value: 'vlm-vllm-engine' }, + { label: 'vlm-http-client', value: 'vlm-http-client' }, + { label: 'vlm-mlx-engine', value: 'vlm-mlx-engine' }, + { label: 'vlm-vllm-async-engine', value: 'vlm-vllm-async-engine' }, + { label: 'vlm-lmdeploy-engine', value: 'vlm-lmdeploy-engine' }, + ], + validation: { message: 'mineruBackendMessage' }, + }, + { + name: 'mineru_server_url', + label: 'mineruServerUrl', + type: FormFieldType.Text, + required: false, + placeholder: 'mineruServerUrlPlaceholder', + shouldRender: (values: any) => + values?.mineru_backend === 'vlm-http-client', + validation: { message: 'mineruServerUrlMessage' }, + }, + { + name: 'mineru_delete_output', + label: 'mineruDeleteOutput', + type: FormFieldType.Switch, + required: false, + defaultValue: true, + }, + ], + verifyTransform: (values, modelInfo) => { + const cfg: Record = { ...values }; + delete cfg.instance_name; + delete cfg.model_name; + cfg.mineru_delete_output = values.mineru_delete_output ? '1' : '0'; + if (values.mineru_backend !== 'vlm-http-client') { + delete cfg.mineru_server_url; + } + return { + apiKey: JSON.stringify(cfg), + baseUrl: values.mineru_apiserver, + modelInfo, + }; + }, + submitTransform: (values, modelInfo) => { + const cfg: Record = { ...values }; + delete cfg.instance_name; + delete cfg.model_name; + cfg.mineru_delete_output = values.mineru_delete_output ? '1' : '0'; + if (values.mineru_backend !== 'vlm-http-client') { + delete cfg.mineru_server_url; + } + return { + instance_name: values.instance_name, + llm_factory: LLMFactory.MinerU, + api_key: JSON.stringify(cfg), + api_base: '', + model_info: modelInfo, + }; + }, + }, +}; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/utils.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/utils.ts new file mode 100644 index 0000000000..53c3482c54 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/utils.ts @@ -0,0 +1,24 @@ +/** + * Capitalize the first letter of a string + */ +export function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * When model_type contains chat and vision=true, automatically add image2text + */ +export function applyChatToImage2Text( + modelType: string[] | string | undefined, + vision?: boolean, +): string[] { + const arr = Array.isArray(modelType) + ? modelType + : modelType + ? [modelType] + : []; + if (arr.includes('chat') && vision) { + return [...arr, 'image2text']; + } + return arr; +} diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/index.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/index.ts new file mode 100644 index 0000000000..6ebbee5ee5 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/index.ts @@ -0,0 +1,4 @@ +export { useListModelsOptions } from './use-list-models-options'; +export { useListModelsPicker } from './use-list-models-picker'; +export { useProviderFields } from './use-provider-fields'; +export { useProviderModalActions } from './use-provider-modal-actions'; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx new file mode 100644 index 0000000000..0f9973dbd5 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx @@ -0,0 +1,103 @@ +import { Checkbox } from '@/components/ui/checkbox'; +import { useTranslate } from '@/hooks/common-hooks'; +import { IProviderModelItem } from '@/interfaces/request/llm'; +import { useMemo } from 'react'; + +interface UseListModelsOptionsParams { + models: IProviderModelItem[]; + selectedModelItems: IProviderModelItem[]; + allSelected: boolean; + handleSelectModel: (model: IProviderModelItem) => void; + handleToggleAll: () => void; +} + +/** + * Build ToggleList options from the fetched model list. The first item is + * a sentinel "All models" row that toggles the full selection. + * + * Why the Checkbox uses `onClick` (not `onCheckedChange`): + * Radix Checkbox calls `event.stopPropagation()` internally on its onClick + * when the Checkbox lives inside a form, so the row's `onClick` (attached + * by ToggleList) never fires when the user clicks the Checkbox itself. + * To make the Checkbox click toggle selection, we handle it in our own + * `onClick` and re-stop propagation to (a) prevent the row's onClick from + * double-firing and (b) make Radix's CheckboxBubbleInput dispatch a + * non-bubbling synthetic click on its hidden form input — without this, + * the dispatched click would bubble back to the row and re-trigger the + * toggle, causing "Maximum update depth exceeded". + */ +export const useListModelsOptions = ({ + models, + selectedModelItems, + allSelected, + handleSelectModel, + handleToggleAll, +}: UseListModelsOptionsParams) => { + const { t } = useTranslate('setting'); + + return useMemo(() => { + const allOption = { + value: null as string | null, + label: ( +
+
+
{t('allModels')}
+
+ { + e.stopPropagation(); + handleToggleAll(); + }} + /> +
+ ), + onClick: () => handleToggleAll(), + }; + + const modelOptions = models.map((m) => { + const checked = selectedModelItems.some((s) => s.name === m.name); + return { + value: m.name, + label: ( +
+
+
{m.name}
+ {m.model_types && + m.model_types.map((type) => { + return ( +
+ {type} +
+ ); + })} +
+ { + e.stopPropagation(); + handleSelectModel(m); + }} + /> +
+ ), + onClick: () => handleSelectModel(m), + }; + }); + if (modelOptions?.length) { + return [allOption, ...modelOptions]; + } else { + return []; + } + }, [ + models, + selectedModelItems, + handleSelectModel, + allSelected, + handleToggleAll, + 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 new file mode 100644 index 0000000000..618b204833 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts @@ -0,0 +1,275 @@ +import { DynamicFormRef } from '@/components/dynamic-form'; +import { useListProviderModels } from '@/hooks/use-llm-request'; +import { IModelInfo, IProviderModelItem } from '@/interfaces/request/llm'; +import { + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { ProviderConfig } from '../types'; + +// Derive is_tools from a model descriptor's `features` array. A model is +// considered tool-capable if it advertises either `tool_call` or +// `function_call`. Returns `undefined` when the model has no features info. +const getIsToolsFromFeatures = ( + features: IProviderModelItem['features'], +): boolean | undefined => { + if (!Array.isArray(features)) return undefined; + return features.includes('tool_call') || features.includes('function_call'); +}; + +// Map a fetched list-model item to the request-side IModelInfo shape. +// Per-model extras (such as is_tools) live under the `extra` object so the +// addProviderInstance API receives them in the shape the backend expects. +const toIModelInfo = (item: IProviderModelItem): IModelInfo => { + const is_tools = getIsToolsFromFeatures(item.features); + return { + model_name: item.name, + model_type: item.model_types ?? [], + max_tokens: item.max_tokens ?? 0, + ...(is_tools !== undefined ? { extra: { is_tools } } : {}), + }; +}; + +// Compare the model_type sets from an initial IModelInfo entry and a +// freshly fetched list item. Tolerates string-vs-array differences so a +// legacy single-value `model_type` still matches the list's array shape. +const modelTypesMatch = ( + initial: string | string[] | undefined, + fetched: string[] | undefined, +): boolean => { + const a = Array.isArray(initial) ? initial : initial ? [initial] : []; + const b = Array.isArray(fetched) ? fetched : []; + if (a.length !== b.length) return false; + const sb = new Set(b); + return a.every((t) => sb.has(t)); +}; + +interface UseListModelsPickerParams { + visible: boolean; + hasModelNameField: boolean; + editMode?: boolean; + viewMode?: boolean; + initialValues?: Record; + llmFactory: string; + config: ProviderConfig; + formRef: RefObject; +} + +/** + * Owns all state for the "List Models" picker: + * - fetched model catalog (`models`) and loading flag + * - currently checked items (`selectedModelItems`) + * - derived `modelInfoList` payload used at verify/submit time + * - `allSelected` flag and the all/none/individual toggle handlers + * - the API fetch (with edit-mode pre-check seeding) and modal-reset effect + * + * The picker lives entirely in component state — no form fields are touched. + * A reentrancy ref guards against Radix Checkbox's double-click dispatch + * (CheckboxBubbleInput re-fires onClick inside a form). + */ +export const useListModelsPicker = ({ + visible, + hasModelNameField, + editMode, + viewMode, + initialValues, + llmFactory, + config, + formRef, +}: UseListModelsPickerParams) => { + const [models, setModels] = 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< + IProviderModelItem[] + >([]); + // Edit-mode seed: the model_info array stored on the existing instance. + // Used to pre-check list items once the model list is fetched. + const initialModelInfoRef = useRef(null); + const { listProviderModels } = useListProviderModels(); + + // Reentrancy guard for selection toggles. When a Checkbox inside the form is + // clicked, Radix's CheckboxBubbleInput dispatches a synthetic click on its + // hidden form input that bubbles up to the row's onClick — so each user + // click fires the toggle handler twice in the same tick. Calling setState + // twice (especially for the all/none toggle below) trips React's + // "Maximum update depth exceeded" guard. The ref short-circuits the second + // call and is released on the next macrotask. + const selectionLockRef = useRef(false); + + // Derived: the model_info array passed to verify/submit. One entry per + // checked list item, with is_tools derived from the model's `features`. + const modelInfoList: IModelInfo[] = useMemo( + () => selectedModelItems.map(toIModelInfo), + [selectedModelItems], + ); + + // "All models" is checked when every fetched model is selected. + const allSelected = useMemo( + () => models.length > 0 && selectedModelItems.length === models.length, + [models.length, selectedModelItems.length], + ); + + // Capture the initial model_info array (edit mode or viewMode) so we + // can match it against the fetched list once it arrives and pre-check + // the right items. + useEffect(() => { + if (!visible) return; + initialModelInfoRef.current = null; + if ((editMode || viewMode) && initialValues) { + const initial = (initialValues as any).model_info; + if (Array.isArray(initial)) { + initialModelInfoRef.current = initial as IModelInfo[]; + } else if ( + (initialValues as any).model_name && + (initialValues as any).model_type + ) { + // Legacy fallback: a single-model payload may still arrive as flat + // fields. Wrap it so the matcher below has a uniform input shape. + initialModelInfoRef.current = [ + { + model_name: (initialValues as any).model_name, + model_type: (initialValues as any).model_type, + max_tokens: (initialValues as any).max_tokens ?? 0, + }, + ]; + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, editMode, viewMode]); + + // Triggered by ToggleList's onOpenChange — fires the API call with the + // same payload shape as verifyProviderConnection. Caches the result in + // component state so subsequent opens don't re-fetch. In edit mode, the + // fetched list is then matched against `initialModelInfoRef` so any model + // that was already configured is pre-checked. + const handleListOpenChange = useCallback( + async (open: boolean) => { + if (!open || !hasModelNameField) return; + if (models.length > 0 || listLoading) return; + setListLoading(true); + try { + const values = (formRef.current?.getValues() || {}) as Record< + string, + any + >; + // Reuse the verifyTransform to build the request payload — it + // already knows how to flatten provider-specific auth (api_key, + // base_url, region, model_info). Pass the current modelInfoList + // (empty on first load, populated on re-opens) so the backend + // sees an array shape consistent with the verify/submit payloads. + const verifyArgs = config.verifyTransform + ? config.verifyTransform(values, modelInfoList) + : { apiKey: values.api_key ?? '', baseUrl: values.base_url }; + const res = await listProviderModels({ + provider_name: llmFactory, + api_key: (verifyArgs as any).apiKey ?? '', + base_url: (verifyArgs as any).baseUrl, + region: (verifyArgs as any).region, + model_info: (verifyArgs as any).modelInfo ?? modelInfoList, + }); + if (res?.code === 0 && Array.isArray(res.data)) { + setModels(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`. + const seed = initialModelInfoRef.current; + if (seed && seed.length > 0) { + const matched = res.data.filter((m: IProviderModelItem) => + seed.some( + (s) => + s.model_name === m.name && + modelTypesMatch(s.model_type, m.model_types), + ), + ); + if (matched.length > 0) { + setSelectedModelItems(matched); + } + } + } + } catch (err) { + console.error('Failed to fetch provider models:', err); + } finally { + setListLoading(false); + } + }, + [ + hasModelNameField, + models.length, + listLoading, + config, + listProviderModels, + llmFactory, + modelInfoList, + formRef, + ], + ); + + // Toggling a list item: add it to `selectedModelItems` if absent, remove + // it otherwise. No form fields are touched — selection lives entirely + // in component state and is surfaced as `modelInfoList` at verify/submit. + const handleSelectModel = useCallback((model: IProviderModelItem) => { + if (selectionLockRef.current) return; + selectionLockRef.current = true; + setSelectedModelItems((prev) => { + const idx = prev.findIndex((p) => p.name === model.name); + if (idx >= 0) { + const next = prev.slice(); + next.splice(idx, 1); + return next; + } + return [...prev, model]; + }); + setTimeout(() => { + selectionLockRef.current = false; + }, 0); + }, []); + + // Toggling the "All models" row: select every model when none/all are + // unselected, otherwise clear the selection. Mirrors the per-item toggle + // semantics so the UI stays consistent (re-clicking all = empty). + // The reentrancy guard here is critical: without it, the second call sees + // `prev.length === models.length` (from the first call's `models.slice()`) + // and returns `[]`, producing two setState calls per click and triggering + // the "Maximum update depth exceeded" error. + const handleToggleAll = useCallback(() => { + if (selectionLockRef.current) return; + selectionLockRef.current = true; + setSelectedModelItems((prev) => { + if (prev.length === models.length) { + return []; + } + return models.slice(); + }); + setTimeout(() => { + selectionLockRef.current = false; + }, 0); + }, [models]); + + // Reset everything when the modal is closed. + useEffect(() => { + if (!visible) { + formRef.current?.reset(); + setModels([]); + setSelectedModelItems([]); + setListLoading(false); + initialModelInfoRef.current = null; + } + }, [visible, formRef]); + + return { + models, + listLoading, + selectedModelItems, + modelInfoList, + allSelected, + handleListOpenChange, + handleSelectModel, + handleToggleAll, + }; +}; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-fields.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-fields.tsx new file mode 100644 index 0000000000..0529bc0166 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-fields.tsx @@ -0,0 +1,297 @@ +import { FormFieldConfig, FormFieldType } from '@/components/dynamic-form'; +import { Input } from '@/components/ui/input'; +import { InputSelect } from '@/components/ui/input-select'; +import { useTranslate } from '@/hooks/common-hooks'; +import { useMemo } from 'react'; +import { ControllerRenderProps, FieldValues } from 'react-hook-form'; +import { LIST_MODEL_FIELD_NAMES, LIST_MODEL_PROVIDERS } from '../constants'; +import { FACTORIES_WITH_BASE_URL, getProviderConfig } from '../field-config'; +import type { FieldConfig, SelectOption } from '../types'; + +interface UseProviderFieldsParams { + llmFactory: string; + editMode?: boolean; + viewMode?: boolean; + initialValues?: Record; + baseUrlOptions?: SelectOption[]; + hideWhenInstanceExists: (values: Record) => boolean; +} + +/** + * Resolve a text value that may be a static i18n key or a factory-aware + * resolver, then translate it via `t`. Used for `placeholder` and `tooltip` + * so that a single FieldConfig entry can render different text per provider + * (e.g. the generic `base_url` field has a Minimax-specific tooltip). + */ +const resolveText = ( + val: string | ((factory: string) => string) | undefined, + factory: string, + t: (key: string) => string, +): string | undefined => { + if (!val) return undefined; + const key = typeof val === 'function' ? val(factory) : val; + return t(key); +}; + +/** Set value by nested path (supports paths like 'model_info.model_type'). */ +const setNestedValue = (obj: any, path: string, value: any) => { + const keys = path.split('.'); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + } + current[keys[keys.length - 1]] = value; +}; + +/** + * Builds the form field config, default values, and doc-link text for the + * Provider modal. Handles: + * - Per-provider text resolution (placeholder / tooltip). + * - shouldRender token → predicate resolution. + * - Hiding the 4 model_* fields when the list-models picker is active. + * - Disabling non-model fields in viewMode. + * - Custom inputSelect rendering (Input + dropdown of suggestions). + */ +export const useProviderFields = ({ + llmFactory, + editMode, + viewMode, + initialValues, + baseUrlOptions, + hideWhenInstanceExists, +}: UseProviderFieldsParams) => { + const { t } = useTranslate('setting'); + + const config = useMemo(() => getProviderConfig(llmFactory), [llmFactory]); + + // Whether this factory should render the "List Models" picker. Only the + // providers listed in `LIST_MODEL_PROVIDERS` opt into the picker; all + // others keep the traditional model_name / model_type / max_tokens / + // is_tools form fields. + const hasModelNameField = useMemo( + () => LIST_MODEL_PROVIDERS.has(llmFactory), + [llmFactory], + ); + + // Resolve the shouldRender string token to an actual predicate. + const resolveShouldRender = useMemo(() => { + return (sr: FieldConfig['shouldRender']) => { + if (!sr) return undefined; + if (typeof sr === 'function') return sr; + + switch (sr) { + case 'hideWhenInstanceExists': + return hideWhenInstanceExists; + case 'modelTypeIncludesChat': + return (values: any) => { + const mt = values?.model_type; + if (Array.isArray(mt)) return mt.includes('chat'); + return mt === 'chat'; + }; + case 'modelTypeSupportsToolCall': + return (values: any) => { + const mt = values?.model_type; + if (Array.isArray(mt)) { + return mt.includes('chat') || mt.includes('image2text'); + } + return mt === 'chat' || mt === 'image2text'; + }; + case 'modelTypeIncludesTtsAndNotExists': + return (values: any) => { + if (!hideWhenInstanceExists(values)) return false; + const mt = values?.model_type; + if (Array.isArray(mt)) return mt.includes('tts'); + return mt === 'tts'; + }; + case 'showBaseUrl': + return () => + FACTORIES_WITH_BASE_URL.some((x) => x === llmFactory) || + llmFactory?.toLowerCase() === 'Anthropic'.toLowerCase(); + case 'showGroupId': + return () => llmFactory?.toLowerCase() === 'Minimax'.toLowerCase(); + default: + return undefined; + } + }; + }, [hideWhenInstanceExists, llmFactory]); + + // For each inputSelect field, build a URL → regionKey map from its + // options (either inline `field.options` or the shared `baseUrlOptions`). + // The map is used both to pick the "default" key's URL as the form's + // initial value and (downstream, in useProviderModalActions) to derive + // the `region` submit field from the user's currently selected URL. + const baseUrlRegionMaps = useMemo(() => { + const maps: Record> = {}; + config.fields.forEach((field) => { + if (field.type !== 'inputSelect') return; + const options = + field.options && field.options.length > 0 + ? field.options + : (baseUrlOptions ?? []); + const urlMap = new Map(); + options.forEach((opt) => { + if (opt.regionKey) { + urlMap.set(opt.value, opt.regionKey); + } + }); + if (urlMap.size > 0) { + maps[field.name] = urlMap; + } + }); + return maps; + }, [config.fields, baseUrlOptions]); + + // Convert FieldConfig to FormFieldConfig (for use by DynamicForm) + const fields: FormFieldConfig[] = useMemo(() => { + const res = config.fields + .filter( + // When the list-models picker is active, the 4 model_* fields are + // owned by the picker (component state) and must not be registered + // in the dynamic form. + (field) => + !hasModelNameField || !LIST_MODEL_FIELD_NAMES.has(field.name), + ) + .map((field) => { + const placeholderText = resolveText(field.placeholder, llmFactory, t); + const tooltipText = resolveText(field.tooltip, llmFactory, t); + const validation = field.validation + ? { + ...field.validation, + message: field.validation.message + ? t(field.validation.message) + : undefined, + } + : undefined; + const baseField: Omit = { + name: field.name, + label: t(field.label), + required: field.required, + hidden: false, + placeholder: placeholderText, + tooltip: tooltipText, + options: field.options?.map((o) => ({ + label: o.label as string, + value: o.value, + })) as any, + defaultValue: field.defaultValue, + validation, + shouldRender: resolveShouldRender(field.shouldRender), + // In viewMode, only the model-related fields are editable. + // All other fields (instance_name, api_key, base_url, etc.) + // are rendered as disabled. + disabled: !!viewMode && !LIST_MODEL_FIELD_NAMES.has(field.name), + dependencies: + field.shouldRender === 'modelTypeIncludesChat' || + field.shouldRender === 'modelTypeSupportsToolCall' || + field.shouldRender === 'modelTypeIncludesTtsAndNotExists' + ? ['model_type'] + : ['model_type', 'instance_name'].includes(field.name) + ? ['model_type', 'instance_name'] + : undefined, + }; + + // inputSelect type: use the InputSelect component, options come from baseUrlOptions + if (field.type === 'inputSelect') { + const inputSelectOptions: SelectOption[] = + field.options && field.options.length > 0 + ? field.options + : (baseUrlOptions ?? []); + return { + ...baseField, + type: FormFieldType.Custom, + options: inputSelectOptions as any, + render: (fieldProps: ControllerRenderProps) => { + return inputSelectOptions.length > 0 ? ( + fieldProps.onChange(value)} + options={inputSelectOptions as any} + placeholder={placeholderText} + /> + ) : ( + + ); + }, + }; + } + + // Other types use the enum value directly from the field config + return { ...baseField, type: field.type }; + }); + return res; + }, [ + config.fields, + resolveShouldRender, + t, + baseUrlOptions, + llmFactory, + hasModelNameField, + viewMode, + ]); + + const defaultValues: FieldValues = useMemo(() => { + // In editMode or viewMode, seed the form with the supplied + // `initialValues` so the user sees the existing instance/model data. + if ((editMode || viewMode) && initialValues) { + return initialValues as FieldValues; + } + const result: FieldValues = {}; + config.fields.forEach((f) => { + if (f.defaultValue !== undefined) { + setNestedValue(result, f.name, f.defaultValue); + return; + } + // For inputSelect fields, default the form to the option whose + // original key in the URL object is 'default' (e.g. + // `availableProviders.url.default`). If no such option exists, + // leave the field empty so the user picks one explicitly. + if (f.type === 'inputSelect') { + const urlMap = baseUrlRegionMaps[f.name]; + if (urlMap) { + for (const [url, regionKey] of urlMap.entries()) { + if (regionKey === 'default') { + setNestedValue(result, f.name, url); + return; + } + } + } + } + }); + // For configurations without a default model_type, assign an empty array (multi-select field) + const mtField = config.fields.find((f) => f.name === 'model_type'); + if (mtField && mtField.defaultValue === undefined) { + setNestedValue(result, 'model_type', []); + } + return result; + }, [ + editMode, + viewMode, + initialValues, + config.fields, + llmFactory, + baseUrlRegionMaps, + ]); + + // Documentation link text (rendered at the bottom of the modal) + const docLinkText = useMemo(() => { + if (config.docLinkText) return config.docLinkText; + if (config.docLinkI18nKey) { + return t(config.docLinkI18nKey, { name: llmFactory }); + } + return null; + }, [config, llmFactory, t]); + + return { + config, + fields, + defaultValues, + docLinkText, + hasModelNameField, + baseUrlRegionMaps, + }; +}; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-modal-actions.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-modal-actions.ts new file mode 100644 index 0000000000..47c08a37b2 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-provider-modal-actions.ts @@ -0,0 +1,170 @@ +import { DynamicFormRef } from '@/components/dynamic-form'; +import { IModelInfo } from '@/interfaces/request/llm'; +import { VerifyResult } from '@/pages/user-setting/setting-model/hooks'; +import { RefObject, useCallback } from 'react'; +import { FieldValues } from 'react-hook-form'; +import type { + IViewModeOkPayload, + ProviderConfig, + ProviderModalProps, +} from '../types'; + +type ActionParams = { + config: ProviderConfig; + viewMode?: boolean; + hasModelNameField: boolean; + llmFactory: string; + initialValues?: Record; + modelInfoList: IModelInfo[]; + formRef: RefObject; + /** + * URL → regionKey map for each inputSelect field (built in + * `useProviderFields`). Used to derive the `region` submit field + * from the user's currently selected base URL. + */ + baseUrlRegionMaps?: Record>; + onOk: ProviderModalProps['onOk']; + onVerify: ProviderModalProps['onVerify']; + onViewModeOk: ProviderModalProps['onViewModeOk']; +}; + +/** + * Look up the `region` key (e.g. 'default', 'intl', 'cn') for the + * currently selected base URL of any inputSelect field. Returns + * `undefined` when no inputSelect field has a value, or when that + * value does not match any option's URL — in those cases the caller + * should leave `region` unset. + */ +const resolveRegionFromValues = ( + values: Record | undefined, + baseUrlRegionMaps?: Record>, +): string | undefined => { + if (!values || !baseUrlRegionMaps) return undefined; + for (const fieldName of Object.keys(baseUrlRegionMaps)) { + const url = values[fieldName]; + if (typeof url !== 'string' || url === '') continue; + const regionKey = baseUrlRegionMaps[fieldName].get(url); + if (regionKey !== undefined) { + return regionKey; + } + } + return undefined; +}; + +/** + * Build the two outbound handlers for the Provider modal: + * + * - `handleVerify` reads current form values, runs them through the + * provider's `verifyTransform`, and forwards the result to `onVerify`. + * Returns a `VerifyResult` (the VerifyButton consumes the shape). + * + * - `handleSubmit` has two paths: + * 1. viewMode → invoke `onViewModeOk` with either the picker's selected + * models (LIST_MODEL_PROVIDERS) or the editable form values + * (non-LIST_MODEL_PROVIDERS). The instance itself is not re-saved. + * 2. normal mode → run values through `submitTransform` (when present) + * and forward to `onOk`. + * + * Both paths inject a `region` field derived from the currently selected + * base URL whenever the field is an inputSelect (see `baseUrlRegionMaps`). + */ +export const useProviderModalActions = ({ + config, + viewMode, + hasModelNameField, + llmFactory, + initialValues, + modelInfoList, + formRef, + baseUrlRegionMaps, + onOk, + onVerify, + onViewModeOk, +}: ActionParams) => { + const handleVerify = useCallback( + async (params: any) => { + const values = formRef.current?.getValues() || params; + if (!config.verifyTransform) { + return { isValid: null, logs: '' } as VerifyResult; + } + const verifyArgs = config.verifyTransform(values, modelInfoList); + const region = resolveRegionFromValues(values, baseUrlRegionMaps); + if (region !== undefined) { + verifyArgs.region = region; + } + const res = await onVerify({ ...params, ...verifyArgs }); + return (res || { isValid: null, logs: '' }) as VerifyResult; + }, + [config, onVerify, modelInfoList, formRef, baseUrlRegionMaps], + ); + + const handleSubmit = useCallback( + async (values?: FieldValues) => { + if (!values) return; + + // viewMode: only add/update models. The instance itself is not + // re-saved because all instance-level fields are disabled. The + // parent receives the selected models (or the model-related form + // values for non-list-model providers) via `onViewModeOk`. + if (viewMode) { + if (!onViewModeOk) { + // No viewMode handler provided — nothing to save, just close + // (the modal's own hideModal flow handles closing). + return; + } + const instanceName = String( + (initialValues as any)?.instance_name ?? '', + ); + const payload: IViewModeOkPayload = hasModelNameField + ? { + instanceName, + llmFactory, + modelInfos: modelInfoList, + } + : { + instanceName, + llmFactory, + modelInfos: [], + formValues: values as Record, + }; + await onViewModeOk(payload); + return; + } + + const transformed = ( + config.submitTransform + ? config.submitTransform(values as Record, modelInfoList) + : values + ) as Record; + const region = resolveRegionFromValues( + values as Record, + baseUrlRegionMaps, + ); + if (region !== undefined) { + transformed.region = region; + } + // Always include `llm_factory` in the submitted payload. Some + // providers' submitTransforms (e.g. GenericApiKeyConfig) omit it, + // but the parent uses it to build the request URL + // (`/api/v1/providers/${llm_factory}/instances`); without it the + // URL becomes `/providers/undefined/instances`. + if (!transformed.llm_factory) { + transformed.llm_factory = llmFactory; + } + await onOk?.(transformed, false); + }, + [ + config, + onOk, + onViewModeOk, + modelInfoList, + viewMode, + hasModelNameField, + llmFactory, + initialValues, + baseUrlRegionMaps, + ], + ); + + return { handleVerify, handleSubmit }; +}; 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 new file mode 100644 index 0000000000..682b3d9cc3 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx @@ -0,0 +1,199 @@ +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 { + useFetchInstanceNameSet, + useHideWhenInstanceExists, +} from '@/pages/user-setting/setting-model/hooks'; +import { memo, useRef } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { LLMHeader } from '../../components/llm-header'; +import VerifyButton from '../verify-button'; +import { + useListModelsOptions, + useListModelsPicker, + useProviderFields, + useProviderModalActions, +} from './hooks'; +import type { ProviderModalProps } from './types'; + +const ProviderModal = ({ + visible, + hideModal, + llmFactory, + loading, + editMode, + viewMode, + initialValues, + baseUrlOptions, + onOk, + onVerify, + onViewModeOk, +}: ProviderModalProps) => { + const { t } = useTranslate('setting'); + const { t: tc } = useCommonTranslation(); + const formRef = useRef(null); + const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); + const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); + + // Field config, default values, doc link, and the LIST_MODEL_PROVIDERS + // flag are all derived from the current llmFactory / mode / initialValues. + // `baseUrlRegionMaps` is forwarded to the actions hook so the modal can + // populate the `region` submit field from the currently selected base URL. + const { + config, + fields, + defaultValues, + docLinkText, + hasModelNameField, + baseUrlRegionMaps, + } = useProviderFields({ + llmFactory, + editMode, + viewMode, + initialValues, + baseUrlOptions, + hideWhenInstanceExists, + }); + + // Owns the "List Models" picker state and lifecycle. When + // `hasModelNameField` is false the picker is hidden and this hook is + // effectively idle (no fetch, no selection state in use). + const { + models, + listLoading, + selectedModelItems, + modelInfoList, + allSelected, + handleListOpenChange, + handleSelectModel, + handleToggleAll, + } = useListModelsPicker({ + visible, + hasModelNameField, + editMode, + viewMode, + initialValues, + llmFactory, + config, + formRef, + }); + + // Render-only: turn the fetched model list into ToggleList options with + // the "All models" sentinel row at the top. + const listModelsOptions = useListModelsOptions({ + models, + selectedModelItems, + allSelected, + handleSelectModel, + handleToggleAll, + }); + + // Submit and verify handlers — branch on viewMode and on whether the + // picker owns the model fields. + const { handleVerify, handleSubmit } = useProviderModalActions({ + config, + viewMode, + hasModelNameField, + llmFactory, + initialValues, + modelInfoList, + formRef, + baseUrlRegionMaps, + onOk, + onVerify, + onViewModeOk, + }); + + return ( + } + open={visible || false} + onOpenChange={(open) => !open && hideModal?.()} + maskClosable={false} + footer={
} + > + { + // The actual submission is handled by SavingButton + }} + ref={formRef} + defaultValues={defaultValues} + labelClassName="font-normal" + > + {hasModelNameField && ( + 0 + ? listModelsOptions + : [] + } + searchPlaceholder={t('listModelsSearchPlaceholder')} + emptyText={t('listModelsEmpty')} + searchLoading={listLoading} + onOpenChange={handleListOpenChange} + maxHeight={400} + closeOnOutsideClick + /> + )} + + + +
+ {docLinkText && config.docLink && ( + + {docLinkText} + + )} + +
+ { + hideModal?.(); + }} + /> + { + handleSubmit(values); + }} + /> +
+
+
+
+ ); +}; + +export default memo(ProviderModal); + +// Export field configurations (for use by other modules) +export { FACTORIES_WITH_BASE_URL, getProviderConfig } from './field-config'; +export type { + FieldConfig, + IViewModeOkPayload, + ProviderConfig, + ProviderModalProps, + ShouldRenderToken, +} from './types'; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/types.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/types.ts new file mode 100644 index 0000000000..4ebd845104 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/types.ts @@ -0,0 +1,194 @@ +import { FormFieldType } from '@/components/dynamic-form'; +import type { IModelInfo } from '@/interfaces/request/llm'; +import type { ReactNode } from 'react'; + +/** + * Form field types. + * - `FormFieldType.*` values map 1:1 to DynamicForm's field types. + * - `'inputSelect'` is a project-specific token: a text input combined with a + * dropdown of suggested values. The ProviderModal resolves it into + * `FormFieldType.Custom` with a custom `render` function. + */ +export type FieldType = FormFieldType | 'inputSelect'; + +/** + * String tokens for shouldRender + * The component resolves these into actual functions based on runtime context (instanceNameSet, etc.) + */ +export type ShouldRenderToken = + | 'hideWhenInstanceExists' + | 'modelTypeIncludesChat' + | 'modelTypeSupportsToolCall' + | 'modelTypeIncludesTtsAndNotExists' + | 'showBaseUrl' + | 'showGroupId'; + +/** + * Option label can be a string or ReactNode (used for rich-text labels in InputSelect). + * `regionKey` is the original key from the provider's `url` object (e.g. 'default', + * 'intl', 'cn'). It is preserved on the option so that the modal can map the + * currently selected URL back to its key for the `region` submit field. + */ +export type SelectOption = { + label: string | ReactNode; + value: string; + regionKey?: string; +}; + +/** + * Resolver for a text value that may differ by factory (provider). + * Use when a shared FieldConfig entry needs different i18n keys per provider + * (e.g. the generic `base_url` field renders different tooltip / placeholder + * for Minimax, TongYiQianWen, SILICONFLOW, etc.). + */ +export type FactoryTextResolver = (llmFactory: string) => string; + +/** + * Field config: defines the presentation and behavior of a form field + */ +export interface FieldConfig { + /** Field name (supports nested paths, e.g. 'model_info.model_type') */ + name: string; + /** Label i18n key */ + label: string; + /** Field type */ + type: FieldType; + /** Whether the field is required */ + required?: boolean; + /** + * Placeholder i18n key. May be a static key, or a function that takes the + * current `llmFactory` and returns the key (for per-provider placeholders). + */ + placeholder?: string | FactoryTextResolver; + /** + * Tooltip i18n key. May be a static key, or a function that takes the + * current `llmFactory` and returns the key (for per-provider tooltips). + */ + tooltip?: string | FactoryTextResolver; + /** Options (used for select/multiSelect/inputSelect) */ + options?: SelectOption[]; + /** Default value */ + defaultValue?: any; + /** + * Validation rules. + * `message` is treated as an i18n key by the ProviderModal and translated + * via `t()` at field-build time. In `Number` fields, `min` / `max` bound + * the value; the message is shown when the bound is violated. + */ + validation?: { + min?: number; + max?: number; + message?: string; + }; + /** + * Conditional rendering: returns true to show the field + * @param values current form values + */ + shouldRender?: ((values: Record) => boolean) | ShouldRenderToken; +} + +/** + * Provider config: defines the full behavior of a LLM provider modal + */ +export interface ProviderConfig { + /** Corresponding LLMFactory value (also used as the field-config key) */ + llmFactory: string; + /** Modal title */ + title: string; + /** Field list (in render order) */ + fields: FieldConfig[]; + /** + * Transform form values into verify API parameters + * Used to construct api_key / base_url / region / model_info when the Verify button is clicked. + * `modelInfo` is the array of currently selected models from the list-models picker + * (one entry per checked list item). Providers without a list-models picker can ignore it. + */ + verifyTransform?: ( + values: Record, + modelInfo: IModelInfo[], + ) => { + apiKey: string; + baseUrl?: string; + region?: string; + modelInfo?: IModelInfo[]; + }; + /** + * Transform form values into submit API parameters. + * Used to handle special field name mapping (e.g. volcengine's endpoint_id -> ark_api_key). + * `modelInfo` is the array of currently selected models from the list-models picker + * (one entry per checked list item). Providers without a list-models picker can ignore it. + */ + submitTransform?: ( + values: Record, + modelInfo: IModelInfo[], + ) => Record; + /** + * Optional link at the bottom of the modal + * e.g. the official documentation link for Ollama-family providers + */ + docLink?: string; + /** + * i18n key for the docLink text (optional) + * e.g. 'ollamaLink'; the { name: llmFactory } variable is passed in + */ + docLinkI18nKey?: string; + /** + * Custom docLink text (optional, takes precedence over docLinkI18nKey) + */ + docLinkText?: string; +} + +/** + * Payload for the viewMode save callback. The modal calls `onViewModeOk` + * (when provided) instead of `onOk` whenever `viewMode` is true. + * + * - `instanceName` is the pre-existing instance's name (taken from + * `initialValues.instance_name`). + * - `llmFactory` is the current provider/factory. + * - For LIST_MODEL_PROVIDERS, `modelInfos` carries the full list of + * currently checked models in the picker (one IModelInfo per checked + * item) and `formValues` is undefined. + * - For non-LIST_MODEL_PROVIDERS, the picker is hidden so `modelInfos` + * is empty and `formValues` carries the editable model-related form + * values (model_name, model_type, max_tokens, is_tools). + */ +export interface IViewModeOkPayload { + instanceName: string; + llmFactory: string; + modelInfos: IModelInfo[]; + formValues?: Record; +} + +/** + * ProviderModal component props + */ +export interface ProviderModalProps { + visible: boolean; + hideModal: () => void; + llmFactory: string; + loading: boolean; + editMode?: boolean; + /** + * Read-only "edit models" mode: opens the modal pre-filled with an + * existing instance's data and only allows editing the model-related + * fields (model_name, model_type, max_tokens, is_tools) plus the + * list-models picker (when applicable). All other fields are disabled. + * On save, only `addInstanceModel` is invoked (not `addProviderInstance`). + */ + viewMode?: boolean; + initialValues?: Record; + /** + * Base URL options for the input+select combo (from IAvailableProvider.url) + * Used by base_url/api_base fields of type inputSelect + */ + baseUrlOptions?: SelectOption[]; + onOk: (payload: any, isVerify?: boolean) => Promise; + onVerify: (payload: any) => Promise; + /** + * Save handler used when `viewMode` is true. The modal calls this with + * the list of selected models (LIST_MODEL_PROVIDERS) or the editable + * model-related form values (non-LIST_MODEL_PROVIDERS). If omitted, + * the modal falls back to `onOk` and submits the standard payload. + */ + onViewModeOk?: (payload: IViewModeOkPayload) => Promise; +} diff --git a/web/src/pages/user-setting/setting-model/modal/spark-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/spark-modal/index.tsx deleted file mode 100644 index 0314c8d552..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/spark-modal/index.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -const SparkModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, -}: IModalProps & { - llmFactory: string; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tc } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const formRef = useRef(null); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const fields: FormFieldConfig[] = useMemo( - () => [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { message: t('instanceNameMessage') }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: buildModelTypeOptions(['chat', 'tts']), - defaultValue: ['chat'], - }, - { - name: 'llm_name', - label: t('modelName'), - type: FormFieldType.Text, - required: true, - placeholder: t('modelNameMessage'), - validation: { - message: t('SparkModelNameMessage'), - }, - }, - { - name: 'spark_api_password', - label: t('addSparkAPIPassword'), - type: FormFieldType.Text, - required: true, - placeholder: t('SparkAPIPasswordMessage'), - validation: { - message: t('SparkAPIPasswordMessage'), - }, - shouldRender: hideWhenInstanceExists, - }, - { - name: 'spark_app_id', - label: t('addSparkAPPID'), - type: FormFieldType.Text, - required: true, - placeholder: t('SparkAPPIDMessage'), - validation: { - message: t('SparkAPPIDMessage'), - }, - dependencies: ['model_type', 'instance_name'], - shouldRender: (formValues: any) => { - if (!hideWhenInstanceExists(formValues)) return false; - const modelType = formValues?.model_type; - if (Array.isArray(modelType)) { - return modelType.includes('tts'); - } - return modelType === 'tts'; - }, - }, - { - name: 'spark_api_secret', - label: t('addSparkAPISecret'), - type: FormFieldType.Text, - required: true, - placeholder: t('SparkAPISecretMessage'), - validation: { - message: t('SparkAPISecretMessage'), - }, - dependencies: ['model_type', 'instance_name'], - shouldRender: (formValues: any) => { - if (!hideWhenInstanceExists(formValues)) return false; - const modelType = formValues?.model_type; - if (Array.isArray(modelType)) { - return modelType.includes('tts'); - } - return modelType === 'tts'; - }, - }, - { - name: 'spark_api_key', - label: t('addSparkAPIKey'), - type: FormFieldType.Text, - required: true, - placeholder: t('SparkAPIKeyMessage'), - validation: { - message: t('SparkAPIKeyMessage'), - }, - dependencies: ['model_type', 'instance_name'], - shouldRender: (formValues: any) => { - if (!hideWhenInstanceExists(formValues)) return false; - const modelType = formValues?.model_type; - if (Array.isArray(modelType)) { - return modelType.includes('tts'); - } - return modelType === 'tts'; - }, - }, - { - name: 'max_tokens', - label: t('maxTokens'), - type: FormFieldType.Number, - required: true, - placeholder: t('maxTokensTip'), - validation: { - min: 0, - message: t('maxTokensInvalidMessage'), - }, - }, - ], - [t, buildModelTypeOptions, hideWhenInstanceExists], - ); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const data = { - instance_name: values.instance_name as string, - model_type: values.model_type, - llm_factory: llmFactory, - max_tokens: values.max_tokens, - }; - - await onOk?.(data as IAddProviderInstanceRequestBody); - }; - - const verifyParamsFunc = useCallback(() => { - const values = formRef.current?.getValues(); - return { - llm_factory: llmFactory, - model_type: values.model_type, - }; - }, [llmFactory]); - - const handleVerify = useCallback( - async (params: any) => { - const verifyParams = verifyParamsFunc(); - const res = await onVerify?.({ ...params, ...verifyParams }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [verifyParamsFunc, onVerify], - ); - - useEffect(() => { - if (!visible) { - formRef.current?.reset(); - } - }, [visible]); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - footer={
} - > - { - console.log(data); - }} - ref={formRef} - defaultValues={ - { - instance_name: '', - model_type: ['chat'], - max_tokens: 8192, - } as FieldValues - } - labelClassName="font-normal" - > - {onVerify && } -
- { - hideModal?.(); - }} - /> - { - handleOk(values); - }} - /> -
-
-
- ); -}; - -export default memo(SparkModal); 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 0720c83fec..a03cf4b7ce 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 @@ -12,12 +12,14 @@ interface IVerifyButton { onVerify: (params: any) => Promise; isAbsolute?: boolean; params?: any; + className?: string; } const VerifyButton: React.FC = ({ onVerify, isAbsolute = true, params, + className, }) => { const { t, i18n } = useTranslate('setting'); const isArabic = (i18n.resolvedLanguage || i18n.language || '') @@ -82,6 +84,7 @@ const VerifyButton: React.FC = ({ !isAbsolute || (verifyResult && verifyResult.isValid === false) ? 'flex flex-col gap-5 w-full ' : `absolute bottom-6 z-[100] ${isArabic ? 'right-6' : 'left-6'}`, + className, )} >
diff --git a/web/src/pages/user-setting/setting-model/modal/volcengine-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/volcengine-modal/index.tsx deleted file mode 100644 index 49d0a2a105..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/volcengine-modal/index.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -type VolcEngineLlmRequest = IAddProviderInstanceRequestBody & { - endpoint_id: string; - ark_api_key: string; -}; - -const VolcEngineModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, -}: IModalProps & { - llmFactory: string; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tc } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const formRef = useRef(null); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const fields: FormFieldConfig[] = useMemo( - () => [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { message: t('instanceNameMessage') }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: buildModelTypeOptions(['chat', 'embedding', 'image2text']), - defaultValue: ['chat'], - }, - { - name: 'llm_name', - label: t('modelName'), - type: FormFieldType.Text, - required: true, - placeholder: t('volcModelNameMessage'), - }, - { - name: 'endpoint_id', - label: t('addEndpointID'), - type: FormFieldType.Text, - required: true, - placeholder: t('endpointIDMessage'), - shouldRender: hideWhenInstanceExists, - }, - { - name: 'ark_api_key', - label: t('addArkApiKey'), - type: FormFieldType.Text, - required: true, - placeholder: t('ArkApiKeyMessage'), - shouldRender: hideWhenInstanceExists, - }, - { - name: 'max_tokens', - label: t('maxTokens'), - type: FormFieldType.Number, - required: true, - placeholder: t('maxTokensTip'), - validation: { - min: 0, - }, - }, - ], - [t, buildModelTypeOptions, hideWhenInstanceExists], - ); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const data: VolcEngineLlmRequest = { - instance_name: values.instance_name as string, - llm_factory: llmFactory, - llm_name: values.llm_name as string, - model_type: values.model_type, - endpoint_id: values.endpoint_id as string, - ark_api_key: values.ark_api_key as string, - max_tokens: values.max_tokens as number, - }; - - await onOk?.(data); - }; - - const verifyParamsFunc = useCallback(() => { - const values = formRef.current?.getValues(); - return { - llm_factory: llmFactory, - model_type: values.model_type, - }; - }, [llmFactory]); - - const handleVerify = useCallback( - async (params: any) => { - const verifyParams = verifyParamsFunc(); - const res = await onVerify?.({ ...params, ...verifyParams }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [verifyParamsFunc, onVerify], - ); - - useEffect(() => { - if (!visible) { - formRef.current?.reset(); - } - }, [visible]); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - footer={
} - > - { - console.log(data); - }} - ref={formRef} - defaultValues={ - { - instance_name: '', - model_type: ['chat'], - max_tokens: 8192, - } as FieldValues - } - labelClassName="font-normal" - > - {onVerify && ( - - )} -
- - {t('ollamaLink', { name: llmFactory })} - -
- { - hideModal?.(); - }} - /> - { - handleOk(values); - }} - /> -
-
-
-
- ); -}; - -export default memo(VolcEngineModal); diff --git a/web/src/pages/user-setting/setting-model/modal/yiyan-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/yiyan-modal/index.tsx deleted file mode 100644 index 7babf637c5..0000000000 --- a/web/src/pages/user-setting/setting-model/modal/yiyan-modal/index.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { - DynamicForm, - DynamicFormRef, - FormFieldConfig, - FormFieldType, -} from '@/components/dynamic-form'; -import { Modal } from '@/components/ui/modal/modal'; -import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; -import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; -import { IModalProps } from '@/interfaces/common'; -import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; -import { - useFetchInstanceNameSet, - useHideWhenInstanceExists, - VerifyResult, -} from '@/pages/user-setting/setting-model/hooks'; -import { memo, useCallback, useMemo, useRef } from 'react'; -import { FieldValues } from 'react-hook-form'; -import { LLMHeader } from '../../components/llm-header'; -import VerifyButton from '../../modal/verify-button'; - -const YiyanModal = ({ - visible, - hideModal, - onOk, - onVerify, - loading, - llmFactory, -}: IModalProps & { - llmFactory: string; - onVerify?: ( - postBody: any, - ) => Promise; -}) => { - const { t } = useTranslate('setting'); - const { t: tc } = useCommonTranslation(); - const { buildModelTypeOptions } = useBuildModelTypeOptions(); - const formRef = useRef(null); - const { instanceNameSet } = useFetchInstanceNameSet(llmFactory); - - const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); - - const fields = useMemo( - () => [ - { - name: 'instance_name', - label: t('instanceName'), - type: FormFieldType.Text, - required: true, - placeholder: t('instanceNameMessage'), - tooltip: t('instanceNameTip'), - validation: { message: t('instanceNameMessage') }, - }, - { - name: 'model_type', - label: t('modelType'), - type: FormFieldType.MultiSelect, - required: true, - options: buildModelTypeOptions(['chat', 'embedding', 'rerank']), - defaultValue: ['chat'], - }, - { - name: 'llm_name', - label: t('modelName'), - type: FormFieldType.Text, - required: true, - placeholder: t('yiyanModelNameMessage'), - }, - { - name: 'yiyan_ak', - label: t('addyiyanAK'), - type: FormFieldType.Text, - required: true, - placeholder: t('yiyanAKMessage'), - shouldRender: hideWhenInstanceExists, - }, - { - name: 'yiyan_sk', - label: t('addyiyanSK'), - type: FormFieldType.Text, - required: true, - placeholder: t('yiyanSKMessage'), - shouldRender: hideWhenInstanceExists, - }, - { - name: 'max_tokens', - label: t('maxTokens'), - type: FormFieldType.Number, - required: true, - placeholder: t('maxTokensTip'), - validation: { - min: 0, - }, - }, - ], - [t, buildModelTypeOptions, hideWhenInstanceExists], - ); - - const handleOk = async (values?: FieldValues) => { - if (!values) return; - - const data: IAddProviderInstanceRequestBody = { - instance_name: values.instance_name as string, - llm_factory: llmFactory, - llm_name: values.llm_name as string, - model_type: values.model_type, - api_key: { - yiyan_ak: values.yiyan_ak, - yiyan_sk: values.yiyan_sk, - }, - max_tokens: values.max_tokens as number, - }; - - await onOk?.(data); - }; - - const verifyParamsFunc = useCallback(() => { - const values = formRef.current?.getValues(); - return { - llm_factory: llmFactory, - llm_name: values.llm_name as string, - model_type: values.model_type, - api_key: { - yiyan_ak: values.yiyan_ak, - yiyan_sk: values.yiyan_sk, - }, - max_tokens: values.max_tokens as number, - }; - }, [llmFactory]); - - const handleVerify = useCallback( - async (params: any) => { - const verifyParams = verifyParamsFunc(); - const res = await onVerify?.({ ...params, ...verifyParams }); - return (res || { isValid: null, logs: '' }) as VerifyResult; - }, - [verifyParamsFunc, onVerify], - ); - - return ( - } - open={visible || false} - onOpenChange={(open) => !open && hideModal?.()} - maskClosable={false} - // footer={
} - footer={<>} - footerClassName="pb-10" - > - { - console.log(data); - }} - defaultValues={ - { - instance_name: '', - model_type: ['chat'], - max_tokens: 8192, - } as FieldValues - } - labelClassName="font-normal" - > -
- {onVerify && } -
- { - hideModal?.(); - }} - /> - { - handleOk(values); - }} - /> -
-
-
-
- ); -}; - -export default memo(YiyanModal); diff --git a/web/src/pages/user-setting/setting-model/payload-utils.ts b/web/src/pages/user-setting/setting-model/payload-utils.ts index 10c654921a..812e4aeab1 100644 --- a/web/src/pages/user-setting/setting-model/payload-utils.ts +++ b/web/src/pages/user-setting/setting-model/payload-utils.ts @@ -18,6 +18,7 @@ const INSTANCE_RESERVED_KEYS = new Set([ 'base_url', 'region', 'verify', + 'model_info', ]); export const MODEL_EXTRA_KEYS = new Set([ @@ -91,6 +92,7 @@ export const splitProviderPayload = (payload: FlatPayload): SplitResult => { api_key: collectApiKeyExtras(payload), base_url: (payload.base_url ?? payload.api_base) as string | undefined, region: (payload.region as string | undefined) || 'default', + model_info: payload.model_info, }; const modelExtra = collectModelExtras(payload); diff --git a/web/src/services/llm-service.ts b/web/src/services/llm-service.ts index cd76d06f73..0d46a7cad3 100644 --- a/web/src/services/llm-service.ts +++ b/web/src/services/llm-service.ts @@ -8,6 +8,7 @@ const { addProvider, addProviderInstance, verifyProviderConnection, + listProviderModels, listProviderInstances, listInstanceModels, showProviderInstance, @@ -45,6 +46,10 @@ const methods = { url: verifyProviderConnection, method: 'post', }, + listProviderModels: { + url: listProviderModels, + method: 'get', + }, listProviderInstances: { url: listProviderInstances, method: 'get', diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index b369d217e0..d2efe19bda 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -32,6 +32,8 @@ export default { `${restAPIv1}/providers/${llm_factory}/instances`, verifyProviderConnection: ({ provider_name }: { provider_name: string }) => `${restAPIv1}/providers/${provider_name}/connection`, + listProviderModels: ({ provider_name }: { provider_name: string }) => + `${restAPIv1}/providers/${provider_name}/models`, listProviderInstances: ({ provider_name }: { provider_name: string }) => `${restAPIv1}/providers/${provider_name}/instances`, listInstanceModels: ({