feat: Unify the 'Add Model Provider' modal (#15768)

### 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
This commit is contained in:
chanx
2026-06-08 16:46:52 +08:00
committed by GitHub
parent 4bbd59823a
commit 144abbe2eb
40 changed files with 3706 additions and 3840 deletions

View File

@@ -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 (
<div className={cn('flex items-center max-w-full')}>
<div
className={cn(
'flex-1 truncate',
canEdit ? 'cursor-text' : 'cursor-default',
)}
onClick={(e) => {
if (!canEdit) return;
e.stopPropagation();
onEdit(getNodeText(label));
}}
>
{label}
</div>
<button
type="button"
className="ml-2 flex-[0_0_24px] text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
};
const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
(
{
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<HTMLInputElement, InputSelectProps>(
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<HTMLInputElement>) => {
if (
e.key === 'Backspace' &&
@@ -196,43 +313,7 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
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<HTMLInputElement, InputSelectProps>(
};
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<HTMLInputElement, InputSelectProps>(
const filteredOptions = availableOptions.filter(
(option) =>
!inputValue ||
option.label
getNodeText(option.label)
.toLowerCase()
.includes(inputValue.toString().toLowerCase()),
);
@@ -290,7 +372,8 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
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<HTMLInputElement, InputSelectProps>(
const triggerElement = (
<div
className={cn(
'flex flex-wrap items-center gap-1 w-full rounded-md border-0.5 border-border-button bg-bg-input px-3 py-1 min-h-8 cursor-text',
'flex items-center gap-1 w-full rounded-md border-0.5 border-border-button bg-bg-input px-3 py-1 min-h-8 cursor-text',
'outline-none transition-colors',
'focus-within:outline-none focus-within:ring-1 focus-within:ring-accent-primary',
className,
@@ -326,109 +409,110 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
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 (
<div
key={`${tagValue}-${index}`}
className="flex items-center bg-bg-card text-text-primary rounded px-2 py-1 text-xs mr-1 mb-1 border border-border-card truncate"
>
<div className="flex-1 truncate">{option.label}</div>
<button
type="button"
className="ml-1 text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(tagValue);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{/* For single select, show the selected value as text instead of a tag */}
{!multi && !isEmpty(normalizedValue[0]) && (
<div className={cn('flex items-center max-w-full')}>
<div className="flex-1 truncate">
{options.find((opt) =>
{/* Wrapper for tags and input - this part wraps */}
<div className="flex flex-wrap items-center gap-1 flex-1 min-w-0">
{/* 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]))}
</div>
<button
type="button"
className="ml-2 flex-[0_0_24px] text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(normalizedValue[0]);
}}
>
<X className="h-3 w-3" />
</button>
</div>
)}
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])) && (
<Input
ref={inputRef}
type={
type === 'date'
? 'date'
: type === 'datetime'
? 'datetime-local'
: type === 'number'
? 'number'
: 'text'
}
value={
type === 'number' && inputValue
? String(inputValue)
: type === 'date' || type === 'datetime'
? inputValue
: inputValue
}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
(
multi
? normalizedValue.length === 0
: isEmpty(normalizedValue[0])
)
? placeholder
: ''
}
className="flex-grow min-w-[50px] border-none px-1 py-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-auto "
onClick={(e) => e.stopPropagation()}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
return (
<div
key={`${tagValue}-${index}`}
className="flex items-center bg-bg-card text-text-primary rounded px-2 py-1 text-xs mr-1 mb-1 border border-border-card truncate"
>
<div className="flex-1 truncate">{option.label}</div>
<button
type="button"
className="ml-1 text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(tagValue);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{/* For single select, show the selected value as text instead of a tag */}
{!multi && !isEmpty(normalizedValue[0]) && (
<SingleSelectDisplay
value={normalizedValue[0]}
options={options}
type={type}
onEdit={(editText) => {
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])) && (
<Input
ref={inputRef}
type={
type === 'date'
? 'date'
: type === 'datetime'
? 'datetime-local'
: type === 'number'
? 'number'
: 'text'
}
value={
type === 'number' && inputValue
? String(inputValue)
: type === 'date' || type === 'datetime'
? inputValue
: inputValue
}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
(
multi
? normalizedValue.length === 0
: isEmpty(normalizedValue[0])
)
? placeholder
: ''
}
className="flex-grow min-w-[50px] border-none px-1 py-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-auto "
onClick={(e) => e.stopPropagation()}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-text-secondary shrink-0 transition-transform',
open && 'rotate-180',
)}
/>
</div>
);

View File

@@ -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<V = unknown> {
/** 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<V = unknown> {
/** 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<V>[];
/**
* 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<V = unknown>({
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<V>) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const containerRef = React.useRef<HTMLDivElement>(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 (
<div ref={containerRef} className={cn('flex flex-col', className)}>
<button
type="button"
aria-expanded={open}
aria-controls={listId}
onClick={() =>
setOpen((prev) => {
const next = !prev;
onOpenChange?.(next);
return next;
})
}
className={cn(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded text-sm',
'h-8 px-3 bg-bg-card text-text-secondary border border-border-button self-start',
'hover:bg-border-button hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary/40',
buttonClassName,
)}
>
{btnText}
{typeof btnText === 'string' && (
<ChevronsDown
className={cn(
'size-4 shrink-0 transition-transform duration-200',
open && 'rotate-180',
)}
aria-hidden="true"
/>
)}
</button>
{open && (
<div
id={listId}
role="list"
className={cn(
'mt-1 flex flex-col rounded-md border border-border-button bg-bg-card w-full overflow-hidden',
listClassName,
)}
>
{searchable && (
<div
className={cn(
'flex items-center gap-2 px-2 h-9 border-b border-border-button',
searchClassName,
)}
>
<Search className="size-4 shrink-0 text-text-secondary" />
<input
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-text-secondary/60"
/>
{searchLoading && (
<Loader2 className="size-4 shrink-0 animate-spin text-text-secondary" />
)}
{query && !searchLoading && (
<button
type="button"
aria-label="Clear search"
onClick={() => handleQueryChange('')}
className="text-text-secondary hover:text-text-primary"
>
<X className="size-4" />
</button>
)}
</div>
)}
<div
className="flex-1 overflow-y-auto divide-y divide-border-button"
style={{ maxHeight }}
>
{showEmpty && (
<div className="px-3 py-2 text-sm text-text-secondary">
{emptyText}
</div>
)}
{showNoResults && (
<div className="px-3 py-2 text-sm text-text-secondary">
{noResultsText}
</div>
)}
{!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 (
<div
key={key}
role="listitem"
onClick={() => {
option.onClick?.();
}}
className={cn(
'cursor-pointer px-3 py-2 text-sm hover:bg-border-button ',
itemClassName,
)}
>
{option.label}
</div>
);
})}
{loadMore &&
(loadMore.hasMore || loadMore.loading) &&
filteredOptions.length > 0 && (
<button
type="button"
disabled={!loadMore.hasMore || loadMore.loading}
onClick={loadMore.onLoadMore}
className={cn(
'w-full px-3 py-2 text-sm border-t border-border-button',
'text-text-secondary hover:text-text-primary hover:bg-border-button',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent',
)}
>
{loadMore.loading ? (
<span className="inline-flex items-center justify-center gap-2">
<Loader2 className="size-3.5 animate-spin" /> Loading
</span>
) : (
(loadMore.text ?? 'Load more')
)}
</button>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<IProviderInstance>({
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/<provider_name>/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<string, string> = {};
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 {

View File

@@ -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[];

View File

@@ -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<string, any>;
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<string, any>;
}
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[];
}

View File

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

View File

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

View File

@@ -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}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -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({
<InstanceModelList
providerName={providerName}
instanceName={instance.instance_name}
instance={instance}
onEditInstance={onEditInstance}
/>
</CollapsibleContent>
</div>
@@ -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<string>();
@@ -187,15 +240,22 @@ function InstanceModelList({
<div className="px-4 pb-4">
{/* Model type tags */}
{modelTypes.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{modelTypes.map((type) => (
<span
key={type}
className="px-2 py-1 text-xs bg-bg-card text-text-secondary rounded-md"
>
{mapModelKey[type.trim() as keyof typeof mapModelKey] || type}
</span>
))}
<div className="flex justify-between items-center">
<div className="flex flex-wrap gap-2 mb-3">
{modelTypes.map((type) => (
<span
key={type}
className="px-2 py-1 text-xs bg-bg-card text-text-secondary rounded-md"
>
{mapModelKey[type.trim() as keyof typeof mapModelKey] || type}
</span>
))}
</div>
{false && (
<Button size="icon" variant="ghost" onClick={handleSettingsClick}>
<Settings size={12} />
</Button>
)}
</div>
)}

View File

@@ -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<string>) => {
[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<SavingParamsState>(
{} 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<string>('');
const [editMode, setEditMode] = useState(false);
const [initialValues, setInitialValues] = useState<
Partial<IAddProviderInstanceRequestBody> & { 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<VerifyResult | undefined>)
| ((
payload: IAddProviderInstanceRequestBody,
isVerify?: boolean,
) => Promise<VerifyResult | undefined>)
| ((
payload: MinerUFormValues,
isVerify?: boolean,
) => Promise<VerifyResult | undefined>)
| ((payload: any, isVerify?: boolean) => Promise<boolean | VerifyResult>)
| (() => void);
onVerify: (postBody: any, isVerify?: boolean) => Promise<any>;
}) => {
const onApiKeyVerifying = useCallback(
async (postBody: any) => {

View File

@@ -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<string>('');
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<string, any> | 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: <span>https://...<span>default</span></span> }, ...]
// `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<string, string | undefined>) => {
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: (
<div className="flex justify-between items-center gap-2">
<span className="truncate">{v}</span>
<span className="text-xs text-text-secondary bg-bg-card px-2 py-0.5 rounded-sm shrink-0">
{k}
</span>
</div>
),
};
});
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 (
<div className="flex w-full border-[0.5px] border-border-button rounded-lg relative ">
<Spotlight />
<section className="flex flex-col gap-4 w-3/5 px-5 border-r-[0.5px] border-border-button overflow-auto scrollbar-auto">
<SystemSetting />
<UsedModel handleAddModel={handleAddModel} />
<UsedModel
handleAddModel={handleAddModel}
onEditInstance={handleEditInstance}
/>
</section>
<section className="flex flex-col w-2/5 overflow-auto scrollbar-auto">
<AvailableModels handleAddModel={handleAddModel} />
</section>
<ApiKeyModal
visible={apiKeyVisible}
hideModal={hideApiKeyModal}
loading={saveApiKeyLoading}
initialValue={initialApiKey}
editMode={editMode}
onOk={onApiKeySavingOk}
onVerify={onApiKeyVerifying}
llmFactory={llmFactory}
></ApiKeyModal>
{llmAddingVisible && (
<OllamaModal
visible={llmAddingVisible}
hideModal={hideLlmAddingModal}
onOk={onLlmAddingOk}
loading={llmAddingLoading}
editMode={llmEditMode}
initialValues={llmInitialValues}
llmFactory={selectedLlmFactory}
onVerify={onApiKeyVerifying}
></OllamaModal>
)}
<VolcEngineModal
visible={volcAddingVisible}
hideModal={hideVolcAddingModal}
onOk={onVolcAddingOk}
loading={volcAddingLoading}
llmFactory={LLMFactory.VolcEngine}
onVerify={onApiKeyVerifying}
></VolcEngineModal>
<GoogleModal
visible={GoogleAddingVisible}
hideModal={hideGoogleAddingModal}
onOk={onGoogleAddingOk}
loading={GoogleAddingLoading}
llmFactory={LLMFactory.GoogleCloud}
onVerify={onApiKeyVerifying}
></GoogleModal>
<TencentCloudModal
visible={TencentCloudAddingVisible}
hideModal={hideTencentCloudAddingModal}
onOk={onTencentCloudAddingOk}
loading={TencentCloudAddingLoading}
llmFactory={LLMFactory.TencentCloud}
onVerify={onApiKeyVerifying}
></TencentCloudModal>
<SparkModal
visible={SparkAddingVisible}
hideModal={hideSparkAddingModal}
onOk={onSparkAddingOk}
loading={SparkAddingLoading}
llmFactory={LLMFactory.XunFeiSpark}
onVerify={onApiKeyVerifying}
></SparkModal>
<YiyanModal
visible={yiyanAddingVisible}
hideModal={hideyiyanAddingModal}
onOk={onyiyanAddingOk}
loading={yiyanAddingLoading}
llmFactory={LLMFactory.BaiduYiYan}
onVerify={onApiKeyVerifying}
></YiyanModal>
<FishAudioModal
visible={FishAudioAddingVisible}
hideModal={hideFishAudioAddingModal}
onOk={onFishAudioAddingOk}
loading={FishAudioAddingLoading}
llmFactory={LLMFactory.FishAudio}
onVerify={onApiKeyVerifying}
></FishAudioModal>
{/* Unified ProviderModal (replaces 9 independent modals) */}
<ProviderModal
visible={providerVisible}
hideModal={hideProviderModal}
llmFactory={currentLlmFactory}
loading={providerLoading}
viewMode={viewMode}
initialValues={viewModeInitialValues}
baseUrlOptions={currentBaseUrlOptions as any}
onOk={handleProviderOk}
onVerify={handleProviderVerify}
onViewModeOk={handleViewModeOk}
/>
<BedrockModal
visible={bedrockAddingVisible}
hideModal={hideBedrockAddingModal}
onOk={onBedrockAddingOk}
loading={bedrockAddingLoading}
llmFactory={LLMFactory.Bedrock}
onVerify={onApiKeyVerifying}
onVerify={async () => ({ isValid: null, logs: '' })}
></BedrockModal>
<AzureOpenAIModal
visible={AzureAddingVisible}
hideModal={hideAzureAddingModal}
onOk={onAzureAddingOk}
loading={AzureAddingLoading}
llmFactory={LLMFactory.AzureOpenAI}
onVerify={onApiKeyVerifying}
></AzureOpenAIModal>
<MinerUModal
visible={mineruVisible}
hideModal={hideMineruModal}
onOk={onMineruOk}
loading={mineruLoading}
onVerify={onApiKeyVerifying}
></MinerUModal>
<PaddleOCRModal
visible={paddleocrVisible}
hideModal={hidePaddleOCRModal}
onOk={onPaddleOCROk}
loading={paddleocrLoading}
onVerify={onApiKeyVerifying}
></PaddleOCRModal>
<OpenDataLoaderModal
visible={opendataloaderVisible}
hideModal={hideOpenDataLoaderModal}
onOk={onOpenDataLoaderOk}
loading={opendataloaderLoading}
onVerify={onApiKeyVerifying}
></OpenDataLoaderModal>
</div>
);
};
export default ModelProviders;

View File

@@ -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<IModalManagerChildrenProps, 'showModal'> {
loading: boolean;
initialValue: string;
llmFactory: string;
editMode?: boolean;
onOk: (postBody: ApiKeyPostBody) => void;
onVerify: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
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<FieldType>();
const { t } = useTranslate('setting');
const handleOk = useCallback(async () => {
await form.handleSubmit((values) => onOk(values as ApiKeyPostBody))();
}, [form, onOk]);
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = 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 (
<Modal
title={<LLMHeader name={llmFactory} />}
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"
>
<Form {...form}>
<div className="space-y-4 py-4">
<RAGFlowFormItem
name="instance_name"
label={t('instanceName')}
tooltip={t('instanceNameTip')}
rules={{ required: t('instanceNameMessage') }}
required
labelClassName="text-sm font-medium text-text-secondary"
>
{(field) => (
<Input
{...field}
placeholder={t('instanceNameMessage')}
onKeyDown={handleKeyDown}
className="w-full"
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="api_key"
label={t('apiKey')}
rules={{ required: t('apiKeyMessage') }}
required
labelClassName="text-sm font-medium text-text-secondary"
>
{(field) => (
<Input
{...field}
data-testid="apikey-input"
onKeyDown={handleKeyDown}
className="w-full"
/>
)}
</RAGFlowFormItem>
{modelsWithBaseUrl.some((x) => x === llmFactory) && (
<RAGFlowFormItem
name="base_url"
label={t('baseUrl')}
tooltip={
llmFactory === LLMFactory.MiniMax
? t('minimaxBaseUrlTip')
: llmFactory === LLMFactory.TongYiQianWen
? t('tongyiBaseUrlTip')
: llmFactory === LLMFactory.SILICONFLOW
? t('siliconBaseUrlTip')
: t('baseUrlTip')
}
labelClassName="text-sm font-medium text-text-primary"
>
{(field) => (
<Input
{...field}
placeholder={
llmFactory === LLMFactory.TongYiQianWen
? t('tongyiBaseUrlPlaceholder')
: llmFactory === LLMFactory.MiniMax
? t('minimaxBaseUrlPlaceholder')
: llmFactory === LLMFactory.SILICONFLOW
? 'https://api.siliconflow.cn/v1'
: 'https://api.openai.com/v1'
}
onKeyDown={handleKeyDown}
className="w-full"
/>
)}
</RAGFlowFormItem>
)}
{llmFactory?.toLowerCase() === 'Anthropic'.toLowerCase() && (
<RAGFlowFormItem
name="base_url"
label={t('baseUrl')}
labelClassName="text-sm font-medium text-text-primary"
>
{(field) => (
<Input
{...field}
placeholder="https://api.anthropic.com/v1"
onKeyDown={handleKeyDown}
className="w-full"
/>
)}
</RAGFlowFormItem>
)}
{llmFactory?.toLowerCase() === 'Minimax'.toLowerCase() && (
<RAGFlowFormItem
name="group_id"
label="Group ID"
labelClassName="text-sm font-medium text-text-primary"
>
{(field) => <Input {...field} className="w-full" />}
</RAGFlowFormItem>
)}
<VerifyButton onVerify={onVerify} />
</div>
</Form>
</Modal>
);
};
export default ApiKeyModal;

View File

@@ -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<IAddProviderInstanceRequestBody> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tg } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const formRef = useRef<DynamicFormRef>(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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={<div className="p-4"></div>}
>
<DynamicForm.Root
fields={fields}
onSubmit={(data) => {
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 && <VerifyButton onVerify={handleVerify} />}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tg('ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</>
</DynamicForm.Root>
</Modal>
);
};
export default memo(AzureOpenAIModal);

View File

@@ -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<IAddProviderInstanceRequestBody> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const formRef = useRef<DynamicFormRef>(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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footerClassName="py-1"
footer={<div className="py-0"></div>}
>
<DynamicForm.Root
fields={fields}
onSubmit={(data) => console.log(data)}
ref={formRef}
defaultValues={{
instance_name: '',
model_type: ['tts'],
max_tokens: 8192,
}}
labelClassName="font-normal"
>
{onVerify && (
<VerifyButton onVerify={handleVerify} isAbsolute={false} />
)}
<div className="flex items-center justify-between w-full">
<a
href="https://fish.audio"
target="_blank"
rel="noreferrer"
className="text-sm text-text-secondary hover:text-primary"
>
{t('FishAudioLink')}
</a>
<div className="flex gap-2">
<DynamicForm.CancelButton handleCancel={() => hideModal?.()} />
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => handleOk(values)}
/>
</div>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default memo(FishAudioModal);

View File

@@ -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<IAddProviderInstanceRequestBody> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const { instanceNameSet } = useFetchInstanceNameSet(llmFactory);
const formRef = useRef<DynamicFormRef>(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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={<div className="p-4"></div>}
>
<DynamicForm.Root
fields={fields}
onSubmit={() => {
// Form submission is handled by SavingButton
}}
ref={formRef}
defaultValues={
{
instance_name: '',
model_type: ['chat'],
max_tokens: 8192,
} as FieldValues
}
labelClassName="font-normal"
>
{onVerify && <VerifyButton onVerify={handleVerify} />}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default memo(GoogleModal);

View File

@@ -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<typeof FormSchema>;
const MinerUModal = ({
visible,
hideModal,
onOk,
onVerify,
loading,
}: IModalProps<MinerUFormValues> & {
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
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<MinerUFormValues>({
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 (
<Dialog open={visible} onOpenChange={hideModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<LLMHeader name={LLMFactory.MinerU} />
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleOk)}
className="space-y-6"
id="mineru-form"
>
<RAGFlowFormItem
name="instance_name"
label={t('setting.instanceName')}
tooltip={t('setting.instanceNameTip')}
required
>
<Input placeholder={t('setting.instanceNameMessage')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="llm_name"
label={t('setting.modelName')}
required
>
<Input placeholder="mineru-from-env-1" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="mineru_apiserver"
label={t('setting.mineru.apiserver')}
required
>
<Input placeholder="http://host.docker.internal:9987" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="mineru_output_dir"
label={t('setting.mineru.outputDir')}
>
<Input placeholder="/tmp/mineru" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="mineru_backend"
label={t('setting.mineru.backend')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={(value) => {
field.onChange(value);
if (value !== 'vlm-http-client') {
form.setValue('mineru_server_url', undefined);
}
}}
options={backendOptions}
placeholder={t('setting.mineru.selectBackend')}
/>
)}
</RAGFlowFormItem>
{backend === 'vlm-http-client' && (
<RAGFlowFormItem
name="mineru_server_url"
label={t('setting.mineru.serverUrl')}
>
<Input placeholder="http://your-vllm-server:30000" />
</RAGFlowFormItem>
)}
<RAGFlowFormItem
name="mineru_delete_output"
label={t('setting.mineru.deleteOutput')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
{onVerify && (
<VerifyButton
onVerify={onVerify as (postBody: any) => Promise<VerifyResult>}
/>
)}
</form>
</Form>
<DialogFooter>
<div className="flex gap-2">
<ButtonLoading type="submit" form="mineru-form" loading={loading}>
{t('common.save', 'Save')}
</ButtonLoading>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default memo(MinerUModal);

View File

@@ -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<Omit<IAddProviderInstanceRequestBody, 'max_tokens'>> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const formRef = useRef<DynamicFormRef>(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<IAddProviderInstanceRequestBody, 'max_tokens'>;
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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={<div className="p-4"></div>}
>
<DynamicForm.Root
fields={fields}
onSubmit={() => {}}
ref={formRef}
defaultValues={
{
instance_name: '',
model_type: ['speech2text'],
llm_name: '16k_zh',
} as FieldValues
}
labelClassName="font-normal"
>
{onVerify && (
<VerifyButton onVerify={handleVerify} isAbsolute={false} />
)}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-between w-full py-6 px-6">
<a
href="https://cloud.tencent.com/document/api/1093/37823"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
{t('TencentCloudLink')}
</a>
<div className="flex gap-2">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default memo(TencentCloudModal);

View File

@@ -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<Record<LLMFactory, string>> = {
[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<IAddProviderInstanceRequestBody> & { provider_order?: string }
> & {
llmFactory: string;
editMode?: boolean;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const formRef = useRef<DynamicFormRef>(null);
const { instanceNameSet } = useFetchInstanceNameSet(llmFactory);
const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet);
const optionsMap: Partial<
Record<LLMFactory, { label: string; value: string }[]>
> & {
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<FormFieldConfig[]>(() => {
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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={<></>}
footerClassName="py-1"
>
<DynamicForm.Root
key={`${visible}-${llmFactory}`}
fields={fields}
ref={formRef}
onSubmit={() => {}}
defaultValues={defaultValues}
labelClassName="font-normal"
>
{onVerify && (
<VerifyButton onVerify={handleVerify} isAbsolute={false} />
)}
<div className="flex items-center justify-between w-full gap-2 ">
<a href={url} target="_blank" rel="noreferrer" className="text-sm">
{t('ollamaLink', { name: llmFactory })}
</a>
<div className="flex gap-2">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default memo(OllamaModal);

View File

@@ -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<T> {
visible: boolean;
hideModal: () => void;
onOk?: (data: T) => Promise<boolean>;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
loading?: boolean;
}
const OpenDataLoaderModal = ({
visible,
hideModal,
onOk,
onVerify,
loading,
}: IModalProps<OpenDataLoaderFormValues>) => {
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<OpenDataLoaderFormValues>({
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 (
<Dialog open={visible} onOpenChange={hideModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<LLMHeader name={LLMFactory.OpenDataLoader} />
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleOk)}
className="space-y-6"
id="opendataloader-form"
>
<RAGFlowFormItem
name="instance_name"
label={t('setting.instanceName')}
tooltip={t('setting.instanceNameTip')}
required
>
<Input placeholder={t('setting.instanceNameMessage')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="llm_name"
label={t('setting.modelName')}
required
>
<Input placeholder="my-opendataloader" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="opendataloader_apiserver"
label={t('setting.baseUrl')}
required
>
<Input placeholder="http://your-opendataloader-service:9383" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="opendataloader_api_key"
label={t('setting.apiKey')}
>
<Input
type="password"
placeholder={t('setting.apiKeyPlaceholder')}
/>
</RAGFlowFormItem>
{onVerify && (
<VerifyButton
onVerify={onVerify as (postBody: any) => Promise<VerifyResult>}
/>
)}
</form>
</Form>
<DialogFooter className="flex justify-end space-x-2">
<Button type="button" variant="secondary" onClick={hideModal}>
{t('common.cancel')}
</Button>
<ButtonLoading
type="submit"
form="opendataloader-form"
loading={loading}
>
{t('common.add')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default memo(OpenDataLoaderModal);

View File

@@ -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<typeof FormSchema>;
export interface IModalProps<T> {
visible: boolean;
hideModal: () => void;
onOk?: (data: T) => Promise<boolean>;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
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<PaddleOCRFormValues>) => {
const { t } = useTranslation();
const form = useForm<PaddleOCRFormValues>({
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 (
<Dialog open={visible} onOpenChange={hideModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<LLMHeader name={LLMFactory.PaddleOCR} />
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleOk)}
className="space-y-6"
id="paddleocr-form"
>
<RAGFlowFormItem
name="instance_name"
label={t('setting.instanceName')}
tooltip={t('setting.instanceNameTip')}
required
>
<Input placeholder={t('setting.instanceNameMessage')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="llm_name"
label={t('setting.modelName')}
required
>
<Input
placeholder={t('setting.paddleocr.modelNamePlaceholder')}
/>
</RAGFlowFormItem>
<RAGFlowFormItem
name="paddleocr_api_url"
label={t('setting.paddleocr.apiUrl')}
required
>
<Input placeholder={t('setting.paddleocr.apiUrlPlaceholder')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="paddleocr_access_token"
label={t('setting.paddleocr.accessToken')}
>
<Input
placeholder={t('setting.paddleocr.accessTokenPlaceholder')}
/>
</RAGFlowFormItem>
<RAGFlowFormItem
name="paddleocr_algorithm"
label={t('setting.paddleocr.algorithm')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={algorithmOptions}
placeholder={t('setting.paddleocr.selectAlgorithm')}
/>
)}
</RAGFlowFormItem>
{onVerify && (
<VerifyButton
onVerify={onVerify as (postBody: any) => Promise<VerifyResult>}
/>
)}
<DialogFooter>
<div className="flex justify-end space-x-2">
<Button type="button" onClick={hideModal} variant={'outline'}>
{t('common.cancel')}
</Button>
<ButtonLoading type="submit" loading={loading}>
{t('common.ok')}
</ButtonLoading>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export default memo(PaddleOCRModal);

View File

@@ -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/<factory>/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<string>([
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<string>([
'model_name',
'model_type',
'max_tokens',
'is_tools',
]);

View File

@@ -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,
];

View File

@@ -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,
};
}

View File

@@ -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';

View File

@@ -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<string, ProviderConfig> = {
[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 }
: {}),
}),
};
}

View File

@@ -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<string, ProviderConfig> = {
// ============ 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<string, any> = {};
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<string, any> = {};
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<string, any> = {};
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<string, any> = {};
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<string, any> = { ...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<string, any> = { ...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,
};
},
},
};

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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: (
<div className="flex justify-between items-center gap-2 w-full">
<div className="flex-1 min-w-0 flex gap-1 items-center">
<div className="font-medium truncate">{t('allModels')}</div>
</div>
<Checkbox
checked={allSelected}
onClick={(e) => {
e.stopPropagation();
handleToggleAll();
}}
/>
</div>
),
onClick: () => handleToggleAll(),
};
const modelOptions = models.map((m) => {
const checked = selectedModelItems.some((s) => s.name === m.name);
return {
value: m.name,
label: (
<div className="flex justify-between items-center gap-2 w-full">
<div className="flex-1 min-w-0 flex gap-1 items-center">
<div className="font-medium truncate">{m.name}</div>
{m.model_types &&
m.model_types.map((type) => {
return (
<div
key={type}
className="text-xs text-text-secondary truncate bg-bg-card rounded-md px-2 py-1"
>
{type}
</div>
);
})}
</div>
<Checkbox
checked={checked}
onClick={(e) => {
e.stopPropagation();
handleSelectModel(m);
}}
/>
</div>
),
onClick: () => handleSelectModel(m),
};
});
if (modelOptions?.length) {
return [allOption, ...modelOptions];
} else {
return [];
}
}, [
models,
selectedModelItems,
handleSelectModel,
allSelected,
handleToggleAll,
t,
]);
};

View File

@@ -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<string, any>;
llmFactory: string;
config: ProviderConfig;
formRef: RefObject<DynamicFormRef>;
}
/**
* 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<IProviderModelItem[]>([]);
const [listLoading, setListLoading] = useState(false);
// Items the user has checked in the picker. Carries the full descriptor
// (including `features`) so we can derive is_tools per model at submit time.
const [selectedModelItems, setSelectedModelItems] = useState<
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<IModelInfo[] | null>(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,
};
};

View File

@@ -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<string, any>;
baseUrlOptions?: SelectOption[];
hideWhenInstanceExists: (values: Record<string, any>) => 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<string, Map<string, string>> = {};
config.fields.forEach((field) => {
if (field.type !== 'inputSelect') return;
const options =
field.options && field.options.length > 0
? field.options
: (baseUrlOptions ?? []);
const urlMap = new Map<string, string>();
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<FormFieldConfig, 'type'> = {
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 ? (
<InputSelect
{...fieldProps}
value={(fieldProps.value ?? '') as string}
onChange={(value) => fieldProps.onChange(value)}
options={inputSelectOptions as any}
placeholder={placeholderText}
/>
) : (
<Input {...fieldProps} 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,
};
};

View File

@@ -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<string, any>;
modelInfoList: IModelInfo[];
formRef: RefObject<DynamicFormRef>;
/**
* 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<string, Map<string, string>>;
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<string, any> | undefined,
baseUrlRegionMaps?: Record<string, Map<string, string>>,
): 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<string, any>,
};
await onViewModeOk(payload);
return;
}
const transformed = (
config.submitTransform
? config.submitTransform(values as Record<string, any>, modelInfoList)
: values
) as Record<string, any>;
const region = resolveRegionFromValues(
values as Record<string, any>,
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 };
};

View File

@@ -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<DynamicFormRef>(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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={<div className="p-4"></div>}
>
<DynamicForm.Root
key={`${visible}-${llmFactory}`}
fields={fields}
onSubmit={() => {
// The actual submission is handled by SavingButton
}}
ref={formRef}
defaultValues={defaultValues}
labelClassName="font-normal"
>
{hasModelNameField && (
<ToggleList
className="w-full"
buttonClassName="self-end"
// searchable
btnText={hasModelNameField ? t('listModels') : 'Select an option'}
options={
hasModelNameField
? listModelsOptions
: listModelsOptions.length > 0
? listModelsOptions
: []
}
searchPlaceholder={t('listModelsSearchPlaceholder')}
emptyText={t('listModelsEmpty')}
searchLoading={listLoading}
onOpenChange={handleListOpenChange}
maxHeight={400}
closeOnOutsideClick
/>
)}
<VerifyButton onVerify={handleVerify} />
<div
className={
docLinkText
? 'absolute bottom-0 right-0 left-0 flex items-center justify-between w-full py-6 px-6'
: 'absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6'
}
>
{docLinkText && config.docLink && (
<a
href={config.docLink}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline ml-24"
>
{docLinkText}
</a>
)}
<div className="flex gap-2">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => {
handleSubmit(values);
}}
/>
</div>
</div>
</DynamicForm.Root>
</Modal>
);
};
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';

View File

@@ -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<string, any>) => 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<string, any>,
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<string, any>,
modelInfo: IModelInfo[],
) => Record<string, any>;
/**
* 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<string, any>;
}
/**
* 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<string, any>;
/**
* 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<any>;
onVerify: (payload: any) => Promise<any>;
/**
* 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<any>;
}

View File

@@ -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<IAddProviderInstanceRequestBody> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const formRef = useRef<DynamicFormRef>(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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={<div className="p-4"></div>}
>
<DynamicForm.Root
fields={fields}
onSubmit={(data) => {
console.log(data);
}}
ref={formRef}
defaultValues={
{
instance_name: '',
model_type: ['chat'],
max_tokens: 8192,
} as FieldValues
}
labelClassName="font-normal"
>
{onVerify && <VerifyButton onVerify={handleVerify} />}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default memo(SparkModal);

View File

@@ -12,12 +12,14 @@ interface IVerifyButton {
onVerify: (params: any) => Promise<VerifyResult>;
isAbsolute?: boolean;
params?: any;
className?: string;
}
const VerifyButton: React.FC<IVerifyButton> = ({
onVerify,
isAbsolute = true,
params,
className,
}) => {
const { t, i18n } = useTranslate('setting');
const isArabic = (i18n.resolvedLanguage || i18n.language || '')
@@ -82,6 +84,7 @@ const VerifyButton: React.FC<IVerifyButton> = ({
!isAbsolute || (verifyResult && verifyResult.isValid === false)
? 'flex flex-col gap-5 w-full '
: `absolute bottom-6 z-[100] ${isArabic ? 'right-6' : 'left-6'}`,
className,
)}
>
<div className="flex gap-2 items-center">

View File

@@ -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<IAddProviderInstanceRequestBody> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const formRef = useRef<DynamicFormRef>(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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={<div className="p-4"></div>}
>
<DynamicForm.Root
fields={fields}
onSubmit={(data) => {
console.log(data);
}}
ref={formRef}
defaultValues={
{
instance_name: '',
model_type: ['chat'],
max_tokens: 8192,
} as FieldValues
}
labelClassName="font-normal"
>
{onVerify && (
<VerifyButton onVerify={handleVerify} isAbsolute={false} />
)}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-between w-full py-6 px-6">
<a
href="https://www.volcengine.com/docs/82379/1302008"
target="_blank"
rel="noreferrer"
>
{t('ollamaLink', { name: llmFactory })}
</a>
<div className="flex gap-2">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default memo(VolcEngineModal);

View File

@@ -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<IAddProviderInstanceRequestBody> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const formRef = useRef<DynamicFormRef>(null);
const { instanceNameSet } = useFetchInstanceNameSet(llmFactory);
const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet);
const fields = useMemo<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: 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 (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
// footer={<div className="p-4"></div>}
footer={<></>}
footerClassName="pb-10"
>
<DynamicForm.Root
key={`${visible}-${llmFactory}`}
fields={fields}
ref={formRef}
onSubmit={(data) => {
console.log(data);
}}
defaultValues={
{
instance_name: '',
model_type: ['chat'],
max_tokens: 8192,
} as FieldValues
}
labelClassName="font-normal"
>
<div>
{onVerify && <VerifyButton onVerify={handleVerify} />}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={tc('ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default memo(YiyanModal);

View File

@@ -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);

View File

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

View File

@@ -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: ({