Feat: v0.27.0 model provider (#16604)

This commit is contained in:
Lynn
2026-07-08 09:47:29 +08:00
committed by GitHub
parent cb93883f3f
commit 0ae5961e1c
94 changed files with 7539 additions and 3044 deletions

View File

@@ -12,14 +12,17 @@ import {
IAddInstanceModelRequestBody,
IAddProviderInstanceRequestBody,
IAddProviderRequestBody,
IDeleteInstanceModelsRequestBody,
IDeleteProviderInstanceRequestBody,
IEditInstanceModelRequestBody,
IListAllModelsRequestParams,
IListProviderModelsRequestBody,
IListProvidersRequestParams,
IModelInfo,
IPatchInstanceModelRequestBody,
ISetDefaultModelRequestBody,
IUpdateModelStatusRequestBody,
IUpdateProviderInstanceRequestBody,
} from '@/interfaces/request/llm';
import llmService from '@/services/llm-service';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -40,6 +43,9 @@ export const enum LLMApiAction {
AddInstanceModel = 'addInstanceModel',
EditInstanceModel = 'editInstanceModel',
DeleteProviderInstance = 'deleteProviderInstance',
DeleteInstanceModels = 'deleteInstanceModels',
UpdateProviderInstance = 'updateProviderInstance',
PatchInstanceModel = 'patchInstanceModel',
ListDefaultModels = 'listDefaultModels',
SetDefaultModel = 'setDefaultModel',
}
@@ -153,12 +159,6 @@ 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,
@@ -186,7 +186,7 @@ export const useFetchInstanceModels = (
queryKey: LlmKeys.instanceModels(providerName, instanceName),
initialData: [],
gcTime: 0,
enabled: !!providerName && !!instanceName,
enabled: !!providerName && !!instanceName && instanceName !== '__draft__',
queryFn: async () => {
const { data } = await llmService.listInstanceModels(
{ provider_name: providerName, instance_name: instanceName },
@@ -246,27 +246,37 @@ export const useAddProviderInstance = () => {
try {
await addProvider({ provider_name: params.llm_factory });
const { data: instancesRes } = await llmService.listProviderInstances(
{ provider_name: params.llm_factory },
true,
);
const instanceExists = instancesRes?.data?.some(
(i: IProviderInstance) => i.instance_name === params.instance_name,
);
if (instanceExists && !params.verify) {
return { code: 0, data: null };
// When `id` is supplied the caller is updating an existing
// instance (blur-save on a saved card), so do not short-circuit
// on the "already exists" check — we *want* the server call.
if (!params.id) {
const { data: instancesRes } = await llmService.listProviderInstances(
{ provider_name: params.llm_factory },
true,
);
const instanceExists = instancesRes?.data?.some(
(i: IProviderInstance) => i.instance_name === params.instance_name,
);
if (instanceExists && !params.verify) {
return { code: 0, data: null };
}
}
} catch {
// ignore list failure and proceed to add
}
const { data } = await llmService.addProviderInstance(params);
// The provider is carried in the URL path
// (`/providers/<llm_factory>/instances`), so `llm_factory` must not
// be duplicated in the request body. Keep it only for URL building
// (native-config form) and send the remaining fields as the body.
const { llm_factory, ...body } = params;
const { data } = await llmService.addProviderInstance(
{ llm_factory, data: body },
true,
);
if (data.code === 0 && !params.verify) {
queryClient.invalidateQueries({
queryKey: LlmKeys.addedProviders(),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.allModels(),
queryKey: LlmKeys.providerInstances(params.llm_factory),
});
}
return data;
@@ -342,12 +352,23 @@ export const useAddInstanceModel = () => {
) => {
const { data } = await llmService.addInstanceModel(params);
if (data.code === 0) {
// `exact: true` keeps the invalidation to the provider summary
// list. Without it the [AddedProviders] prefix would also match
// every providerInstances / instanceModels query and refetch
// every provider's instances — we only want the current one.
queryClient.invalidateQueries({
queryKey: LlmKeys.addedProviders(),
exact: true,
});
queryClient.invalidateQueries({
queryKey: LlmKeys.allModels(),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.instanceModels(
params.provider_name,
params.instance_name,
),
});
}
return data;
},
@@ -455,6 +476,119 @@ export const useUpdateModelStatus = () => {
return { loading, updateModelStatus: mutateAsync };
};
/**
* PATCH `/providers/{name}/instances/{name}/models/{model_name}` — updates
* a single model's editable fields (max_tokens, model_type, status, is_tools).
* Used by the per-row Edit dialog. Distinct from `useUpdateModelStatus`
* (which only flips the active/inactive bit) so call sites can pass the
* full set of editable fields without coercing them into the status hook.
*/
export const usePatchInstanceModel = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { isPending: loading, mutateAsync } = useMutation({
mutationKey: [LLMApiAction.PatchInstanceModel],
mutationFn: async (params: IPatchInstanceModelRequestBody) => {
const { data } = await llmService.patchInstanceModel(params);
if (data.code === 0) {
message.success(t('message.modified'));
queryClient.invalidateQueries({
queryKey: LlmKeys.addedProviders(),
exact: true,
});
queryClient.invalidateQueries({
queryKey: LlmKeys.instanceModels(
params.provider_name,
params.instance_name,
),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.allModels(),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.defaultModels(),
});
}
return data;
},
});
return { loading, patchInstanceModel: mutateAsync };
};
export const useDeleteInstanceModels = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { isPending: loading, mutateAsync } = useMutation({
mutationKey: [LLMApiAction.DeleteInstanceModels],
mutationFn: async (params: IDeleteInstanceModelsRequestBody) => {
const { data } = await llmService.deleteInstanceModels(params);
if (data.code === 0) {
message.success(t('message.deleted'));
queryClient.invalidateQueries({
queryKey: LlmKeys.addedProviders(),
exact: true,
});
queryClient.invalidateQueries({
queryKey: LlmKeys.allModels(),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.defaultModels(),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.instanceModels(
params.provider_name,
params.instance_name,
),
});
}
return data;
},
});
return { loading, deleteInstanceModels: mutateAsync };
};
export const useUpdateProviderInstance = () => {
const queryClient = useQueryClient();
const { isPending: loading, mutateAsync } = useMutation({
mutationKey: [LLMApiAction.UpdateProviderInstance],
mutationFn: async (params: IUpdateProviderInstanceRequestBody) => {
const { data } = await llmService.updateProviderInstance(params);
if (data.code === 0) {
queryClient.invalidateQueries({
queryKey: LlmKeys.addedProviders(),
exact: true,
});
queryClient.invalidateQueries({
queryKey: LlmKeys.providerInstances(params.provider_name),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.providerInstance(
params.provider_name,
params.instance_name,
),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.instanceModels(
params.provider_name,
params.instance_name,
),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.allModels(),
});
queryClient.invalidateQueries({
queryKey: LlmKeys.defaultModels(),
});
}
return data;
},
});
return { loading, updateProviderInstance: mutateAsync };
};
export const useFetchDefaultModels = () => {
const { data, isFetching: loading } = useQuery<IDefaultModel[]>({
queryKey: LlmKeys.defaultModels(),

View File

@@ -45,10 +45,16 @@ export interface IAvailableProvider {
name: string;
model_types: string[];
url: { default?: string; [key: string]: string | undefined };
has_instance: boolean;
}
export interface IProviderInstance {
api_key: string;
/**
* Usually a plain key string, but the showProviderInstance endpoint
* may return an object `{ api_key, ...nested }` for providers that
* bundle extra credentials (see the nested fields below).
*/
api_key: string | Record<string, any>;
id: string;
instance_name: string;
provider_id: string;
@@ -56,10 +62,20 @@ export interface IProviderInstance {
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).
* to pre-fill the base_url/api_base form field when opening a saved
* instance.
*/
base_url?: string;
/**
* Provider-specific credentials that may be returned either at the top
* level or nested inside `api_key`:
* - group_id → MiniMax
* - api_version → Azure OpenAI
* - provider_order → OpenRouter
*/
group_id?: string;
api_version?: string;
provider_order?: string;
}
export interface IAddedModel {
model_type: string[];
@@ -75,6 +91,20 @@ export interface IInstanceModel {
model_type: string[];
name: string;
status: string;
/**
* Persisted verification result from the backend:
* - `true` → verified successfully
* - `false` → verified but failed
* - `undefined` → never verified yet
*/
verify?: 'unknown' | 'success' | 'fail';
/**
* Persisted Tool-call flag from `tenant_model.extra.is_tools`.
* The backend's `_hybrid_get_instance_models` includes this so the
* frontend can forward the correct value in auto-save payloads
* without relying solely on the (possibly unfetched) catalog.
*/
is_tools?: boolean;
}
export interface IDefaultModel {

View File

@@ -38,6 +38,13 @@ export interface IAddProviderRequestBody {
export type IAddProviderInstanceRequestBody = IAddLlmRequestBody & {
instance_name: string;
region?: string;
/**
* Optional id of an existing instance. When present the backend
* treats the call as an update of that row (rather than a create),
* which is what the inline "blur-save" flow on saved instance cards
* needs.
*/
id?: string;
};
export interface IDeleteProviderInstanceRequestBody {
@@ -73,6 +80,42 @@ export interface IUpdateModelStatusRequestBody {
status: 'active' | 'inactive';
}
/**
* Body shape for PATCH `/providers/{name}/instances/{name}/models/{model_name}`.
* All fields are optional; only the supplied keys are updated server-side.
*/
export interface IPatchInstanceModelRequestBody {
provider_name: string;
instance_name: string;
model_name: string;
status?: 'active' | 'inactive';
max_tokens?: number;
model_type?: string[];
extra?: Record<string, any>;
}
export interface IDeleteInstanceModelsRequestBody {
provider_name: string;
instance_name: string;
model_name: string[];
}
export interface IUpdateProviderInstanceRequestBody {
provider_name: string;
instance_name: string;
id?: string;
/**
* Either a plain API-key string, or — for providers that need an
* extra credential such as MiniMax's `group_id` — an object bundling
* the key with those fields: `{ api_key, group_id }`.
*/
api_key?: string | Record<string, any>;
base_url?: string;
region?: string;
model_info?: IModelInfo[];
verify?: boolean;
}
export interface ISetDefaultModelRequestBody {
model_provider: string;
model_instance: string;
@@ -98,7 +141,7 @@ export interface IProviderModelItem {
*/
export interface IListProviderModelsRequestBody {
provider_name: string;
api_key: string;
api_key?: string;
base_url?: string;
region?: string;
model_info?: IModelInfo[];

View File

@@ -1672,6 +1672,14 @@ Example: Virtual Hosted Style`,
'http://your-opendataloader-service:9383',
modify: 'Modify',
systemModelSettings: 'Set default models',
default: 'Default',
empty: 'No data',
docLink: 'Documentation link',
addInstance: 'Add instance',
addInstanceText: 'Add instance',
noInstancesConfigured: 'No instances configured yet.',
editInstanceName: 'Edit instance name',
models: 'Models',
chatModel: 'LLM',
chatModelTip: 'The default LLM for each newly created dataset.',
embeddingModel: 'Embedding',
@@ -1697,6 +1705,13 @@ Example: Virtual Hosted Style`,
instanceNameMessage: 'Please input the instance name!',
instanceNameTip:
'A unique name to identify this provider instance under the same factory.',
instanceNamePlaceholder: 'Please input instance name',
instanceNameSaveTip:
'Enter an instance name and save it. Once saved, it cannot be changed.',
instanceNameSavePrompt:
'Please save the instance name first before editing other fields.',
instanceNameLockedHint: 'Instance name is locked',
deleteInstance: 'Delete instance',
modelName: 'Model name',
modelID: 'Model ID',
modelUid: 'Model UID',
@@ -1900,6 +1915,8 @@ Example: Virtual Hosted Style`,
'Please select at least one model before verification.',
addCustomModel: 'Add custom model',
addCustomModelTitle: 'Add custom model',
batchAddModels: 'Add all visible models',
batchRemoveModels: 'Remove all visible models',
editCustomModelTitle: 'Edit model',
modelMaxTokens: 'Max tokens',
modelFeatures: 'Model features',

View File

@@ -1366,6 +1366,14 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
'http://your-opendataloader-service:9383',
modify: '修改',
systemModelSettings: '设置默认模型',
default: '默认',
empty: '暂无数据',
docLink: '文档链接',
addInstance: '添加实例',
addInstanceText: '添加实例',
noInstancesConfigured: '尚未配置任何实例。',
editInstanceName: '编辑实例名称',
models: '模型',
chatModel: 'LLM',
chatModelTip: '所有新创建的知识库都会使用默认的聊天模型。',
ttsModel: 'TTS',
@@ -1390,6 +1398,11 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
instanceName: '实例名称',
instanceNameMessage: '请输入实例名称!',
instanceNameTip: '用于在同一厂商下唯一标识该实例的名称。',
instanceNamePlaceholder: '请输入实例名称',
instanceNameSaveTip: '请输入实例名称并保存。保存后不可修改。',
instanceNameSavePrompt: '请先保存实例名称,再编辑其他字段。',
instanceNameLockedHint: '实例名称已锁定',
deleteInstance: '删除实例',
modelName: '模型名称',
modelID: '模型ID',
modelUid: '模型UID',
@@ -1555,6 +1568,8 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
selectModelBeforeVerify: '请至少选择一个模型后再验证。',
addCustomModel: '添加自定义模型',
addCustomModelTitle: '添加自定义模型',
batchAddModels: '批量添加当前模型',
batchRemoveModels: '批量移除当前模型',
editCustomModelTitle: '编辑模型',
modelMaxTokens: '最大 Token 数',
modelTypes: {

View File

@@ -1,6 +1,6 @@
import { FilterFormField, FormFieldType } from '@/components/dynamic-form';
import { TFunction } from 'i18next';
import { BedrockRegionList } from '../../setting-model/constant';
import { BedrockRegionList } from '../../setting-model/constants';
const awsRegionOptions = BedrockRegionList.map((r) => ({
label: r,

View File

@@ -1,34 +0,0 @@
import { LlmIcon } from '@/components/svg-icon';
import { Button } from '@/components/ui/button';
import { APIMapUrl } from '@/constants/llm';
import { t } from 'i18next';
import { ArrowUpRight, Plus } from 'lucide-react';
export const LLMHeader = ({ name }: { name: string }) => {
return (
<div className="flex items-center space-x-3 mb-3">
<LlmIcon name={name} imgClass="h-8 w-8 text-text-primary" />
<div className="flex flex-1 gap-1 items-center">
<div className="font-normal text-base truncate">{name}</div>
{!!APIMapUrl[name as keyof typeof APIMapUrl] && (
<Button
variant={'ghost'}
className=" bg-transparent w-4 h-5"
onClick={(e) => {
e.stopPropagation();
window.open(APIMapUrl[name as keyof typeof APIMapUrl]);
}}
// target="_blank"
rel="noopener noreferrer"
>
<ArrowUpRight size={16} />
</Button>
)}
</div>
<Button className=" px-2 items-center gap-0 text-xs h-6 rounded-md transition-colors hidden group-hover:flex">
<Plus size={12} />
{t('addTheModel')}
</Button>
</div>
);
};

View File

@@ -1,401 +0,0 @@
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { LlmIcon } from '@/components/svg-icon';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Switch } from '@/components/ui/switch';
import { RAGFlowTooltip } from '@/components/ui/tooltip';
import { ModelStatus } from '@/constants/llm';
import {
useDeleteProviderInstance,
useEditInstanceModel,
useFetchAddedProviders,
useFetchInstanceModels,
useFetchProviderInstances,
useUpdateModelStatus,
} from '@/hooks/use-llm-request';
import {
IAvailableProvider,
IInstanceModel,
IProviderInstance,
} from '@/interfaces/database/llm';
import { IProviderModelItem } from '@/interfaces/request/llm';
import { cn } from '@/lib/utils';
import { ChevronsDown, ChevronsUp, Pencil, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddCustomModelDialog } from '../modal/provider-modal/components/add-custom-model-dialog';
import { useCustomModelFields } from '../modal/provider-modal/components/use-custom-model-fields';
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();
return (
<div
className="flex flex-col w-full gap-5 mb-4"
data-testid="added-models-section"
>
<div className="text-text-primary text-2xl font-medium mb-2 mt-4">
{t('setting.addedModels')}
</div>
{providerList.map((provider) => (
<ProviderCard
key={provider.name}
provider={provider}
handleAddModel={handleAddModel}
onEditInstance={onEditInstance}
/>
))}
</div>
);
}
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);
if (!instances || instances.length <= 0) {
return null;
}
return (
<div
className="w-full rounded-lg border border-border-button"
data-testid="added-model-card"
data-provider={provider.name}
>
{/* Provider header */}
<div className="flex h-16 items-center p-4 text-text-secondary">
<div className="flex items-center space-x-3">
<LlmIcon name={provider.name} width={32} />
<div className="font-medium text-xl text-text-primary">
{provider.name}
</div>
</div>
</div>
{/* Instances */}
{instances.length > 0 && (
<div className="border-t border-border-button">
{instances.map((instance) => (
<InstanceRow
key={instance.id}
instance={instance}
providerName={provider.name}
handleAddModel={handleAddModel}
onEditInstance={onEditInstance}
/>
))}
</div>
)}
</div>
);
}
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);
const { deleteProviderInstance } = useDeleteProviderInstance();
const handleDelete = async () => {
await deleteProviderInstance({
provider_name: providerName,
instances: [instance.instance_name],
});
};
return (
<Collapsible
open={visible}
onOpenChange={setVisible}
className="border-b border-border-button last:border-b-0"
>
<div>
{/* Instance header */}
<div className="flex items-center justify-between p-4">
<span className="font-medium text-text-primary">
{instance.instance_name}
</span>
<div className="flex items-center space-x-2">
{/* <Button variant="outline" size="sm" onClick={handleApiKeyClick}>
{t('setting.apiKey')}
</Button> */}
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm">
<span>
{visible
? t('setting.hideModels')
: t('setting.showMoreModels')}
</span>
{visible ? (
<ChevronsUp size={16} />
) : (
<ChevronsDown size={16} />
)}
</Button>
</CollapsibleTrigger>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button size="icon" variant="danger-hover">
<Trash2 size={16} />
</Button>
</ConfirmDeleteDialog>
</div>
</div>
{/* Models */}
<CollapsibleContent>
<InstanceModelList
providerName={providerName}
instanceName={instance.instance_name}
instance={instance}
onEditInstance={onEditInstance}
/>
</CollapsibleContent>
</div>
</Collapsible>
);
}
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>();
models.forEach((m) => {
if (m.model_type) {
m.model_type.forEach((type) => types.add(type));
}
});
return Array.from(types);
}, [models]);
return (
<div className="px-4 pb-4">
{/* Model type tags */}
{modelTypes.length > 0 && (
<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>
{/* <Button size="icon" variant="ghost" onClick={handleSettingsClick}>
<Settings size={12} />
</Button> */}
</div>
)}
{/* Model list */}
<div className="bg-bg-card rounded-lg max-h-96 overflow-auto scrollbar-auto">
<ul>
{models.map((model) => (
<ModelListItem
key={model.name}
model={model}
providerName={providerName}
instanceName={instanceName}
/>
))}
</ul>
</div>
</div>
);
}
function ModelListItem({
model,
providerName,
instanceName,
}: {
model: IInstanceModel;
providerName: string;
instanceName: string;
}) {
const { t } = useTranslation();
const { updateModelStatus } = useUpdateModelStatus();
const { editInstanceModel, loading: editLoading } = useEditInstanceModel();
const customModelFields = useCustomModelFields();
const [editOpen, setEditOpen] = useState(false);
const handleStatusChange = (checked: boolean) => {
updateModelStatus({
provider_name: providerName,
instance_name: instanceName,
model_name: model.name,
status: checked ? ModelStatus.Active : ModelStatus.Inactive,
});
};
// Only show name / model_types / max_tokens; only model_types is editable.
const editFields = useMemo(
() =>
customModelFields
.filter((f) => ['name', 'model_types', 'max_tokens'].includes(f.name))
.map((f) => ({
...f,
disabled: f.name !== 'model_types',
})),
[customModelFields],
);
const editDefaultValues = useMemo(
() => ({
name: model.name,
model_types: model.model_type ?? [],
max_tokens: model.max_tokens ?? 0,
}),
[model],
);
const handleEditSubmit = useCallback(
async (item: IProviderModelItem) => {
await editInstanceModel({
provider_name: providerName,
instance_name: instanceName,
model_name: [model.name],
model_type: item.model_types ?? [],
});
setEditOpen(false);
},
[editInstanceModel, providerName, instanceName, model.name],
);
return (
<li className="flex items-center border-b-[0.5px] border-border-button justify-between p-3 hover:bg-bg-card transition-colors last:border-b-0 group">
<div className="flex items-center space-x-3">
<span className="font-medium text-text-primary">{model.name}</span>
{model.model_type.slice(0, 3).map((modelType) => (
<span
className="px-2 py-1 text-xs bg-bg-card text-text-secondary rounded-md"
key={modelType}
>
{modelType}
</span>
))}
{model.model_type.length > 3 && (
<RAGFlowTooltip
tooltip={
<div className="flex flex-col gap-1">
{model.model_type.slice(3).map((type) => (
<span key={type}>{type}</span>
))}
</div>
}
>
<span
className="px-2 py-1 text-xs bg-bg-card text-text-secondary rounded-md cursor-pointer"
key="ellipsis"
>
...
</span>
</RAGFlowTooltip>
)}
<Button
size="icon"
variant="ghost"
className={cn('h-6 w-6 hidden', 'group-hover:flex')}
onClick={(e: any) => {
e.stopPropagation();
setEditOpen(true);
}}
aria-label={t('setting.editCustomModelTitle')}
>
<Pencil size={12} />
</Button>
</div>
<Switch
checked={model.status === ModelStatus.Active}
onCheckedChange={handleStatusChange}
/>
<AddCustomModelDialog
open={editOpen}
onOpenChange={setEditOpen}
title={t('setting.editCustomModelTitle')}
fields={editFields}
defaultValues={editDefaultValues}
onSubmit={handleEditSubmit}
loading={editLoading}
existingNames={[]}
/>
</li>
);
}

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const BedrockRegionList = [
'us-east-2',
'us-east-1',

View File

@@ -1,4 +1,19 @@
import { LLMFactory } from '@/constants/llm';
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useSetModalState } from '@/hooks/common-hooks';
import {
useAddInstanceModel,
@@ -129,7 +144,9 @@ export const useVerifyConnection = () => {
};
// ============ Hooks for retained special modals ============
// Bedrock and SoMark still use custom modal components.
// Bedrock and SoMark have been migrated to inline instance cards
// (BedrockInstanceCard / SoMarkInstanceCard); these legacy modal
// hooks are kept only for backward-compat references.
export const useSubmitBedrock = () => {
const [saveLoading, setSaveLoading] = useState(false);
@@ -185,106 +202,3 @@ export const useSubmitBedrock = () => {
showBedrockAddingModal,
};
};
export const useSubmitSoMark = () => {
const [saveLoading, setSaveLoading] = useState(false);
const submitProviderInstance = useSubmitProviderInstance();
const verifyConnection = useVerifyConnection();
const {
visible: somarkVisible,
hideModal: hideSoMarkModal,
showModal: showSoMarkModal,
} = useSetModalState();
const onSoMarkOk = useCallback(
async (payload: any, isVerify = false) => {
if (!isVerify) {
setSaveLoading(true);
}
const req = {
instance_name: payload.instance_name,
llm_factory: LLMFactory.SoMark,
api_key: payload.somark_api_key || '',
base_url: payload.somark_base_url,
max_tokens: 0,
model_info: [
{
model_name: payload.llm_name,
model_type: ['ocr'],
max_tokens: 0,
extra: {
somark_image_format: payload.somark_image_format,
somark_formula_format: payload.somark_formula_format,
somark_table_format: payload.somark_table_format,
somark_cs_format: payload.somark_cs_format,
somark_enable_text_cross_page:
payload.somark_enable_text_cross_page,
somark_enable_table_cross_page:
payload.somark_enable_table_cross_page,
somark_enable_title_level_recognition:
payload.somark_enable_title_level_recognition,
somark_enable_inline_image: payload.somark_enable_inline_image,
somark_enable_table_image: payload.somark_enable_table_image,
somark_enable_image_understanding:
payload.somark_enable_image_understanding,
somark_keep_header_footer: payload.somark_keep_header_footer,
},
},
],
};
try {
if (isVerify) {
return verifyConnection(
LLMFactory.SoMark,
req.api_key,
req.base_url,
undefined,
req.model_info as IModelInfo[],
);
}
const ret = await submitProviderInstance(
req as IAddProviderInstanceRequestBody,
false,
);
if (ret.code === 0) {
hideSoMarkModal();
return true;
}
return false;
} finally {
if (!isVerify) {
setSaveLoading(false);
}
}
},
[submitProviderInstance, hideSoMarkModal, setSaveLoading, verifyConnection],
);
return {
somarkVisible,
hideSoMarkModal,
showSoMarkModal,
onSoMarkOk,
somarkLoading: saveLoading,
};
};
/**
* Wraps the verify callback: provides a unified call with isVerify=true for the Verify button
*/
export const useVerifySettings = ({
onVerify,
}: {
onVerify: (postBody: any, isVerify?: boolean) => Promise<any>;
}) => {
const onApiKeyVerifying = useCallback(
async (postBody: any) => {
const res = await onVerify(postBody, true);
return res;
},
[onVerify],
);
return {
onApiKeyVerifying,
};
};

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.modelContainer {
width: 100%;
.factoryOperationWrapper {

View File

@@ -1,409 +1,238 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Spotlight from '@/components/spotlight';
import { LLMFactory } from '@/constants/llm';
import { useTranslate } from '@/hooks/common-hooks';
import {
useAddInstanceModel,
LlmKeys,
useAddProviderInstance,
useFetchAvailableProviders,
useVerifyProviderConnection,
useFetchProviderInstances,
} from '@/hooks/use-llm-request';
import { IInstanceModel, IProviderInstance } from '@/interfaces/database/llm';
import type {
IAddProviderInstanceRequestBody,
IModelInfo,
} from '@/interfaces/request/llm';
import { useCallback, useEffect, 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 { useSubmitBedrock, useSubmitSoMark, useVerifySettings } from './hooks';
import BedrockModal from './modal/bedrock-modal';
import ProviderModal, { IViewModeOkPayload } from './modal/provider-modal';
import SoMarkModal from './modal/somark-modal';
import { splitProviderPayload } from './payload-utils';
import { IProviderInstance } from '@/interfaces/database/llm';
import { useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ProviderInstanceCard } from './instance-card/provider-instance-card';
import { ProviderHeaderBar } from './layout/provider-header-bar';
import { Sidebar, SidebarSelection } from './layout/sidebar';
import SystemSetting from './layout/system-setting';
const ModelProviders = () => {
// Retained special modals
const {
bedrockAddingLoading,
onBedrockAddingOk,
bedrockAddingVisible,
hideBedrockAddingModal,
showBedrockAddingModal,
} = useSubmitBedrock();
/**
* Sidebar-driven model provider settings page.
*
* Layout:
* - Left: `Sidebar` (Default-models entry, search, provider list).
* - Right:
* * 'default' selection -> `SystemSetting`.
* * provider selection -> a sticky `ProviderHeaderBar` at the top,
* a vertical stack of `ProviderInstanceCard` in the middle, and
* a sticky "+ Instance" button at the bottom. Each click of
* that button adds a new draft card; multiple drafts can coexist
* and be saved / cancelled independently.
*
* Special-case providers (handled inside `ProviderInstanceCard`):
* - `Bedrock`: rendered inline via `BedrockInstanceCard`.
* - `SoMark`: rendered inline via `SoMarkInstanceCard`.
*/
const SettingModelV2: FC = () => {
const { t: tSetting } = useTranslate('setting');
const [selection, setSelection] = useState<SidebarSelection>('default');
// Stack of draft-instance identifiers, rendered as `ProviderInstanceCard`
// entries below the persisted instances. Each draft can be saved or
// cancelled independently; saving removes the draft from this list and
// triggers a refetch that surfaces the newly-saved instance above.
const [draftIds, setDraftIds] = useState<string[]>([]);
// Monotonic counter so each draft card has a stable, unique React key.
const draftIdCounterRef = useRef(0);
// Tracks whether the user explicitly cancelled the auto-shown draft
// for the current selection. Reset on every selection change.
const cancelledRef = useRef(false);
// Unified ProviderModal state
const [providerVisible, setProviderVisible] = useState(false);
const [currentLlmFactory, setCurrentLlmFactory] = useState<string>('');
const [providerLoading, setProviderLoading] = useState(false);
// Always re-fetch when the selection changes. Passing an empty string
// disables the query.
const providerQueryName = selection === 'default' ? '' : selection;
const { data: instances, loading: instancesLoading } =
useFetchProviderInstances(providerQueryName);
const queryClient = useQueryClient();
// 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);
// ProviderModal submission logic: calls addProviderInstance + addInstanceModel
const { addProviderInstance } = useAddProviderInstance();
const { addInstanceModel } = useAddInstanceModel();
const { verifyProviderConnection } = useVerifyProviderConnection();
const { data: availableProviders } = useFetchAvailableProviders();
// 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],
);
useEffect(() => {
if (!providerVisible) {
setProviderLoading(false);
}
}, [providerVisible]);
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 {
somarkVisible,
hideSoMarkModal,
showSoMarkModal,
onSoMarkOk,
somarkLoading,
} = useSubmitSoMark();
const ModalMap = useMemo(
() => ({
[LLMFactory.Bedrock]: showBedrockAddingModal,
[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);
},
[LLMFactory.SoMark]: showSoMarkModal,
}),
[showBedrockAddingModal, showSoMarkModal],
);
const handleAddModel = useCallback(
(llmFactory: string) => {
if (isLocalLlmFactory(llmFactory)) {
setCurrentLlmFactory(llmFactory);
setProviderVisible(true);
} else if (llmFactory in ModalMap) {
ModalMap[llmFactory as keyof typeof ModalMap]();
} else {
setCurrentLlmFactory(llmFactory);
setProviderVisible(true);
}
},
[ModalMap],
);
// 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);
},
[],
);
// 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],
);
// 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);
// Append a new draft id to the visible list.
const addDraft = useCallback(() => {
draftIdCounterRef.current += 1;
const id = `draft-${draftIdCounterRef.current}`;
setDraftIds((prev) => [...prev, id]);
}, []);
const { onApiKeyVerifying: onSoMarkVerifying } = useVerifySettings({
onVerify: onSoMarkOk,
});
// Remove a draft id from the visible list (called on save / cancel).
const removeDraft = useCallback((id: string) => {
setDraftIds((prev) => prev.filter((d) => d !== id));
}, []);
// When the selection changes, clear the cancelled flag and drop any
// in-flight drafts so the user starts fresh on the new provider.
useEffect(() => {
cancelledRef.current = false;
setDraftIds([]);
}, [selection]);
// If the user switches to a provider with no existing instances and
// no drafts already on screen, auto-show a "new instance" draft so
// they can fill it in immediately. If they have explicitly cancelled,
// do not re-show.
//
// Wait for the provider-instances query to settle before deciding:
// `initialData: []` on the hook means `instances` is an empty array
// from the first render, so a naive length check would auto-spawn a
// draft during the brief loading window even when the provider
// already has saved instances, only to remove it once the query
// resolves. Gating on `!instancesLoading` skips that flicker and
// keeps the UI clean for providers that do have instances.
useEffect(() => {
if (selection === 'default' || cancelledRef.current) return;
if (instancesLoading) return;
if (instances.length === 0 && draftIds.length === 0) {
addDraft();
}
}, [selection, instances, instancesLoading, draftIds, addDraft]);
const { addProviderInstance } = useAddProviderInstance();
// Save handler for a draft card. Calls `addProviderInstance` with the
// values supplied by the draft form (instance_name, api_key, base_url,
// model_info...). After a successful save, removes the draft from the
// list and invalidates the instance query so the new card appears in
// the persisted list automatically.
const handleDraftSave = useCallback(
async (id: string, values: Record<string, any>) => {
const ret = await addProviderInstance({
llm_factory: selection as string,
instance_name: values.instance_name,
api_key: values.api_key,
base_url: values.base_url ?? values.api_base,
model_info: values.model_info,
} as any);
if (ret?.code === 0) {
// Mark this selection as "user-engaged" so the auto-show effect
// below does not spawn another draft while the providerInstances
// refetch is still in flight (during that window both `instances`
// and `draftIds` are empty and would otherwise re-trigger
// `addDraft()`).
cancelledRef.current = true;
removeDraft(id);
queryClient.invalidateQueries({
queryKey: LlmKeys.providerInstances(providerQueryName),
});
}
},
[
addProviderInstance,
selection,
queryClient,
providerQueryName,
removeDraft,
],
);
// The instance name has just been persisted (via the dedicated
// "Save name" button inside the draft card). Remove the draft and
// pin the auto-show guard so a new placeholder draft is not
// auto-injected during the brief window between draft removal and
// the providerInstances refetch landing.
const handleNameSaved = useCallback(
(id: string) => {
cancelledRef.current = true;
removeDraft(id);
},
[removeDraft],
);
// User clicked Cancel on a specific draft — remove it from the list
// and stop the auto-show effect from re-opening it for the current
// empty-instance selection.
const handleDraftCancel = useCallback(
(id: string) => {
cancelledRef.current = true;
removeDraft(id);
},
[removeDraft],
);
const draftInstance: IProviderInstance = useMemo(
() => ({ instance_name: '' }) as IProviderInstance,
[],
);
return (
<div className="flex w-full border-[0.5px] border-border-button rounded-lg relative ">
<div className="flex w-full h-full border-[0.5px] border-border-button rounded-lg relative overflow-hidden">
<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}
onEditInstance={handleEditInstance}
/>
<section className="flex flex-col gap-4 w-[320px] shrink-0 px-5 border-r-[0.5px] border-border-button overflow-auto scrollbar-auto">
<Sidebar selection={selection} onSelect={setSelection} />
</section>
<section className="flex flex-col w-2/5 overflow-auto scrollbar-auto">
<AvailableModels handleAddModel={handleAddModel} />
<section className="flex-1 flex flex-col overflow-hidden">
{selection === 'default' ? (
<div className="flex-1 overflow-auto scrollbar-auto">
<SystemSetting />
</div>
) : (
<>
{/* Sticky top: provider name + doc-link arrow */}
<ProviderHeaderBar providerName={selection as string} />
{/* Scrollable middle: instance cards + optional draft cards */}
<div className="flex-1 overflow-auto scrollbar-auto p-4 flex flex-col gap-4">
{instances.length === 0 && draftIds.length === 0 && (
<div className="text-text-secondary text-sm py-6 text-center">
{tSetting('noInstancesConfigured')}
</div>
)}
{instances.map((instance, index) => (
<ProviderInstanceCard
key={instance.instance_name}
providerName={selection as string}
instance={instance}
defaultOpen={index === 0}
/>
))}
{draftIds.map((id) => (
<ProviderInstanceCard
key={id}
providerName={selection as string}
instance={draftInstance}
isDraft
onDelete={() => handleDraftCancel(id)}
onNameSaved={() => handleNameSaved(id)}
onSaved={(values) => handleDraftSave(id, values)}
/>
))}
<div className=" bottom-0 z-10 border-border-button py-4">
<button
type="button"
className="w-full flex items-center justify-center gap-2 px-3 py-1 rounded-md border border-dashed border-border-button text-text-secondary hover:bg-bg-input hover:text-text-primary transition-colors"
onClick={addDraft}
data-testid="add-instance-bottom"
>
<Plus className="size-4" />
<span className="text-sm">{tSetting('addInstanceText')}</span>
</button>
</div>
</div>
</>
)}
</section>
{/* 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={(payload) => onBedrockAddingOk(payload, true)}
></BedrockModal>
<SoMarkModal
visible={somarkVisible}
hideModal={hideSoMarkModal}
onOk={onSoMarkOk}
loading={somarkLoading}
onVerify={onSoMarkVerifying}
></SoMarkModal>
</div>
);
};
export default ModelProviders;
export default SettingModelV2;

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use client';
import {

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// src/components/AvailableModels.tsx
import { LlmIcon } from '@/components/svg-icon';
import { Button } from '@/components/ui/button';
@@ -43,7 +59,7 @@ type ModelType =
| 'vision'
| 'ocr';
const sortModelTypes = (modelTypes: string[]) => {
export const sortModelTypes = (modelTypes: string[]) => {
return [...modelTypes].sort(
(a, b) =>
(orderMap[a as ModelType] || 999) - (orderMap[b as ModelType] || 999),

View File

@@ -0,0 +1,783 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Form } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import { Segmented } from '@/components/ui/segmented';
import { useTranslate } from '@/hooks/common-hooks';
import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options';
import {
useAddProviderInstance,
useDeleteProviderInstance,
useFetchProviderInstance,
useVerifyProviderConnection,
} from '@/hooks/use-llm-request';
import { IProviderInstance } from '@/interfaces/database/llm';
import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm';
import { zodResolver } from '@hookform/resolvers/zod';
import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { BedrockRegionList } from '../constants';
import { VerifyResult } from '../hooks';
import { splitProviderPayload } from '../payload-utils';
import { ModelsSection } from './models-section';
import VerifyButton from './verify-button';
type AuthMode = 'access_key_secret' | 'iam_role' | 'assume_role';
type BedrockFormValues = {
auth_mode: AuthMode;
bedrock_ak?: string;
bedrock_sk?: string;
aws_role_arn?: string;
bedrock_region: string;
llm_name: string;
max_tokens: number;
model_type: ('chat' | 'embedding')[];
};
// Field names whose value commits via click (Segmented, Select,
// MultiSelect) rather than blur. Their popovers render in Radix
// portals outside the card's blur container, so blur-driven saves
// don't catch them — a form.watch watcher is used instead.
const BEDROCK_WATCHED_FIELDS = new Set([
'auth_mode',
'bedrock_region',
'model_type',
]);
interface BedrockInstanceCardProps {
providerName: string;
instance: IProviderInstance;
isDraft?: boolean;
onSaved?: (values: Record<string, any>) => void | Promise<void>;
onNameSaved?: () => void;
onDelete?: () => void;
/**
* When true, this card starts expanded and fetches its instance
* details on mount. Default `false` so non-first cards stay
* collapsed until the user opens them.
*/
defaultOpen?: boolean;
}
/**
* Inline instance card for AWS Bedrock. Mirrors the two-stage UX of
* `ProviderInstanceCard` (save name first, then edit fields) but renders
* Bedrock-specific fields (auth_mode segmented, ak/sk/arn, region, model
* name, max tokens, model_type) directly instead of going through the
* generic DynamicForm path.
*/
export function BedrockInstanceCard({
providerName,
instance,
isDraft = false,
onSaved,
onNameSaved,
onDelete,
defaultOpen = false,
}: BedrockInstanceCardProps) {
const { t } = useTranslation();
const { t: tSetting } = useTranslate('setting');
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const [open, setOpen] = useState(isDraft || defaultOpen);
const [draftName, setDraftName] = useState('');
const [nameSaved, setNameSaved] = useState(!isDraft);
const savingRef = useRef(false);
useEffect(() => {
if (isDraft) {
setDraftName('');
setNameSaved(false);
} else {
setNameSaved(true);
}
}, [providerName, isDraft]);
const FormSchema = useMemo(
() =>
z
.object({
auth_mode: z
.enum(['access_key_secret', 'iam_role', 'assume_role'])
.default('access_key_secret'),
bedrock_ak: z.string().optional(),
bedrock_sk: z.string().optional(),
aws_role_arn: z.string().optional(),
bedrock_region: z
.string()
.min(1, { message: tSetting('bedrockRegionMessage') }),
llm_name: z
.string()
.min(1, { message: tSetting('bedrockModelNameMessage') }),
max_tokens: z
.number({
required_error: tSetting('maxTokensMessage'),
invalid_type_error: tSetting('maxTokensInvalidMessage'),
})
.nonnegative({ message: tSetting('maxTokensMinMessage') }),
model_type: z
.array(z.enum(['chat', 'embedding']))
.min(1, { message: tSetting('modelTypeMessage') }),
})
.superRefine((data, ctx) => {
if (data.auth_mode === 'access_key_secret') {
if (!data.bedrock_ak || !data.bedrock_ak.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: tSetting('bedrockAKMessage'),
path: ['bedrock_ak'],
});
}
if (!data.bedrock_sk || !data.bedrock_sk.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: tSetting('bedrockSKMessage'),
path: ['bedrock_sk'],
});
}
}
if (data.auth_mode === 'iam_role') {
if (!data.aws_role_arn || !data.aws_role_arn.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: tSetting('awsRoleArnMessage'),
path: ['aws_role_arn'],
});
}
}
}),
[tSetting],
);
const { data: instanceDetails, refetch: refetchInstanceDetails } =
useFetchProviderInstance(
isDraft ? '' : providerName,
isDraft ? '' : instance.instance_name,
);
// Lazily fetch full instance details only when the card is open.
// Mirrors the generic ProviderInstanceCard: collapsed cards never
// hit /providers/<name>/instances/<instance_name>; expanding one
// triggers a fresh refetch.
useEffect(() => {
if (!isDraft && open && providerName && instance.instance_name) {
refetchInstanceDetails();
}
}, [
isDraft,
open,
providerName,
instance.instance_name,
refetchInstanceDetails,
]);
const initialValues = useMemo<BedrockFormValues>(() => {
const merged = { ...instance, ...(instanceDetails ?? {}) } as any;
const apiKey =
merged.api_key && typeof merged.api_key === 'object'
? merged.api_key
: {};
return {
auth_mode: (apiKey.auth_mode as AuthMode) ?? 'access_key_secret',
bedrock_ak: apiKey.bedrock_ak ?? '',
bedrock_sk: apiKey.bedrock_sk ?? '',
aws_role_arn: apiKey.aws_role_arn ?? '',
bedrock_region:
merged.region && merged.region !== 'default' ? merged.region : '',
llm_name: '',
max_tokens: 8192,
model_type: ['chat'],
};
}, [instance, instanceDetails]);
const form = useForm<BedrockFormValues>({
resolver: zodResolver(FormSchema),
defaultValues: initialValues,
});
useEffect(() => {
// Reset form when initial values change (e.g. instance details load).
form.reset(initialValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
const authMode = useWatch({ control: form.control, name: 'auth_mode' });
const regionOptions = useMemo(
() => BedrockRegionList.map((x) => ({ value: x, label: tSetting(x) })),
[tSetting],
);
// Build a Bedrock-shaped payload for both submit and verify flows.
const buildPayload = useCallback(
(values: BedrockFormValues, instanceName: string) => {
const cleaned: Record<string, any> = { ...values };
const fieldsByMode: Record<AuthMode, string[]> = {
access_key_secret: ['bedrock_ak', 'bedrock_sk'],
iam_role: ['aws_role_arn'],
assume_role: [],
};
(Object.keys(fieldsByMode) as AuthMode[]).forEach((mode) => {
if (mode !== values.auth_mode) {
fieldsByMode[mode].forEach((f) => {
delete cleaned[f];
});
}
});
const flat = {
...cleaned,
instance_name: instanceName,
llm_factory: providerName,
max_tokens: values.max_tokens,
model_type: values.model_type,
};
const { instancePayload, modelPayload } = splitProviderPayload(flat);
return {
...instancePayload,
max_tokens: modelPayload.max_tokens,
model_info: [modelPayload],
} as IAddProviderInstanceRequestBody;
},
[providerName],
);
const { verifyProviderConnection } = useVerifyProviderConnection();
const handleVerify = useCallback(
async (params: any) => {
const isValid = await form.trigger();
if (!isValid) {
return {
isValid: false,
logs: tSetting('bedrockRegionMessage'),
} as VerifyResult;
}
const values = form.getValues();
const payload = buildPayload(
values,
draftName.trim() || instance.instance_name,
);
const { instancePayload, modelPayload } = splitProviderPayload({
...payload,
...values,
llm_factory: providerName,
instance_name: draftName.trim() || instance.instance_name,
});
const ret = await verifyProviderConnection({
provider_name: providerName,
api_key: JSON.stringify(instancePayload.api_key),
base_url: instancePayload.base_url,
region: instancePayload.region,
model_info: [modelPayload],
...params,
});
return {
isValid: ret.code === 0,
logs: ret.message,
} as VerifyResult;
},
[
form,
providerName,
buildPayload,
draftName,
instance.instance_name,
verifyProviderConnection,
tSetting,
],
);
const { addProviderInstance } = useAddProviderInstance();
const handleSaveName = useCallback(async () => {
const trimmed = draftName.trim();
if (!trimmed) return;
const ret = await addProviderInstance({
llm_factory: providerName,
instance_name: trimmed,
} as any);
if (ret?.code === 0) {
onNameSaved?.();
}
}, [draftName, addProviderInstance, providerName, onNameSaved]);
// Auto-save in draft mode after the name is locked. Debounced on form
// value changes; refuses to fire until validation passes.
useEffect(() => {
if (!isDraft) return;
if (!nameSaved) return;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const sub = form.watch(() => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (cancelled || savingRef.current) return;
const isValid = await form.trigger();
if (cancelled || savingRef.current) return;
if (!isValid) return;
const trimmed = draftName.trim();
if (!trimmed) return;
savingRef.current = true;
try {
const values = form.getValues();
const payload = buildPayload(values, trimmed);
await onSaved?.(payload as unknown as Record<string, any>);
} finally {
savingRef.current = false;
}
}, 200);
});
return () => {
cancelled = true;
if (saveTimeout) clearTimeout(saveTimeout);
try {
sub?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, nameSaved, form, draftName, buildPayload, onSaved]);
// Saved-mode auto-save. Both blur-driven (text inputs) and
// change-driven (Segmented / Select / MultiSelect) edits are
// coalesced through a shared debounced `scheduleSave`. Selects render
// in Radix portals outside the card's blur container, so blur-driven
// saves don't catch them — a form.watch watcher is used instead.
const blurSavingRef = useRef(false);
// Flipped to true while a child (e.g. ModelsSection's
// AddCustomModelDialog) opens a Portal-based dialog. Suppresses the
// spurious blur-save fired when focus moves into the Portal.
const blurSuppressRef = useRef(false);
const lastSavedSigRef = useRef('');
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const AUTO_SAVE_DEBOUNCE_MS = 500;
const performSave = useCallback(async () => {
if (isDraft) return;
if (blurSavingRef.current) return;
if (blurSuppressRef.current) return;
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
const payload = buildPayload(values, instance.instance_name);
const finalPayload = {
...payload,
id: instanceDetails?.id || instance.id,
};
const sig = JSON.stringify(finalPayload);
if (sig === lastSavedSigRef.current) return;
blurSavingRef.current = true;
try {
const ret = await addProviderInstance(finalPayload as any);
if (ret?.code === 0) {
lastSavedSigRef.current = sig;
}
} finally {
blurSavingRef.current = false;
}
}, [
isDraft,
form,
buildPayload,
instance.instance_name,
instance.id,
instanceDetails?.id,
addProviderInstance,
]);
const scheduleSave = useCallback(() => {
if (isDraft) return;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
void performSave();
}, AUTO_SAVE_DEBOUNCE_MS);
}, [isDraft, performSave]);
const handleFieldsBlur = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (isDraft) return;
if (
e.currentTarget.contains(e.relatedTarget as Node | null) &&
e.relatedTarget !== null
) {
return;
}
scheduleSave();
},
[isDraft, scheduleSave],
);
// Segmented / Select / MultiSelect change-driven save (saved mode
// only). These commit via click and their popovers render in portals,
// so blur-driven saves don't catch them. Watch the form directly.
// Only react to user-driven changes (type === 'change'); ignore
// programmatic resets (form.reset when instanceDetails loads).
useEffect(() => {
if (isDraft) return;
if (!instanceDetails) return;
let cancelled = false;
const subscription = form.watch(
(_values: any, meta: { name?: string; type?: string }) => {
if (cancelled) return;
if (meta?.type !== 'change') return;
if (!meta?.name || !BEDROCK_WATCHED_FIELDS.has(meta.name)) return;
scheduleSave();
},
);
return () => {
cancelled = true;
try {
subscription?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, instanceDetails, form, scheduleSave]);
// Clear pending save on unmount.
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, []);
const { deleteProviderInstance } = useDeleteProviderInstance();
const handleDelete = useCallback(async () => {
if (isDraft) {
onDelete?.();
} else {
await deleteProviderInstance({
provider_name: providerName,
instances: [instance.instance_name],
});
}
}, [
isDraft,
providerName,
instance.instance_name,
deleteProviderInstance,
onDelete,
]);
// ──────────────── Field group rendered in both modes ────────────────
const renderFields = () => (
<Form {...form}>
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
<RAGFlowFormItem
name="model_type"
label={tSetting('modelType')}
required
>
{(field) => (
<MultiSelect
options={buildModelTypeOptions(['chat', 'embedding'])}
placeholder={tSetting('modelTypeMessage')}
onValueChange={field.onChange}
defaultValue={field.value}
variant="inverted"
maxCount={100}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem name="llm_name" label={tSetting('modelName')} required>
<Input placeholder={tSetting('bedrockModelNameMessage')} />
</RAGFlowFormItem>
<div>
<RAGFlowFormItem name="auth_mode">
{(field) => (
<Segmented
value={field.value}
onChange={(value) => {
if (value !== 'access_key_secret') {
form.setValue('bedrock_ak', '');
form.setValue('bedrock_sk', '');
}
if (value !== 'iam_role') {
form.setValue('aws_role_arn', '');
}
field.onChange(value);
}}
options={[
{
label: tSetting('awsAuthModeAccessKeySecret'),
value: 'access_key_secret',
},
{ label: tSetting('awsAuthModeIamRole'), value: 'iam_role' },
{
label: tSetting('awsAuthModeAssumeRole'),
value: 'assume_role',
},
]}
/>
)}
</RAGFlowFormItem>
</div>
{authMode === 'access_key_secret' && (
<>
<RAGFlowFormItem
name="bedrock_ak"
label={tSetting('awsAccessKeyId')}
required
>
<Input placeholder={tSetting('bedrockAKMessage')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="bedrock_sk"
label={tSetting('awsSecretAccessKey')}
required
>
<Input placeholder={tSetting('bedrockSKMessage')} />
</RAGFlowFormItem>
</>
)}
{authMode === 'iam_role' && (
<RAGFlowFormItem
name="aws_role_arn"
label={tSetting('awsRoleArn')}
required
>
<Input placeholder={tSetting('awsRoleArnMessage')} />
</RAGFlowFormItem>
)}
{authMode === 'assume_role' && (
<div className="text-sm text-text-secondary">
{tSetting('awsAssumeRoleTip')}
</div>
)}
<RAGFlowFormItem
name="bedrock_region"
label={tSetting('bedrockRegion')}
required
>
{(field) => (
<SelectWithSearch
value={field.value}
onChange={field.onChange}
options={regionOptions}
placeholder={tSetting('bedrockRegionMessage')}
allowClear
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="max_tokens"
label={tSetting('maxTokens')}
required
>
{(field) => (
<Input
type="number"
placeholder={tSetting('maxTokensTip')}
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
)}
</RAGFlowFormItem>
</form>
{/* VerifyButton lives inside <Form> (FormProvider) so its
internal useFormContext() resolves the form instance.
Rendered outside <form> so it never triggers submission. */}
<div className="pt-3">
<VerifyButton onVerify={handleVerify} isAbsolute={false} />
</div>
</Form>
);
return (
<div
className="border-b border-border-button mb-5 pb-5"
data-testid={`instance-card-${instance.instance_name || 'draft'}`}
>
{nameSaved ? (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-1 w-full mb-5">
<div
className="group flex items-center flex-1 gap-2 px-2 mx-2 py-1 cursor-pointer bg-bg-input rounded-md"
data-testid="instance-name-row"
>
<Button
variant="ghost"
size="icon-sm"
aria-label={
open ? t('setting.hideModels') : t('setting.showMoreModels')
}
data-testid="instance-collapse"
>
{open ? (
<ListChevronsDownUp className="size-4" />
) : (
<ListChevronsUpDown className="size-4" />
)}
</Button>
<span
className="text-sm font-medium"
data-testid="instance-name-static"
>
{draftName || instance.instance_name}
</span>
</div>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
aria-label={tSetting('deleteInstance')}
data-testid="instance-delete"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
</CollapsibleTrigger>
<CollapsibleContent
forceMount
className="data-[state=closed]:hidden overflow-hidden"
>
<div
className="px-2 pb-4 flex flex-col gap-4"
onBlurCapture={handleFieldsBlur}
>
{renderFields()}
<div className="pt-3">
<ModelsSection
providerName={providerName}
instanceName={instance.instance_name || '__draft__'}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => form.getValues()}
onBlurSuppressChange={(s) => {
blurSuppressRef.current = s;
}}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<div className="px-2 py-3 flex flex-col gap-4">
<div
className="flex flex-col gap-1.5"
data-testid="instance-name-section"
>
<label
htmlFor="instance-name-input"
className="text-sm font-medium text-text-primary"
>
<span className="text-destructive mr-0.5">*</span>
{tSetting('instanceName')}
</label>
<div className="flex items-center">
<Input
id="instance-name-input"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={tSetting('instanceNamePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveName();
}
}}
className="flex-1 rounded-r-none"
data-testid="instance-name-input"
/>
<Button
onClick={handleSaveName}
disabled={!draftName.trim()}
data-testid="instance-name-save"
variant="outline"
className="rounded-l-none bg-bg-input shrink-0"
>
{tSetting('save')}
</Button>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
className="ml-2 shrink-0"
aria-label={tSetting('deleteInstance')}
data-testid="draft-delete"
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
<p
className="text-xs text-text-secondary"
data-testid="instance-name-helper"
>
{tSetting('instanceNameSaveTip')}
</p>
</div>
<fieldset
disabled={!nameSaved}
className="contents disabled:[&_*]:pointer-events-none disabled:opacity-60"
data-testid="instance-locked-fields"
>
{renderFields()}
<div className="pt-3">
<ModelsSection
providerName={providerName}
instanceName={instance.instance_name || '__draft__'}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => form.getValues()}
/>
</div>
</fieldset>
</div>
)}
</div>
);
}
export default BedrockInstanceCard;

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynamicForm, DynamicFormRef } from '@/components/dynamic-form';
import { RefObject } from 'react';
import { DRAFT_INSTANCE_SENTINEL, DraftModeCardProps } from '../interface';
import { ModelsSection } from '../models-section';
import VerifyButton from '../verify-button';
import { InstanceNameSection } from './instance-name-section';
/**
* The draft (unsaved) variant of the provider instance card.
*
* Renders the instance name input section at the top, followed by a
* `<fieldset disabled>` that wraps the form fields, verify button, and
* per-instance models section. Fields are visually locked (and
* pointer-events disabled) until the user saves the instance name —
* after which the parent removes this draft and replaces it with a
* saved card.
*/
export function DraftModeCard({
formFields,
formDefaultValues,
formRef,
handleVerify,
handleDelete,
handleSaveName,
handleInstanceModelsEdited,
providerName,
instanceName,
instance,
modelInfoRef,
draftName,
setDraftName,
}: DraftModeCardProps) {
return (
<div className="px-2 py-3 flex flex-col gap-4">
<InstanceNameSection
draftName={draftName}
setDraftName={setDraftName}
handleSaveName={handleSaveName}
handleDelete={handleDelete}
/>
<fieldset
disabled
className="contents disabled:[&_*]:pointer-events-none disabled:opacity-60"
data-testid="instance-locked-fields"
>
<DynamicForm.Root
key={`${providerName}-${instanceName}-true`}
ref={formRef as RefObject<DynamicFormRef>}
fields={formFields}
onSubmit={() => undefined}
defaultValues={formDefaultValues}
labelClassName="font-normal"
/>
<div className=" pt-3">
<VerifyButton
onVerify={handleVerify}
isAbsolute={false}
formRef={formRef}
/>
</div>
<div className=" pt-3">
<ModelsSection
providerName={providerName}
instanceName={instanceName || DRAFT_INSTANCE_SENTINEL}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => formRef.current?.getValues?.() ?? {}}
onInstanceModelsChange={(info) => {
modelInfoRef.current = info;
}}
onInstanceModelsEdited={handleInstanceModelsEdited}
/>
</div>
</fieldset>
</div>
);
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useTranslate } from '@/hooks/common-hooks';
import { Trash2 } from 'lucide-react';
export interface InstanceNameSectionProps {
draftName: string;
setDraftName: (name: string) => void;
handleSaveName: () => Promise<void>;
handleDelete: () => Promise<void>;
}
/**
* The instance-name input section shown at the top of a draft (unsaved)
* card. The input itself carries the destructive red border (no wrapping
* red box) and is paired with a Save button + delete icon. The helper
* text below explains the workflow.
*/
export function InstanceNameSection({
draftName,
setDraftName,
handleSaveName,
handleDelete,
}: InstanceNameSectionProps) {
const { t: tSetting } = useTranslate('setting');
return (
<div className="flex flex-col gap-1.5" data-testid="instance-name-section">
<label
htmlFor="instance-name-input"
className="text-sm font-medium text-text-primary"
>
<span className="text-destructive mr-0.5">*</span>
{tSetting('instanceName')}
</label>
<div className="flex items-center">
<Input
id="instance-name-input"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={tSetting('instanceNamePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleSaveName();
}
}}
// The input itself carries the red border (not a wrapping
// box). Persists while the name is unsaved.
className="flex-1 rounded-r-none"
data-testid="instance-name-input"
/>
<Button
onClick={() => void handleSaveName()}
disabled={!draftName.trim()}
data-testid="instance-name-save"
variant="outline"
className="rounded-l-none bg-bg-input shrink-0"
>
{tSetting('save')}
</Button>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
className="ml-2 shrink-0"
aria-label={tSetting('deleteInstance')}
data-testid="draft-delete"
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
<p
className="text-xs text-text-secondary"
data-testid="instance-name-helper"
>
{tSetting('instanceNameSaveTip')}
</p>
</div>
);
}

View File

@@ -0,0 +1,148 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { DynamicForm, DynamicFormRef } from '@/components/dynamic-form';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { useTranslate } from '@/hooks/common-hooks';
import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react';
import { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { DRAFT_INSTANCE_SENTINEL, SavedModeCardProps } from '../interface';
import { ModelsSection } from '../models-section';
import VerifyButton from '../verify-button';
/**
* The saved (non-draft) variant of the provider instance card.
*
* Renders a Collapsible whose trigger shows the instance name + delete
* button, and whose content holds the form fields, the verify button,
* and (when expanded) the per-instance models section.
*/
export function SavedModeCard({
formFields,
formDefaultValues,
formRef,
handleFieldsBlur,
handleVerify,
handleDelete,
handleInstanceModelsEdited,
providerName,
instanceName,
instance,
instanceDetailsLoaded,
modelInfoRef,
blurSuppressRef,
draftName,
open,
setOpen,
}: SavedModeCardProps) {
const { t } = useTranslation();
const { t: tSetting } = useTranslate('setting');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-1 w-full mb-5">
<div
className="group w-[calc(100%-40px)] flex items-center flex-1 gap-2 px-2 py-1 cursor-pointer bg-bg-input rounded-md"
data-testid="instance-name-row"
>
<Button
variant="ghost"
size="icon-sm"
aria-label={
open ? t('setting.hideModels') : t('setting.showMoreModels')
}
data-testid="instance-collapse"
>
{open ? (
<ListChevronsDownUp className="size-4" />
) : (
<ListChevronsUpDown className="size-4" />
)}
</Button>
<div
className="text-sm font-medium truncate overflow-hidden w-[calc(100%-40px)]"
data-testid="instance-name-static"
>
{draftName || instanceName}
</div>
</div>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
aria-label={tSetting('deleteInstance')}
data-testid="instance-delete"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
</CollapsibleTrigger>
<CollapsibleContent forceMount className="data-[state=closed]:hidden">
<div
className="pb-4 flex flex-col gap-4"
onBlurCapture={handleFieldsBlur}
>
<DynamicForm.Root
key={`${providerName}-${instanceName}-false-${instanceDetailsLoaded ? 'loaded' : 'pending'}`}
ref={formRef as RefObject<DynamicFormRef>}
fields={formFields}
onSubmit={() => undefined}
defaultValues={formDefaultValues}
labelClassName="font-normal"
/>
<div className=" pt-3">
<VerifyButton
onVerify={handleVerify}
isAbsolute={false}
formRef={formRef}
/>
</div>
{open && (
<div className=" pt-3">
<ModelsSection
providerName={providerName}
instanceName={instanceName || DRAFT_INSTANCE_SENTINEL}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => formRef.current?.getValues?.() ?? {}}
onBlurSuppressChange={(s) => {
blurSuppressRef.current = s;
}}
onInstanceModelsChange={(info) => {
modelInfoRef.current = info;
}}
onInstanceModelsEdited={handleInstanceModelsEdited}
/>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,816 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynamicFormRef, FormFieldType } from '@/components/dynamic-form';
import {
useAddProviderInstance,
useDeleteProviderInstance,
useFetchAvailableProviders,
useFetchProviderInstance,
useUpdateProviderInstance,
useVerifyProviderConnection,
} from '@/hooks/use-llm-request';
import { IProviderInstance } from '@/interfaces/database/llm';
import {
IModelInfo,
IUpdateProviderInstanceRequestBody,
} from '@/interfaces/request/llm';
import { RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { useProviderFields } from '../provider-schema/hooks';
import { SelectOption } from '../provider-schema/types';
import { API_KEY_NESTED_FIELDS, ApiKeyNestedField } from './interface';
// ---------------------------------------------------------------------------
// Pure helpers — api_key shape normalization
// ---------------------------------------------------------------------------
/**
* Build the `api_key` payload value from the flat form values. If any of
* the nested credential fields (see `API_KEY_NESTED_FIELDS`) carry a
* value, returns `{ api_key, ...nested }`; otherwise returns the plain
* api_key string. Used by both the auto-save payload and its change
* signature baseline so the two stay byte-identical.
*/
export function buildApiKeyValue(
values: Record<string, any>,
): string | Record<string, any> | undefined {
const nested: Record<string, any> = {};
for (const field of API_KEY_NESTED_FIELDS) {
const v = values[field];
if (v !== undefined && v !== '') nested[field] = v;
}
if (Object.keys(nested).length === 0) return values.api_key;
return { api_key: values.api_key ?? '', ...nested };
}
/**
* Inverse of `buildApiKeyValue`, used on the echo/prefill path. The
* backend persists the credential bundle as a JSON string (see
* `rag/llm/chat_model.py`, which does `json.loads(key)`), so
* `showProviderInstance` may return `api_key` as:
* 1. a raw JSON string `'{"api_key":"sk-x","group_id":"123"}'`
* 2. an already-parsed object `{ api_key, group_id }`
* 3. a plain bare key string `'sk-x'`
* Normalise all three into the bare key plus any nested credential
* fields so the form pre-fills group_id / api_version / provider_order.
*/
export function unwrapApiKey(raw: unknown): {
apiKey: string;
nested: Record<string, any>;
} {
let obj: any = raw;
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
obj = JSON.parse(trimmed);
} catch {
obj = raw;
}
}
}
if (obj && typeof obj === 'object') {
const nested: Record<string, any> = {};
for (const field of API_KEY_NESTED_FIELDS) {
const v = obj[field];
if (v !== undefined && v !== '') nested[field] = v;
}
return { apiKey: obj.api_key ?? '', nested };
}
return { apiKey: typeof raw === 'string' ? raw : '', nested: {} };
}
/** Pick the value associated with the `default` region, if present. */
function pickDefaultUrl(
options?: Array<{ value: string; regionKey?: string }>,
): string | undefined {
return options?.find((o) => o.regionKey === 'default')?.value;
}
// ---------------------------------------------------------------------------
// useProviderBaseUrlOptions — fetch provider catalog and build URL options
// ---------------------------------------------------------------------------
/**
* Fetch the catalog of available providers and derive the
* `base_url` / `api_base` dropdown options for the current provider.
* Used to pre-fill the URL field with the provider's default URL when
* creating a new instance.
*/
export function useProviderBaseUrlOptions(providerName: string) {
const { data: availableProviders } = useFetchAvailableProviders();
const currentProvider = useMemo(
() =>
providerName
? availableProviders.find((p) => p.name === providerName)
: undefined,
[availableProviders, providerName],
);
const baseUrlOptions = useMemo(() => {
const urlObj = currentProvider?.url as
| Record<string, string | undefined>
| undefined;
if (!urlObj) return undefined;
const options = Object.keys(urlObj)
.filter((k) => !!urlObj[k])
.map((k) => {
const v = urlObj[k] as string;
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;
}, [currentProvider]);
return { baseUrlOptions, availableProviders };
}
// ---------------------------------------------------------------------------
// useProviderInitialValues — form initial values for draft vs saved
// ---------------------------------------------------------------------------
/**
* Build the form's initial values from the persisted instance, the
* lazy-loaded `showProviderInstance` details, and the provider's default
* base URL.
*
* - Draft: empty form, with `base_url` / `api_base` pre-filled from the
* provider's `default` URL when available.
* - Saved: prefer `instanceDetails` (which carries api_key / base_url);
* normalise api_key into the bare key plus any nested credential
* fields (see {@link unwrapApiKey}).
*/
export function useProviderInitialValues(
instance: IProviderInstance,
instanceDetails: IProviderInstance | undefined,
isDraft: boolean,
baseUrlOptions: SelectOption[] | undefined,
) {
return useMemo(() => {
const defaultBaseUrl = pickDefaultUrl(baseUrlOptions);
if (isDraft) {
const values: Record<string, any> = { instance_name: '' };
if (defaultBaseUrl) {
values.base_url = defaultBaseUrl;
values.api_base = defaultBaseUrl;
}
return values;
}
const merged: IProviderInstance = {
...instance,
...(instanceDetails ?? ({} as IProviderInstance)),
};
const values: Record<string, any> = {
instance_name: merged.instance_name,
};
// api_key may come back as a JSON string, an already-parsed object,
// or a plain bare key (see `unwrapApiKey`). Normalise it so the
// api_key text field shows the bare key and the nested credential
// fields (MiniMax group_id, Azure api_version, OpenRouter
// provider_order) pre-fill their own form inputs.
if (merged.api_key) {
const { apiKey, nested } = unwrapApiKey(merged.api_key);
values.api_key = apiKey;
Object.assign(values, nested);
}
if (merged.base_url) {
values.base_url = merged.base_url;
values.api_base = merged.base_url;
} else if (defaultBaseUrl) {
values.base_url = defaultBaseUrl;
values.api_base = defaultBaseUrl;
}
// The /providers/<p>/instances/<i> endpoint also returns `region`
// for providers where it applies; surface it so the form / region
// submit logic can echo it back.
if ((merged as any).region) values.region = (merged as any).region;
// Fallback: some backends may still surface these credential fields
// at the top level rather than nested in api_key — echo any that
// weren't already lifted from the api_key object above.
for (const field of API_KEY_NESTED_FIELDS) {
if (values[field] === undefined && (merged as any)[field] !== undefined) {
values[field] = (merged as any)[field];
}
}
return values;
}, [instance, instanceDetails, isDraft, baseUrlOptions]);
}
// ---------------------------------------------------------------------------
// useLazyInstanceDetails — fetch full instance details when card opens
// ---------------------------------------------------------------------------
/**
* The list endpoint (`useFetchProviderInstances`) does not return
* sensitive/heavy fields like `api_key` or `base_url`. Pull the full
* instance via `showProviderInstance` so the form can be pre-filled when
* the user clicks an existing provider on the left. The hook is
* `enabled: false` by default — we trigger it manually here so we
* don't change behavior of other call sites.
*/
export function useLazyInstanceDetails(
providerName: string,
instanceName: string,
isDraft: boolean,
open: boolean,
) {
const { data: instanceDetails, refetch: refetchInstanceDetails } =
useFetchProviderInstance(
isDraft ? '' : providerName,
isDraft ? '' : instanceName,
);
useEffect(() => {
if (!isDraft && open && providerName && instanceName) {
refetchInstanceDetails();
}
}, [isDraft, open, providerName, instanceName, refetchInstanceDetails]);
return { instanceDetails, refetchInstanceDetails };
}
// ---------------------------------------------------------------------------
// useFormResetOnDetailsLoad — re-fill form when instanceDetails resolves
// ---------------------------------------------------------------------------
/**
* React-Hook-Form only consumes `defaultValues` on first mount, so we
* explicitly reset the form here to make the freshly-fetched values
* visible. We use `keepDirtyValues` so the user's in-progress edits
* (if any) are not clobbered by a background refetch.
*/
export function useFormResetOnDetailsLoad(
formRef: RefObject<DynamicFormRef>,
formDefaultValues: Record<string, any>,
instanceDetails: IProviderInstance | undefined,
isDraft: boolean,
) {
useEffect(() => {
if (isDraft) return;
if (!instanceDetails) return;
const form = (formRef.current as any)?.form;
if (form?.reset) {
form.reset(formDefaultValues, { keepDirtyValues: true });
} else {
formRef.current?.reset?.(formDefaultValues);
}
}, [isDraft, instanceDetails, formDefaultValues, formRef]);
}
// ---------------------------------------------------------------------------
// useVerifyProvider — wraps useVerifyProviderConnection for the card
// ---------------------------------------------------------------------------
/**
* Adapter that reads the current form values and proxies them into
* `verifyProviderConnection`, then shapes the response into the
* `VerifyResult` consumed by {@link VerifyButton}.
*/
export function useVerifyProvider(
providerName: string,
formRef: RefObject<DynamicFormRef>,
) {
const { verifyProviderConnection } = useVerifyProviderConnection();
return useCallback(
async (params: any) => {
const values = { ...(formRef.current?.getValues?.() ?? {}), ...params };
const ret = await verifyProviderConnection({
provider_name: providerName,
api_key: values.api_key ?? '',
base_url: values.base_url ?? values.api_base,
model_info: values.model_info,
});
if (ret.code === 0) {
return { isValid: true, logs: ret.message } as {
isValid: boolean;
logs: string;
};
}
return { isValid: false, logs: ret.message } as {
isValid: boolean;
logs: string;
};
},
[providerName, formRef, verifyProviderConnection],
);
}
// ---------------------------------------------------------------------------
// useSaveInstanceName — dedicated hook for the draft name Save button
// ---------------------------------------------------------------------------
/**
* Save the instance name on its own. Calls `addProviderInstance` with
* only the instance name (backend now supports creating an instance with
* just a name). On success notifies the parent via `onNameSaved` so it
* can remove this draft — the invalidated `providerInstances` query
* will surface the persisted card automatically.
*/
export function useSaveInstanceName(
providerName: string,
draftName: string,
onNameSaved?: () => void,
) {
const { addProviderInstance } = useAddProviderInstance();
return useCallback(async () => {
const trimmed = draftName.trim();
if (!trimmed) return;
const ret = await addProviderInstance({
llm_factory: providerName,
instance_name: trimmed,
} as any);
if (ret?.code === 0) {
onNameSaved?.();
}
}, [draftName, addProviderInstance, providerName, onNameSaved]);
}
// ---------------------------------------------------------------------------
// useDeleteInstance — wires the card's delete button
// ---------------------------------------------------------------------------
/**
* Delete handler: for saved instances calls `useDeleteProviderInstance`;
* for drafts calls `onDelete` (which maps to onCancel in the parent).
*/
export function useDeleteInstance(
providerName: string,
instanceName: string,
isDraft: boolean,
onDelete?: () => void,
) {
const { deleteProviderInstance } = useDeleteProviderInstance();
return useCallback(async () => {
if (isDraft) {
onDelete?.();
} else {
await deleteProviderInstance({
provider_name: providerName,
instances: [instanceName],
});
}
}, [isDraft, providerName, instanceName, deleteProviderInstance, onDelete]);
}
// ---------------------------------------------------------------------------
// useDraftAutoSave — 200ms-debounced watch-based save for draft mode
// ---------------------------------------------------------------------------
/**
* Auto-save: whenever the form's other fields change (in draft mode),
* watch the form values and, after a 200ms debounce (acting as a blur
* proxy — fires shortly after the user stops typing / blurs out of a
* field), trigger validation. If all required fields are valid AND
* the instance name has been entered and saved, call `onSaved` with
* the merged values.
*/
export function useDraftAutoSave(
formRef: RefObject<DynamicFormRef>,
isDraft: boolean,
nameSaved: boolean,
draftName: string,
onSaved: ((values: Record<string, any>) => void | Promise<void>) | undefined,
modelInfoRef: { current: IModelInfo[] },
) {
// Keep the latest `onSaved` and `draftName` in refs so the auto-save
// effect below can read them without re-subscribing on every render
// (the parent passes a fresh `onSaved` arrow each render).
const onSavedRef = useRef(onSaved);
useEffect(() => {
onSavedRef.current = onSaved;
});
const draftNameRef = useRef(draftName);
useEffect(() => {
draftNameRef.current = draftName;
});
useEffect(() => {
if (!isDraft) return;
const formInstance = (formRef.current as any)?.form;
if (!formInstance || typeof formInstance.watch !== 'function') return;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const savingRef = { current: false };
const subscription = formInstance.watch(() => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (cancelled || savingRef.current) return;
const isValid = await formRef.current?.trigger();
if (cancelled || savingRef.current) return;
if (!isValid) return;
// Name gate: refuse to actually save if the name is empty or
// has not been "saved" (locked). The red border on the name
// section is the visible signal — it stays on while
// `!nameSaved` regardless of whether the user has touched
// other fields.
if (!draftNameRef.current.trim() || !nameSaved) return;
if (!onSavedRef.current) return;
savingRef.current = true;
try {
const values = formRef.current?.getValues?.() ?? {};
const payload: Record<string, any> = {
...values,
instance_name: draftNameRef.current.trim(),
};
// Forward the latest per-instance model list as `model_info`
// when the user has attached models to the draft. The list
// is normally empty for drafts (the backend has nothing yet),
// but it can be populated once ModelsSection has fetched /
// received data — we only set the key when non-empty so an
// absent value is preserved as `undefined` rather than
// forcing an empty-array clear on the server.
if (modelInfoRef.current.length > 0) {
payload.model_info = modelInfoRef.current;
}
await onSavedRef.current(payload);
} finally {
savingRef.current = false;
}
}, 200);
});
return () => {
cancelled = true;
if (saveTimeout) clearTimeout(saveTimeout);
try {
subscription?.unsubscribe?.();
} catch {
// ignore cleanup errors
}
};
}, [isDraft, nameSaved, formRef, modelInfoRef]);
}
// ---------------------------------------------------------------------------
// useSavedAutoSave — blur + dropdown-driven auto-save for saved cards
// ---------------------------------------------------------------------------
/** Field types whose value is committed via click/select (not blur). The
* card's `onBlurCapture` auto-save fires before the dropdown click
* handler commits the new value, and the popover content is rendered in
* a Radix portal outside the card's blur container, so blur-based saves
* are unreliable for these. We watch the form values directly and
* trigger the same auto-save on value change. */
const DROPDOWN_FIELD_TYPES = new Set<FormFieldType>([
FormFieldType.Select,
FormFieldType.MultiSelect,
FormFieldType.Segmented,
// `Custom` is the form-field type used by `inputSelect` in this
// codebase (see use-provider-fields). Every `Custom` field rendered
// inside the provider instance card is an `InputSelect` dropdown.
FormFieldType.Custom,
]);
interface UseSavedAutoSaveArgs {
formRef: RefObject<DynamicFormRef>;
formFields: Array<{ name: string; type: FormFieldType; [k: string]: any }>;
providerName: string;
instanceName: string;
instanceId: string | undefined;
isDraft: boolean;
instanceDetails: IProviderInstance | undefined;
initialValues: Record<string, any>;
modelInfoRef: { current: IModelInfo[] };
}
/**
* Wires the blur-driven + dropdown-driven auto-save flow used by saved
* (non-draft) cards. Returns a `handleFieldsBlur` for the card body to
* attach to `onBlurCapture`, plus a `markModelsEdited` callback for
* `onInstanceModelsEdited` so the next auto-save short-circuits after
* a PATCH-driven model change.
*/
export function useSavedAutoSave({
formRef,
formFields,
providerName,
instanceName,
instanceId,
isDraft,
instanceDetails,
initialValues,
modelInfoRef,
}: UseSavedAutoSaveArgs) {
const { updateProviderInstance } = useUpdateProviderInstance();
const blurSavingRef = useRef(false);
const blurSuppressRef = useRef(false);
const lastSavedPayloadRef = useRef<string>('');
const hasSyncedInstanceRef = useRef(false);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const AUTO_SAVE_DEBOUNCE_MS = 500;
// Shared auto-save routine. Triggered by:
// - `handleFieldsBlur` (focus leaves a non-dropdown field), and
// - the dropdown value-change watcher (a dropdown field's value
// commits via click, not blur — and the popover is rendered in a
// Radix portal outside the card's blur container, so blur-based
// saves are unreliable for dropdowns).
const performAutoSave = useCallback(async () => {
if (isDraft) return;
if (blurSavingRef.current) return;
if (blurSuppressRef.current) return;
const isValid = await formRef.current?.trigger();
if (!isValid) return;
const values = formRef.current?.getValues?.() ?? {};
const resolvedId = instanceDetails?.id || instanceId;
// Providers like MiniMax / Azure-OpenAI / OpenRouter carry extra
// credential fields (group_id / api_version / provider_order) that
// the backend expects bundled *inside* api_key as an object rather
// than as top-level keys. Nesting them here also folds their values
// into the change signature below, so editing one actually triggers
// a blur-save.
const apiKeyValue = buildApiKeyValue(values as Record<string, any>);
const payload: IUpdateProviderInstanceRequestBody = {
provider_name: providerName,
instance_name: instanceName,
id: resolvedId,
api_key: apiKeyValue ?? '',
base_url: values.base_url ?? values.api_base,
region: values.region || 'default',
model_info: [],
verify: false,
};
// Pull the latest model list from ModelsSection (via the ref it
// updates). Only attach when non-empty so we don't accidentally
// wipe the persisted model set with an empty array.
if (modelInfoRef.current.length > 0) {
payload.model_info = modelInfoRef.current;
}
// Skip if nothing actually changed since the last save (or initial
// mount): prevents a no-op PUT on every focus shift.
const signature = JSON.stringify(payload);
if (signature === lastSavedPayloadRef.current) return;
blurSavingRef.current = true;
try {
const ret = await updateProviderInstance(payload);
if (ret?.code === 0) {
lastSavedPayloadRef.current = signature;
hasSyncedInstanceRef.current = true;
}
} finally {
blurSavingRef.current = false;
}
}, [
isDraft,
providerName,
instanceName,
instanceId,
instanceDetails?.id,
updateProviderInstance,
formRef,
modelInfoRef,
]);
// Debounced auto-save: coalesces rapid edits (blur cascade,
// successive dropdown changes, typing in filterable dropdowns) into
// a single delayed `performAutoSave` call.
const scheduleAutoSave = useCallback(() => {
if (isDraft) return;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
void performAutoSave();
}, AUTO_SAVE_DEBOUNCE_MS);
}, [isDraft, performAutoSave]);
const handleFieldsBlur = useCallback(
async (e: React.FocusEvent<HTMLDivElement>) => {
if (isDraft) return;
// Ignore focus moves that stay inside the same container.
if (
e.currentTarget.contains(e.relatedTarget as Node | null) &&
e.relatedTarget !== null
) {
return;
}
scheduleAutoSave();
},
[isDraft, scheduleAutoSave],
);
// Refs so the dropdown watcher effect can invoke the latest callbacks
// without re-subscribing on every render (the parent passes a fresh
// `onBlurCapture` arrow each render, and `performAutoSave` changes
// whenever its deps change — e.g. when `instanceDetails` loads).
const performAutoSaveRef = useRef(performAutoSave);
useEffect(() => {
performAutoSaveRef.current = performAutoSave;
});
const scheduleAutoSaveRef = useRef<() => void>(() => {});
useEffect(() => {
scheduleAutoSaveRef.current = scheduleAutoSave;
});
// Clear any pending debounced save when the card unmounts so we don't
// fire a stale request after teardown.
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, []);
// Seed the "last saved" signature once initial values are loaded so
// the first blur after mount doesn't trigger an unnecessary save.
useEffect(() => {
if (isDraft) return;
const resolvedId = instanceDetails?.id || instanceId;
if (!resolvedId) return;
// Match the api_key shape performAutoSave produces (extra credential
// fields nested inside api_key) so the first blur after mount
// doesn't see a signature diff and fire a redundant save. model_info
// is omitted for the same reason as in performAutoSave: model
// changes are owned by the per-model endpoints, not this auto-save.
const baseline = {
provider_name: providerName,
instance_name: instanceName,
id: resolvedId,
api_key: buildApiKeyValue(initialValues),
base_url: initialValues.base_url ?? initialValues.api_base,
region: initialValues.region,
model_info: [] as IModelInfo[],
};
lastSavedPayloadRef.current = JSON.stringify(baseline);
}, [
isDraft,
providerName,
instanceName,
instanceId,
instanceDetails?.id,
initialValues,
]);
// Dropdown value-change auto-save (saved mode only). A dropdown
// field's value commits via click, not blur — and the popover is
// rendered in a Radix portal outside the card's blur container, so
// blur-based saves are unreliable for dropdowns.
//
// We subscribe to the *raw* RHF form so we can read the change
// metadata `{ name, type }`. Only genuine user edits carry
// `type === 'change'`; programmatic updates (the `form.reset` that
// runs when `instanceDetails` loads, `setValue`, etc.) come through
// with an undefined type. Gating on `type === 'change'` is what stops
// merely opening the card / loading its details from firing a save —
// only an actual user selection in a dropdown schedules one. The
// shared debounce (`scheduleAutoSave`) coalesces it with any
// blur-triggered save. Skipped in draft mode (which has its own
// 200ms-debounced watch) and until `instanceDetails` loads.
const dropdownFieldNames = useMemo(
() =>
formFields
.filter((f) => DROPDOWN_FIELD_TYPES.has(f.type))
.map((f) => f.name),
[formFields],
);
useEffect(() => {
if (isDraft) return;
if (dropdownFieldNames.length === 0) return;
if (!instanceDetails) return;
const form = (formRef.current as any)?.form;
if (!form || typeof form.watch !== 'function') return;
let cancelled = false;
const dropdownFieldSet = new Set(dropdownFieldNames);
const subscription = form.watch(
(_values: any, meta: { name?: string; type?: string }) => {
if (cancelled) return;
// Ignore programmatic value changes (reset/setValue) — they have
// no `type`. Only react to user-driven dropdown selections.
if (meta?.type !== 'change') return;
if (!meta?.name || !dropdownFieldSet.has(meta.name)) return;
scheduleAutoSaveRef.current();
},
);
return () => {
cancelled = true;
try {
subscription?.unsubscribe?.();
} catch {
// ignore cleanup errors
}
};
}, [isDraft, dropdownFieldNames, instanceDetails, formRef]);
// Absorb a model patch into the host's last-saved baseline. When the
// user saves the edit modal, patchInstanceModel has already persisted
// the new max_tokens / model_type / features server-side, so the next
// blur auto-save should NOT re-PUT the same model_info. By parsing
// the previously-saved payload and overwriting ONLY model_info, the
// baseline now matches the current state and the signature check in
// performAutoSave short-circuits — while any in-flight edits to
// api_key / base_url / region remain in `lastSavedPayloadRef`
// unchanged and will still trigger a save on blur via signature
// mismatch.
//
// Skipped until the host has synced at least once. Before that the
// baseline still carries the initial `model_info: []`; rewriting it
// here would skip the very first PUT that syncs the user's first
// add/edit into the persisted model_info.
const markModelsEdited = useCallback(() => {
if (isDraft) return;
if (!hasSyncedInstanceRef.current) return;
const prev = lastSavedPayloadRef.current;
if (!prev) return;
const parsed = JSON.parse(prev) as IUpdateProviderInstanceRequestBody;
parsed.model_info =
modelInfoRef.current.length > 0 ? modelInfoRef.current : [];
// Mirror the `verify: false` field that performAutoSave always
// attaches, otherwise the next signature comparison would diff on
// this key alone and re-fire the save.
(parsed as any).verify = false;
lastSavedPayloadRef.current = JSON.stringify(parsed);
}, [isDraft, modelInfoRef]);
return {
handleFieldsBlur,
performAutoSave,
scheduleAutoSave,
blurSuppressRef,
markModelsEdited,
};
}
// ---------------------------------------------------------------------------
// useFormFields — wraps useProviderFields and strips instance_name
// ---------------------------------------------------------------------------
/**
* Wraps `useProviderFields` and removes the `instance_name` field from
* both the field list and the default values — the card header owns
* the instance name (editable on hover), so we keep a single source of
* truth and avoid showing it twice in the form.
*/
export function useFormFields(
providerName: string,
isDraft: boolean,
initialValues: Record<string, any>,
baseUrlOptions: SelectOption[] | undefined,
hideWhenInstanceExists: (values: any) => boolean,
) {
const { fields, defaultValues } = useProviderFields({
llmFactory: providerName,
editMode: !isDraft,
viewMode: isDraft,
initialValues,
baseUrlOptions,
hideWhenInstanceExists,
});
const formFields = useMemo(
() => fields.filter((f) => f.name !== 'instance_name'),
[fields],
);
const formDefaultValues = useMemo(() => {
const { instance_name: _ignored, ...rest } = (defaultValues ??
{}) as Record<string, any>;
void _ignored;
return rest;
}, [defaultValues]);
return { formFields, formDefaultValues };
}
// Re-export ApiKeyNestedField for components that need it.
export type { ApiKeyNestedField };

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynamicFormRef, FormFieldConfig } from '@/components/dynamic-form';
import { IProviderInstance } from '@/interfaces/database/llm';
import { IModelInfo } from '@/interfaces/request/llm';
import { RefObject } from 'react';
/** Public props for {@link ProviderInstanceCard}. */
export interface ProviderInstanceCardProps {
providerName: string;
/**
* The instance to render. When `isDraft` is true, this is a placeholder
* used to render the "new instance" inline form; the actual save call
* will use the values typed in the form fields.
*/
instance: IProviderInstance;
/**
* True when this card represents a freshly-added (unsaved) instance.
* Renders Save / Cancel buttons and treats all fields as editable.
*/
isDraft?: boolean;
/** Called after a draft instance is successfully saved. */
onSaved?: (values: Record<string, any>) => void | Promise<void>;
/**
* Called after a draft instance's *name* has been persisted via
* `addProviderInstance` (with just `instance_name`). The parent should
* remove this draft from its visible list; the freshly invalidated
* `providerInstances` query will surface the persisted card.
*/
onNameSaved?: () => void;
/**
* Called when the user deletes a draft instance.
* For drafts this is equivalent to onCancel; for saved instances
* the component calls useDeleteProviderInstance internally.
*/
onDelete?: () => void;
/**
* When true, this card starts expanded and its instance details
* are fetched on mount. Default `false` so additional cards stay
* collapsed until the user opens them — at which point details
* are fetched on demand.
*/
defaultOpen?: boolean;
}
/**
* Provider-specific credential fields that the backend expects bundled
* *inside* `api_key` as an object rather than as top-level keys:
* api_key: { api_key, group_id?, api_version?, provider_order? }
* - MiniMax → group_id
* - Azure OpenAI → api_version
* - OpenRouter → provider_order
* When none of these are present the api_key stays a bare string.
*/
export const API_KEY_NESTED_FIELDS = [
'group_id',
'api_version',
'provider_order',
] as const;
export type ApiKeyNestedField = (typeof API_KEY_NESTED_FIELDS)[number];
/** Sentinel instance name used by draft (unsaved) provider cards. */
export const DRAFT_INSTANCE_SENTINEL = '__draft__';
// ---------------------------------------------------------------------------
// Sub-component props
// ---------------------------------------------------------------------------
/** Props for the saved-mode (collapsible) card body. */
export interface SavedModeCardProps {
formFields: FormFieldConfig[];
formDefaultValues: Record<string, any>;
formRef: RefObject<DynamicFormRef>;
handleFieldsBlur: (e: React.FocusEvent<HTMLDivElement>) => void;
handleVerify: (params: any) => Promise<{ isValid: boolean; logs: string }>;
handleDelete: () => Promise<void>;
handleInstanceModelsEdited: () => void;
providerName: string;
instanceName: string;
instance: IProviderInstance;
instanceDetailsLoaded: boolean;
modelInfoRef: React.MutableRefObject<IModelInfo[]>;
blurSuppressRef: React.MutableRefObject<boolean>;
draftName: string;
open: boolean;
setOpen: (open: boolean) => void;
}
/** Props for the draft-mode card (instance name + locked form fields). */
export interface DraftModeCardProps {
formFields: FormFieldConfig[];
formDefaultValues: Record<string, any>;
formRef: RefObject<DynamicFormRef>;
handleVerify: (params: any) => Promise<{ isValid: boolean; logs: string }>;
handleDelete: () => Promise<void>;
handleSaveName: () => Promise<void>;
handleInstanceModelsEdited: () => void;
providerName: string;
instanceName: string;
instance: IProviderInstance;
modelInfoRef: React.MutableRefObject<IModelInfo[]>;
draftName: string;
setDraftName: (name: string) => void;
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Minus, Plus } from 'lucide-react';
import { ModelRowProps } from '../interface';
import { ModelTypeBadges } from './model-type-badges';
import { ModelVerifyButton } from './model-verify-button';
/** Single model row in the catalog list. */
export function ModelRow({
model,
isAdded,
verifyStatus,
hideActions,
onVerify,
onAdd,
onRemove,
onEdit,
editLabel,
}: ModelRowProps) {
return (
<li
key={model.name}
className="group flex items-center justify-between gap-3 p-3 border-b border-border-button last:border-b-0 hover:bg-bg-input transition-colors"
data-testid={`models-row-${model.name}`}
>
<div className="flex gap-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-sm text-text-primary truncate">
{model.name}
</span>
</div>
<div className="flex flex-wrap items-center gap-1">
<ModelTypeBadges
types={model.model_types ?? []}
showEdit={!hideActions}
onEdit={onEdit}
editLabel={editLabel}
editTestSuffix={model.name}
/>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<ModelVerifyButton
status={verifyStatus}
onVerify={onVerify}
modelName={model.name}
/>
{!hideActions && (
<button
type="button"
className="size-6 flex items-center justify-center rounded-md transition-colors text-text-secondary"
onClick={() => (isAdded ? onRemove() : onAdd())}
aria-label={isAdded ? `Remove ${model.name}` : `Add ${model.name}`}
>
{isAdded ? (
<Minus className="size-4" />
) : (
<Plus className="size-4" />
)}
</button>
)}
</div>
</li>
);
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Pencil } from 'lucide-react';
import { mapModelKey } from '../../available-models';
import { ModelTypeBadgesProps } from '../interface';
/** Max model-type badges shown inline; the rest collapse into a tooltip. */
const MAX_VISIBLE_TYPES = 3;
/** Renders the model-type badges row, collapsing overflow into a tooltip. */
export function ModelTypeBadges({
types,
onEdit,
showEdit,
editLabel,
editTestSuffix,
}: ModelTypeBadgesProps) {
const visible = types.slice(0, MAX_VISIBLE_TYPES);
const hidden = types.slice(MAX_VISIBLE_TYPES);
return (
<>
{visible.map((mt) => (
<span
key={mt}
className="px-1.5 py-0.5 text-[10px] bg-bg-card text-text-secondary rounded-md"
>
{mapModelKey[mt as keyof typeof mapModelKey] || mt}
</span>
))}
{hidden.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span
className="px-1.5 py-0.5 text-[10px] bg-bg-card text-text-secondary rounded-md cursor-default"
data-testid={`models-types-overflow-${editTestSuffix}`}
>
+{hidden.length}
</span>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-wrap gap-1 max-w-[16rem]">
{hidden.map((mt) => (
<span
key={mt}
className="px-1.5 py-0.5 text-[10px] bg-bg-card text-text-secondary rounded-md"
>
{mapModelKey[mt as keyof typeof mapModelKey] || mt}
</span>
))}
</div>
</TooltipContent>
</Tooltip>
)}
{showEdit && (
<button
type="button"
className="ml-1 size-5 flex items-center justify-center rounded-md text-text-secondary opacity-0 transition-all hover:bg-bg-card hover:text-text-primary group-hover:opacity-100 focus-visible:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
aria-label={editLabel}
title={editLabel}
data-testid={`models-edit-${editTestSuffix}`}
>
<Pencil className="size-3" />
</button>
)}
</>
);
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cn } from '@/lib/utils';
import { Check, Loader2, RefreshCcw, TriangleAlert } from 'lucide-react';
import { ModelVerifyButtonProps, VerifyStatus } from '../interface';
/** Icon + color for a single verify status. */
const VERIFY_ICON: Record<
VerifyStatus,
{ icon: React.ReactNode; className: string }
> = {
idle: {
icon: <RefreshCcw className="size-3" />,
className: 'text-text-secondary hover:bg-bg-input hover:text-text-primary',
},
loading: {
icon: <Loader2 className="size-4 animate-spin" />,
className: 'text-text-secondary cursor-wait',
},
success: {
icon: <Check className="size-4" />,
className: 'text-state-success',
},
error: {
icon: <TriangleAlert className="size-4" />,
className: 'text-state-warning',
},
};
/** Per-model verify button. */
export function ModelVerifyButton({
status,
onVerify,
modelName,
}: ModelVerifyButtonProps) {
const cfg = VERIFY_ICON[status];
return (
<button
type="button"
className={cn(
'size-6 flex items-center justify-center rounded-md transition-colors',
cfg.className,
)}
onClick={onVerify}
disabled={status === 'loading'}
aria-label={`Verify ${modelName}`}
>
{cfg.icon}
</button>
);
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cn } from '@/lib/utils';
import { TagFilterButtonProps } from '../interface';
/** Tag pill used in the filter row. */
export function TagFilterButton({
label,
count,
active,
onClick,
}: TagFilterButtonProps) {
return (
<button
type="button"
className={cn(
'px-2 py-0.5 text-xs rounded-md border border-border-button transition-colors',
active
? 'bg-text-primary text-bg-base'
: 'bg-bg-card text-text-secondary hover:text-text-primary',
)}
onClick={onClick}
>
{label}
<span className="ml-1 opacity-60">{count}</span>
</button>
);
}

View File

@@ -0,0 +1,601 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
useAddInstanceModel,
useDeleteInstanceModels,
useListProviderModels,
usePatchInstanceModel,
useUpdateProviderInstance,
useVerifyProviderConnection,
} from '@/hooks/use-llm-request';
import { IInstanceModel, IProviderInstance } from '@/interfaces/database/llm';
import { IModelInfo, IProviderModelItem } from '@/interfaces/request/llm';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { sortModelTypes } from '../available-models';
import { useCustomModelFields } from '../use-custom-model-fields';
import { ModelsSectionProps, VerifyStatus } from './interface';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Feature keys that mark a model as supporting tool/function calls. */
const TOOL_FEATURE_KEYS = ['is_tools', 'tool_call', 'tools', 'function_call'];
/** Sentinel instance name used by draft (unsaved) provider cards. */
export const DRAFT_INSTANCE_SENTINEL = '__draft__';
// ---------------------------------------------------------------------------
// Pure helpers (no React state, easy to test)
// ---------------------------------------------------------------------------
/** True when `features` contains any of {@link TOOL_FEATURE_KEYS}. */
export const hasToolFeature = (
features: string[] | null | undefined,
): boolean =>
Array.isArray(features) &&
features.some((f) => TOOL_FEATURE_KEYS.includes(f));
/**
* Normalize the assorted shapes returned by the backend for a model's
* `model_types` into a plain `string[]`.
* - already an array → as-is
* - a single string → wrapped
* - nullish / other → []
*/
export const normalizeModelTypes = (raw: unknown): string[] =>
Array.isArray(raw) ? raw : raw ? [raw as string] : [];
/**
* Build an `IModelInfo[]` (the shape the PUT
* `/providers/{name}/instances/{name}` endpoint expects) from a list of
* provider model items. `features` is forwarded via `extra` so the backend
* can persist per-model flags such as `is_tools`.
*/
export const buildModelInfo = (items: IProviderModelItem[]): IModelInfo[] =>
items.map((m) => ({
model_name: m.name,
model_type: m.model_types ?? [],
max_tokens: m.max_tokens ?? 0,
extra: { is_tools: hasToolFeature(m.features) },
}));
/** Resolved credentials for catalog / verify / batch calls. */
export type ResolvedCreds = { apiKey: string; baseUrl: string };
// ---------------------------------------------------------------------------
// 1. useResolveCreds — resolve api_key / base_url from host form or instance
// ---------------------------------------------------------------------------
export function useResolveCreds(
instance: IProviderInstance | undefined,
getFormValues: ModelsSectionProps['getFormValues'],
) {
// Prefer the live values from the host card's form (so the user can
// verify with an api_key they have just typed but not yet saved); fall
// back to the persisted instance fields when no form getter is wired up.
const resolveCreds = useCallback((): ResolvedCreds => {
const fv = getFormValues?.() ?? {};
return {
apiKey: (fv.api_key as string) ?? instance?.api_key ?? '',
baseUrl:
(fv.base_url as string) ??
(fv.api_base as string) ??
instance?.base_url ??
'',
};
}, [getFormValues, instance]);
return { resolveCreds };
}
// ---------------------------------------------------------------------------
// 2. useModelsCatalog — upstream provider catalog fetch + auto-fetch
// ---------------------------------------------------------------------------
interface UseModelsCatalogArgs {
providerName: string;
instanceName: string;
hideActions: boolean;
isDraftInstance: boolean;
resolveCreds: () => ResolvedCreds;
instanceModels: IInstanceModel[] | undefined;
}
export function useModelsCatalog({
providerName,
instanceName,
hideActions,
isDraftInstance,
resolveCreds,
instanceModels,
}: UseModelsCatalogArgs) {
const { listProviderModels } = useListProviderModels();
const [catalog, setCatalog] = useState<IProviderModelItem[]>([]);
const [manualListLoading, setManualListLoading] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
// Manual "List models" handler — hits the upstream catalog endpoint.
// The result is merged into `catalog`; the displayed list then becomes
// the union of catalog + instance models.
const handleListModels = async () => {
setManualListLoading(true);
try {
const { apiKey, baseUrl } = resolveCreds();
const ret = await listProviderModels({
provider_name: providerName,
api_key: apiKey,
base_url: baseUrl,
});
if (ret?.code === 0) {
setCatalog((ret.data as IProviderModelItem[]) ?? []);
}
setHasFetched(true);
} catch {
setHasFetched(true);
} finally {
setManualListLoading(false);
}
};
// Auto-fetch the provider's available-models catalog when this section
// mounts (effectively "when the card is expanded"). Skipped for draft
// instances and catalog-preview-only hosts.
const hasAutoFetchedRef = useRef(false);
useEffect(() => {
if (hasAutoFetchedRef.current) return;
if (hideActions) return;
if (!providerName) return;
if (isDraftInstance) return;
hasAutoFetchedRef.current = true;
handleListModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerName, instanceName, hideActions]);
// Mark `hasFetched` true once the per-instance query resolves — even if
// it returned an empty array — so `hideIfEmpty` can safely take effect.
useEffect(() => {
if (instanceModels) {
setHasFetched(true);
}
}, [instanceModels]);
return {
catalog,
setCatalog,
manualListLoading,
hasFetched,
handleListModels,
};
}
// ---------------------------------------------------------------------------
// 3. useModelsDerived — derived model list (instance catalog) + sync
// ---------------------------------------------------------------------------
interface UseModelsDerivedArgs {
catalog: IProviderModelItem[];
instanceModels: IInstanceModel[] | undefined;
onInstanceModelsChange: ModelsSectionProps['onInstanceModelsChange'];
onInstanceModelsEdited?: ModelsSectionProps['onInstanceModelsEdited'];
}
export function useModelsDerived({
catalog,
instanceModels,
onInstanceModelsChange,
onInstanceModelsEdited,
}: UseModelsDerivedArgs) {
const catalogFeatures = useMemo(() => {
const map = new Map<string, string[]>();
catalog.forEach((m) => {
if (Array.isArray(m.features) && m.features.length > 0) {
map.set(m.name, m.features);
}
});
return map;
}, [catalog]);
const instanceItems: IProviderModelItem[] = useMemo(() => {
// `im` is typed `any` because the backend may return either
// `model_type` or `model_types`, and `features` is not on the
// declared IInstanceModel interface.
return (instanceModels ?? []).map((im: any) => {
const model_types = normalizeModelTypes(
im.model_types ?? im.model_type ?? [],
);
const catalogFeats = catalogFeatures.get(im.name) ?? im.features ?? null;
const features =
im.is_tools && !hasToolFeature(catalogFeats)
? [...(catalogFeats ?? []), 'is_tools']
: catalogFeats;
return {
name: im.name,
max_tokens: im.max_tokens ?? 0,
model_types,
features,
};
});
}, [instanceModels, catalogFeatures]);
// Union of instance models + catalog, keyed by `name`. Catalog entries
// win on conflict; instance set listed first so already-added models
// stay at the top on the initial render.
const models: IProviderModelItem[] = useMemo(() => {
const byName = new Map<string, IProviderModelItem>();
instanceItems.forEach((m) => byName.set(m.name, m));
catalog.forEach((m) => byName.set(m.name, m));
return Array.from(byName.values());
}, [instanceItems, catalog]);
const addedSet = useMemo(
() => new Set((instanceModels ?? []).map((m: IInstanceModel) => m.name)),
[instanceModels],
);
// Keep the latest callbacks in refs so the effect below only fires
// when `instanceItems` actually changes — not on every parent
// re-render that passes a new arrow for the callbacks. The previous
// deps included the callbacks directly, which made the effect re-run
// with the same data on every render; that was harmless for the
// idempotent model_info push, but the new "edited" callback updates
// the host's last-saved baseline and must not absorb in-flight form
// edits fired by an unrelated re-render.
const onChangeRef = useRef(onInstanceModelsChange);
const onEditedRef = useRef(onInstanceModelsEdited);
useEffect(() => {
onChangeRef.current = onInstanceModelsChange;
onEditedRef.current = onInstanceModelsEdited;
});
// Track the previous set of instance model names so we can tell
// "patch" (same names, different data) apart from "add/remove"
// (different names). Only the patch case needs to fire the host-side
// baseline-update callback so the next blur auto-save short-circuits.
const prevNamesRef = useRef<Set<string>>(new Set());
// Push the latest per-instance model list up to the host so its
// auto-save can include `model_info` in the payload. When the change
// is purely a patch (same names, different data), also notify the
// host via `onInstanceModelsEdited` so it can absorb the model_info
// diff into its last-saved baseline — without this signal, the next
// blur would signature-mismatch and fire a redundant PUT carrying
// the already-PATCH-saved model_info. Adds/removes intentionally
// skip this signal so the next blur still carries the updated list
// into PUT (the standard sync path for the instance's model_info).
useEffect(() => {
const currentNames = new Set(instanceItems.map((m) => m.name));
const prevNames = prevNamesRef.current;
const isPatch =
currentNames.size > 0 &&
currentNames.size === prevNames.size &&
Array.from(currentNames).every((n) => prevNames.has(n));
onChangeRef.current?.(buildModelInfo(instanceItems));
if (isPatch) {
onEditedRef.current?.();
}
prevNamesRef.current = currentNames;
}, [instanceItems]);
return { instanceItems, models, addedSet };
}
// ---------------------------------------------------------------------------
// 4. useModelsFilter — search box + tag filter
// ---------------------------------------------------------------------------
export function useModelsFilter(models: IProviderModelItem[]) {
const [search, setSearch] = useState('');
const [tag, setTag] = useState<string | null>(null);
const filteredModels = useMemo(() => {
const q = search.trim().toLowerCase();
return models.filter((m) => {
if (q && !m.name.toLowerCase().includes(q)) return false;
if (tag && !m.model_types?.includes(tag)) return false;
return true;
});
}, [models, search, tag]);
const allTags = useMemo(() => {
const tagsSet = new Set<string>();
models.forEach((m) => m.model_types?.forEach((t) => tagsSet.add(t)));
return sortModelTypes(Array.from(tagsSet));
}, [models]);
return { search, tag, setSearch, setTag, filteredModels, allTags };
}
// ---------------------------------------------------------------------------
// 5. useModelVerify — per-model verify state + handler
// ---------------------------------------------------------------------------
interface UseModelVerifyArgs {
providerName: string;
resolveCreds: () => ResolvedCreds;
instanceModels: IInstanceModel[] | undefined;
}
export function useModelVerify({
providerName,
resolveCreds,
instanceModels,
}: UseModelVerifyArgs) {
const { verifyProviderConnection } = useVerifyProviderConnection();
const [verify, setVerify] = useState<Record<string, VerifyStatus>>({});
// Seed the per-model verify status from the backend's persisted `verify`
// flag on each instance model.
useEffect(() => {
if (!instanceModels || instanceModels.length === 0) return;
setVerify((prev) => {
let changed = false;
const next = { ...prev };
for (const im of instanceModels) {
if (im.name in next) continue;
if (im.verify === 'success') {
next[im.name] = 'success';
changed = true;
} else if (im.verify === 'fail') {
next[im.name] = 'error';
changed = true;
}
}
return changed ? next : prev;
});
}, [instanceModels]);
const handleVerify = async (model: IProviderModelItem) => {
setVerify((prev) => ({ ...prev, [model.name]: 'loading' }));
try {
const { apiKey, baseUrl } = resolveCreds();
const ret = await verifyProviderConnection({
provider_name: providerName,
api_key: apiKey,
base_url: baseUrl,
model_info: [
{
model_name: model.name,
model_type: model.model_types ?? [],
max_tokens: model.max_tokens ?? 0,
},
],
});
setVerify((prev) => ({
...prev,
[model.name]: ret.code === 0 ? 'success' : 'error',
}));
} catch {
setVerify((prev) => ({ ...prev, [model.name]: 'error' }));
}
};
return { verify, handleVerify };
}
// ---------------------------------------------------------------------------
// 6. useModelMutations — add / remove / batch toggle / custom add
// ---------------------------------------------------------------------------
interface UseModelMutationsArgs {
providerName: string;
instanceName: string;
isDraftInstance: boolean;
hideActions: boolean;
resolveCreds: () => ResolvedCreds;
instance: IProviderInstance | undefined;
instanceItems: IProviderModelItem[];
filteredModels: IProviderModelItem[];
addedSet: Set<string>;
setCatalog: Dispatch<SetStateAction<IProviderModelItem[]>>;
}
export function useModelMutations({
providerName,
instanceName,
isDraftInstance,
hideActions,
resolveCreds,
instance,
instanceItems,
filteredModels,
addedSet,
setCatalog,
}: UseModelMutationsArgs) {
const { addInstanceModel } = useAddInstanceModel();
const { deleteInstanceModels } = useDeleteInstanceModels();
const { updateProviderInstance, loading: batchLoading } =
useUpdateProviderInstance();
// True when every model currently shown in the filtered list is already
// attached to the instance — drives the +/- toggle on the batch button.
const allFilteredAdded = useMemo(
() =>
filteredModels.length > 0 &&
filteredModels.every((m) => addedSet.has(m.name)),
[filteredModels, addedSet],
);
const handleAddModel = async (model: IProviderModelItem) => {
await addInstanceModel({
provider_name: providerName,
instance_name: instanceName,
model_name: model.name,
model_type: model.model_types ?? [],
max_tokens: model.max_tokens ?? 0,
extra: { is_tools: hasToolFeature(model.features) },
});
};
const handleRemoveModel = async (model: IProviderModelItem) => {
await deleteInstanceModels({
provider_name: providerName,
instance_name: instanceName,
model_name: [model.name],
});
};
const handleAddCustom = async (item: IProviderModelItem) => {
// Append the custom item to the local catalog so it shows up in the
// unioned `models` list immediately. Server-side persistence happens
// via `addInstanceModel` below (when there is a real instance).
setCatalog((prev) =>
prev.some((m) => m.name === item.name) ? prev : [...prev, item],
);
if (hideActions || isDraftInstance) {
return;
}
await addInstanceModel({
provider_name: providerName,
instance_name: instanceName,
model_name: item.name,
model_type: item.model_types ?? [],
max_tokens: item.max_tokens ?? 0,
extra: { is_tools: hasToolFeature(item.features) },
});
};
// Batch attach/detach the currently visible (filtered) models via the
// PUT `/providers/{name}/instances/{name}` endpoint (replaces
// `model_info` wholesale).
const handleBatchToggleModels = async () => {
if (filteredModels.length === 0) return;
const { apiKey, baseUrl } = resolveCreds();
const byName = new Map<string, IProviderModelItem>();
instanceItems.forEach((m) => byName.set(m.name, m));
let nextModels: IProviderModelItem[];
if (allFilteredAdded) {
const drop = new Set(filteredModels.map((m) => m.name));
nextModels = Array.from(byName.values()).filter((m) => !drop.has(m.name));
} else {
filteredModels.forEach((m) => byName.set(m.name, m));
nextModels = Array.from(byName.values());
}
await updateProviderInstance({
provider_name: providerName,
instance_name: instanceName,
api_key: apiKey,
base_url: baseUrl,
region: instance?.region ?? 'default',
model_info: buildModelInfo(nextModels),
});
};
return {
allFilteredAdded,
handleAddModel,
handleRemoveModel,
handleAddCustom,
handleBatchToggleModels,
batchLoading,
};
}
// ---------------------------------------------------------------------------
// 7. useModelEdit — edit dialog state + submit
// ---------------------------------------------------------------------------
interface UseModelEditArgs {
providerName: string;
instanceName: string;
setCatalog: Dispatch<SetStateAction<IProviderModelItem[]>>;
}
export function useModelEdit({
providerName,
instanceName,
setCatalog,
}: UseModelEditArgs) {
const customModelDialogFields = useCustomModelFields();
const { patchInstanceModel, loading: editLoading } = usePatchInstanceModel();
// Model currently being edited via AddCustomModelDialog (with `name`
// pinned/disabled and the dialog initial values pre-populated from the
// model's current config). `null` when the edit dialog is closed.
const [editingModel, setEditingModel] = useState<IProviderModelItem | null>(
null,
);
// Field schema for the edit dialog — identical to the add schema
// except the `name` field is locked (model name is the row's primary
// key and the API forbids renaming via this endpoint).
const editModelDialogFields = useMemo(
() =>
customModelDialogFields.map((f) =>
f.name === 'name' ? { ...f, disabled: true } : f,
),
[customModelDialogFields],
);
// Initial form values for the edit dialog, derived from the model
// currently being edited.
const editDefaultValues = useMemo(() => {
if (!editingModel) return undefined;
return {
name: editingModel.name,
model_types: editingModel.model_types ?? [],
max_tokens: editingModel.max_tokens ?? 0,
features: editingModel.features ?? [],
};
}, [editingModel]);
// Persist edits to an existing model. The local `catalog` is patched so
// the UI reflects the new values immediately, before the cache
// invalidation lands.
const handleEditSubmit = async (item: IProviderModelItem) => {
if (!editingModel) return;
const targetName = editingModel.name;
setCatalog((prev) =>
prev.some((m) => m.name === targetName)
? prev.map((m) => (m.name === targetName ? item : m))
: prev,
);
await patchInstanceModel({
provider_name: providerName,
instance_name: instanceName,
model_name: targetName,
max_tokens: item.max_tokens ?? 0,
model_type: item.model_types ?? [],
extra: { is_tools: hasToolFeature(item.features) },
});
setEditingModel(null);
};
return {
editingModel,
setEditingModel,
editModelDialogFields,
editDefaultValues,
handleEditSubmit,
editLoading,
customModelDialogFields,
};
}

View File

@@ -0,0 +1,311 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button } from '@/components/ui/button';
import { SearchInput } from '@/components/ui/input';
import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks';
import { useFetchInstanceModels } from '@/hooks/use-llm-request';
import { ListMinus, ListPlus, Loader2, Plus, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddCustomModelDialog } from '../add-custom-model-dialog';
import { mapModelKey } from '../available-models';
import { ModelRow } from './components/model-row';
import { TagFilterButton } from './components/tag-filter-button';
import {
DRAFT_INSTANCE_SENTINEL,
useModelEdit,
useModelMutations,
useModelVerify,
useModelsCatalog,
useModelsDerived,
useModelsFilter,
useResolveCreds,
} from './hooks';
import { ModelsSectionProps } from './interface';
export function ModelsSection(props: ModelsSectionProps) {
const { t } = useTranslation();
const { t: tSetting } = useTranslate('setting');
const { t: tc } = useCommonTranslation();
const {
providerName,
instanceName,
instance,
hideActions = false,
hideIfEmpty = false,
getFormValues,
onBlurSuppressChange,
onInstanceModelsChange,
onInstanceModelsEdited,
} = props;
const isDraftInstance =
!instanceName || instanceName === DRAFT_INSTANCE_SENTINEL;
// 1. Credentials for catalog / verify / batch calls.
const { resolveCreds } = useResolveCreds(instance, getFormValues);
// 2. Per-instance saved models (shared by catalog, derived, verify).
const { data: instanceModels } = useFetchInstanceModels(
providerName,
instanceName,
);
// 3. Upstream catalog + auto-fetch on mount.
const {
catalog,
setCatalog,
manualListLoading,
hasFetched,
handleListModels,
} = useModelsCatalog({
providerName,
instanceName,
hideActions,
isDraftInstance,
resolveCreds,
instanceModels,
});
// 4. Derived union list (instance catalog) + push to host.
const { instanceItems, models, addedSet } = useModelsDerived({
catalog,
instanceModels,
onInstanceModelsChange,
onInstanceModelsEdited,
});
// 5. Search + tag filter.
const { search, tag, setSearch, setTag, filteredModels, allTags } =
useModelsFilter(models);
// 6. Per-model verify state.
const { verify, handleVerify } = useModelVerify({
providerName,
resolveCreds,
instanceModels,
});
// 7. Add / remove / batch toggle / custom add.
const {
allFilteredAdded,
handleAddModel,
handleRemoveModel,
handleAddCustom,
handleBatchToggleModels,
batchLoading,
} = useModelMutations({
providerName,
instanceName,
isDraftInstance,
hideActions,
resolveCreds,
instance,
instanceItems,
filteredModels,
addedSet,
setCatalog,
});
// 8. Edit dialog state + submit.
const {
editingModel,
setEditingModel,
editModelDialogFields,
editDefaultValues,
handleEditSubmit,
editLoading,
customModelDialogFields,
} = useModelEdit({
providerName,
instanceName,
setCatalog,
});
// Add-custom-model dialog open state (local UI state).
const [dialogOpen, setDialogOpen] = useState(false);
// Mirror dialog open state up to the host so it can pause its
// blur-driven auto-save while the dialog is open (focus shifts into a
// React Portal outside the host's onBlurCapture container).
useEffect(() => {
const open = dialogOpen || editingModel !== null;
onBlurSuppressChange?.(open);
return () => {
if (open) onBlurSuppressChange?.(false);
};
}, [dialogOpen, editingModel, onBlurSuppressChange]);
// hideIfEmpty: render nothing once the first fetch completes with no models.
if (hideIfEmpty && hasFetched && models.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-3" data-testid="models-section">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-text-primary">
{t('setting.models')}
</div>
{!hideActions && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleListModels}
disabled={manualListLoading}
data-testid="models-list-button"
>
{manualListLoading && <Loader2 className="size-3 animate-spin" />}
{t('setting.listModels')}
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => setDialogOpen(true)}
data-testid="models-add-custom"
aria-label={t('setting.addCustomModel')}
>
<Plus className="size-4" />
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-2 border rounded-sm p-5 border-border-button">
<div className="flex flex-col gap-2 ">
<div className="flex items-center gap-2">
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('setting.search')}
rootClassName="flex-1"
/>
{!hideActions && (
<Button
variant="outline"
size="icon-sm"
onClick={handleBatchToggleModels}
disabled={batchLoading || filteredModels.length === 0}
data-testid="models-batch-toggle"
aria-label={
allFilteredAdded
? tSetting('batchRemoveModels')
: tSetting('batchAddModels')
}
title={
allFilteredAdded
? tSetting('batchRemoveModels')
: tSetting('batchAddModels')
}
>
{batchLoading ? (
<Loader2 className="size-4 animate-spin" />
) : allFilteredAdded ? (
<ListMinus className="size-4" />
) : (
<ListPlus className="size-4" />
)}
</Button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
<TagFilterButton
label={tSetting('allModels')}
count={models.length}
active={tag === null}
onClick={() => setTag(null)}
/>
{allTags.map((tKey) => (
<TagFilterButton
key={tKey}
label={mapModelKey[tKey as keyof typeof mapModelKey] || tKey}
count={
models.filter((m) => m.model_types?.includes(tKey)).length
}
active={tag === tKey}
onClick={() => setTag(tag === tKey ? null : tKey)}
/>
))}
</div>
</div>
<div className="bg-bg-card rounded-lg max-h-80 overflow-auto scrollbar-auto border border-border-button">
{filteredModels.length === 0 ? (
<div className="flex items-center justify-center text-text-secondary text-sm py-6 gap-2">
<Search className="size-4" />
{t('setting.listModelsEmpty')}
</div>
) : (
<ul>
{filteredModels.map((model) => (
<ModelRow
key={model.name}
model={model}
isAdded={addedSet.has(model.name)}
verifyStatus={verify[model.name] ?? 'idle'}
hideActions={hideActions}
onVerify={() => handleVerify(model)}
onAdd={() => handleAddModel(model)}
onRemove={() => handleRemoveModel(model)}
onEdit={() => setEditingModel(model)}
editLabel={tSetting('editModel')}
/>
))}
</ul>
)}
</div>
</div>
<AddCustomModelDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={tSetting('addCustomModelTitle')}
fields={customModelDialogFields}
existingNames={models.map((m) => m.name)}
onSubmit={async (item) => {
await handleAddCustom(item);
setDialogOpen(false);
}}
submitText={tc('confirm')}
cancelText={tc('cancel')}
/>
<AddCustomModelDialog
open={editingModel !== null}
onOpenChange={(open) => {
if (!open) setEditingModel(null);
}}
title={tSetting('editModel')}
fields={editModelDialogFields}
existingNames={models
.filter((m) => m.name !== editingModel?.name)
.map((m) => m.name)}
defaultValues={editDefaultValues}
loading={editLoading}
onSubmit={async (item) => {
await handleEditSubmit(item);
}}
submitText={tc('confirm')}
cancelText={tc('cancel')}
/>
</div>
);
}
export default ModelsSection;

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IProviderInstance } from '@/interfaces/database/llm';
import { IModelInfo, IProviderModelItem } from '@/interfaces/request/llm';
/** State of the per-row "verify model" button. */
export type VerifyStatus = 'idle' | 'loading' | 'success' | 'error';
export interface ModelsSectionProps {
providerName: string;
instanceName: string;
/** Optional — used to populate api_key/base_url for the verify and list calls. */
instance?: IProviderInstance;
/**
* If true, hides the List Models / + buttons (used in the "new instance"
* draft state where there is no backend instance to query yet).
*/
hideActions?: boolean;
/**
* If true, the section renders nothing once the first catalog fetch
* completes and no models are available. Used by draft instances to
* avoid showing an empty list.
*/
hideIfEmpty?: boolean;
/**
* Optional getter returning the host card's current form values
* (`api_key`, `base_url` / `api_base`, region-specific fields, ...).
* When provided, ModelsSection prefers these over the persisted
* `instance` props when calling listProviderModels / verifyProviderConnection,
* so the user can verify with values they are still editing (before
* blur-save persists them to the backend).
*/
getFormValues?: () => Record<string, any>;
/**
* Notifies the host that ModelsSection has opened (or closed) a modal
* dialog whose contents live in a React Portal outside the host's
* `onBlurCapture` container. The host should temporarily disable its
* blur-driven auto-save while suppressed === true; otherwise the
* focus shift into the dialog body fires a spurious "save". Restored
* to false when the dialog closes.
*/
onBlurSuppressChange?: (suppressed: boolean) => void;
/**
* Notifies the host whenever the per-instance model list changes.
* The list is delivered already converted to the `IModelInfo[]`
* shape expected by the update / add-provider-instance endpoints,
* so the host can forward it verbatim in its auto-save payload.
* Fires once on mount with `[]` (initial empty state) and again
* whenever the per-instance fetch resolves or an add/remove mutation
* settles and the cache invalidates.
*/
onInstanceModelsChange?: (modelInfo: IModelInfo[]) => void;
/**
* Optional callback fired when the per-instance model list changes
* in a way that does NOT need the host to re-sync via its own
* auto-save — i.e. an existing model was patched (max_tokens /
* model_type / features changed via the edit dialog) but the model
* set stayed the same.
*
* The PATCH endpoint already persisted the change server-side, so
* the host uses this signal to absorb the resulting model_info diff
* into its last-saved baseline. The next blur-driven auto-save will
* then short-circuit as a no-op (signature matches), avoiding a
* redundant PUT that re-sends the already-saved model_info.
*
* Pair with `onInstanceModelsChange`: that callback fires for every
* change (add/remove/patch) so the host can keep its `modelInfoRef`
* current, while `onInstanceModelsEdited` fires ONLY for patches so
* the host can suppress its next auto-save for an already-persisted
* change.
*/
onInstanceModelsEdited?: () => void;
}
export interface ModelTypeBadgesProps {
types: string[];
showEdit?: boolean;
onEdit?: () => void;
editLabel?: string;
editTestSuffix?: string;
}
export interface ModelVerifyButtonProps {
status: VerifyStatus;
onVerify: () => void;
modelName: string;
}
export interface ModelRowProps {
model: IProviderModelItem;
isAdded: boolean;
verifyStatus: VerifyStatus;
hideActions: boolean;
onVerify: () => void;
onAdd: () => void;
onRemove: () => void;
onEdit: () => void;
editLabel: string;
}
export interface TagFilterButtonProps {
label: string;
count: number;
active: boolean;
onClick: () => void;
}

View File

@@ -0,0 +1,237 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynamicFormRef } from '@/components/dynamic-form';
import { IModelInfo } from '@/interfaces/request/llm';
import { useEffect, useRef, useState } from 'react';
import { useFetchInstanceNameSet, useHideWhenInstanceExists } from '../hooks';
import { BedrockInstanceCard } from './bedrock-instance-card';
import { DraftModeCard } from './components/draft-mode-card';
import { InstanceNameSection } from './components/instance-name-section';
import { SavedModeCard } from './components/saved-mode-card';
import { SoMarkInstanceCard } from './somark-instance-card';
import {
useDeleteInstance,
useDraftAutoSave,
useFormFields,
useFormResetOnDetailsLoad,
useLazyInstanceDetails,
useProviderBaseUrlOptions,
useProviderInitialValues,
useSaveInstanceName,
useSavedAutoSave,
useVerifyProvider,
} from './hooks';
import { ProviderInstanceCardProps } from './interface';
/**
* One inline provider-instance card. The provider name + doc-link arrow
* live in the parent page's sticky `ProviderHeaderBar`; this card only
* shows the **instance**-level details (name, fields, verify, models).
*
* Two visual modes (driven by the `nameSaved` flag, not the `isDraft`
* prop — `isDraft` only controls whether the form is editable):
* 1. **Unsaved name** (`!nameSaved`): the instance name lives in a
* dedicated form-field section at the top of the body, wrapped in
* a red border with a label, input, inline Save button, and
* always-visible helper text. The form fields are always visible
* (no collapsible). The auto-save on blur is *active* but will
* refuse to call `onSaved` until the name is entered and saved.
* 2. **Saved name** (`nameSaved`): the form-field section collapses
* into a single collapsible row showing the name as plain text
* with a hover-only key/lock icon. The form fields live inside
* the collapsible content and can be collapsed/expanded.
*/
export function ProviderInstanceCard(props: ProviderInstanceCardProps) {
// AWS Bedrock has provider-specific fields (auth_mode, region, AK/SK,
// role ARN, model name, max_tokens) that don't fit the generic
// DynamicForm path. Render its own inline card instead.
//
// SoMark is similar: its many provider-specific fields (image /
// formula / table / cs formats + 7 boolean feature toggles) don't
// fit the generic DynamicForm path. Render its own inline card too.
//
// Dispatch BEFORE any hooks so each branch component has a stable
// hook-call order (Rules of Hooks).
if (props.providerName === 'Bedrock') {
return <BedrockInstanceCard {...props} />;
}
if (props.providerName === 'SoMark') {
return <SoMarkInstanceCard {...props} />;
}
return <GenericProviderInstanceCard {...props} />;
}
function GenericProviderInstanceCard({
providerName,
instance,
isDraft = false,
onSaved,
onNameSaved,
onDelete,
defaultOpen = false,
}: ProviderInstanceCardProps) {
// Drafts always start open (the user just added them and needs to
// fill the fields); saved cards default to collapsed unless the
// parent flagged this card as the one to expand initially (typically
// the first instance in the list).
const [open, setOpen] = useState(isDraft || defaultOpen);
// Drafts start with an empty name — the user types it themselves.
const [draftName, setDraftName] = useState('');
// Tracks whether the instance name has been saved for the current
// draft/saved state. Saved instances start with `true` (the name is
// persisted in the backend); draft instances start with `false` and
// flip to `true` after the dedicated "Save" button on the name
// section is pressed.
const [nameSaved, setNameSaved] = useState(!isDraft);
const formRef = useRef<DynamicFormRef>(null);
// Mirror of the per-instance model list — written by ModelsSection
// via `setModelInfo`, read by the auto-save payload assembler.
const modelInfoRef = useRef<IModelInfo[]>([]);
useEffect(() => {
if (isDraft) {
setDraftName('');
setNameSaved(false);
} else {
setNameSaved(true);
}
}, [providerName, isDraft]);
// ── Data fetching ────────────────────────────────────────────────
const { instanceNameSet } = useFetchInstanceNameSet(
isDraft ? providerName : '',
);
const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet);
const { baseUrlOptions } = useProviderBaseUrlOptions(providerName);
const { instanceDetails } = useLazyInstanceDetails(
providerName,
instance.instance_name,
isDraft,
open,
);
// ── Form initial values + fields ────────────────────────────────
const initialValues = useProviderInitialValues(
instance,
instanceDetails,
isDraft,
baseUrlOptions,
);
const { formFields, formDefaultValues } = useFormFields(
providerName,
isDraft,
initialValues,
baseUrlOptions,
hideWhenInstanceExists,
);
useFormResetOnDetailsLoad(
formRef,
formDefaultValues,
instanceDetails,
isDraft,
);
// ── Action handlers ─────────────────────────────────────────────
const handleVerify = useVerifyProvider(providerName, formRef);
const handleSaveName = useSaveInstanceName(
providerName,
draftName,
onNameSaved,
);
const handleDelete = useDeleteInstance(
providerName,
instance.instance_name,
isDraft,
onDelete,
);
// ── Auto-save wiring ─────────────────────────────────────────────
// Draft: 200ms-debounced watch effect, gated on the instance name
// being entered and saved.
useDraftAutoSave(
formRef,
isDraft,
nameSaved,
draftName,
isDraft ? onSaved : undefined,
modelInfoRef,
);
// Saved: blur-driven + dropdown value-change auto-save via PUT.
const { handleFieldsBlur, blurSuppressRef, markModelsEdited } =
useSavedAutoSave({
formRef,
formFields,
providerName,
instanceName: instance.instance_name,
instanceId: instance.id,
isDraft,
instanceDetails,
initialValues,
modelInfoRef,
});
return (
<div
className="border-b border-border-button mb-5 pb-5"
data-testid={`instance-card-${instance.instance_name || 'draft'}`}
>
{nameSaved ? (
<SavedModeCard
formFields={formFields}
formDefaultValues={formDefaultValues}
formRef={formRef}
handleFieldsBlur={handleFieldsBlur}
handleVerify={handleVerify}
handleDelete={handleDelete}
handleInstanceModelsEdited={markModelsEdited}
providerName={providerName}
instanceName={instance.instance_name}
instance={instance}
instanceDetailsLoaded={Boolean(instanceDetails)}
modelInfoRef={modelInfoRef}
blurSuppressRef={blurSuppressRef}
draftName={draftName}
open={open}
setOpen={setOpen}
/>
) : (
<DraftModeCard
formFields={formFields}
formDefaultValues={formDefaultValues}
formRef={formRef}
handleVerify={handleVerify}
handleDelete={handleDelete}
handleSaveName={handleSaveName}
handleInstanceModelsEdited={markModelsEdited}
providerName={providerName}
instanceName={instance.instance_name}
instance={instance}
modelInfoRef={modelInfoRef}
draftName={draftName}
setDraftName={setDraftName}
/>
)}
</div>
);
}
// Re-export the name section for callers that need to embed it
// (e.g. parent pages with custom layouts).
export { InstanceNameSection };
export default ProviderInstanceCard;

View File

@@ -0,0 +1,845 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
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 { useTranslate } from '@/hooks/common-hooks';
import {
useAddProviderInstance,
useDeleteProviderInstance,
useFetchProviderInstance,
useVerifyProviderConnection,
} from '@/hooks/use-llm-request';
import { IProviderInstance } from '@/interfaces/database/llm';
import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm';
import { zodResolver } from '@hookform/resolvers/zod';
import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { VerifyResult } from '../hooks';
import VerifyButton from './verify-button';
const IMAGE_FORMATS = ['url', 'base64', 'none'] as const;
const FORMULA_FORMATS = ['latex', 'mathml', 'ascii'] as const;
const TABLE_FORMATS = ['html', 'markdown', 'image'] as const;
const CS_FORMATS = ['image'] as const;
const FORMAT_LABELS = {
url: 'URL',
base64: 'Base64',
none: 'None',
latex: 'LaTeX',
mathml: 'MathML',
ascii: 'ASCII',
html: 'HTML',
markdown: 'Markdown',
image: 'Image',
} as const;
const buildFormatOptions = <T extends keyof typeof FORMAT_LABELS>(
formats: readonly T[],
) => formats.map((value) => ({ label: FORMAT_LABELS[value], value }));
// Field names whose value commits via click (Selects, Switches) rather
// than blur. Their popovers render in Radix portals outside the card's
// blur container, so blur-driven saves don't catch them — a form.watch
// watcher is used instead to schedule a save when they change.
const SOMARK_WATCHED_FIELDS = new Set([
'somark_image_format',
'somark_formula_format',
'somark_table_format',
'somark_cs_format',
'somark_enable_text_cross_page',
'somark_enable_table_cross_page',
'somark_enable_title_level_recognition',
'somark_enable_inline_image',
'somark_enable_table_image',
'somark_enable_image_understanding',
'somark_keep_header_footer',
]);
type SoMarkFormValues = {
llm_name: string;
somark_base_url: string;
somark_api_key?: string;
somark_image_format: (typeof IMAGE_FORMATS)[number];
somark_formula_format: (typeof FORMULA_FORMATS)[number];
somark_table_format: (typeof TABLE_FORMATS)[number];
somark_cs_format: (typeof CS_FORMATS)[number];
somark_enable_text_cross_page: boolean;
somark_enable_table_cross_page: boolean;
somark_enable_title_level_recognition: boolean;
somark_enable_inline_image: boolean;
somark_enable_table_image: boolean;
somark_enable_image_understanding: boolean;
somark_keep_header_footer: boolean;
};
interface SoMarkInstanceCardProps {
providerName: string;
instance: IProviderInstance;
isDraft?: boolean;
onSaved?: (values: Record<string, any>) => void | Promise<void>;
onNameSaved?: () => void;
onDelete?: () => void;
/**
* When true, this card starts expanded and fetches its instance
* details on mount. Default `false` so non-first cards stay
* collapsed until the user opens them.
*/
defaultOpen?: boolean;
}
/**
* Inline instance card for SoMark. Mirrors the two-stage UX of
* `BedrockInstanceCard` (save name first, then edit fields) but renders
* SoMark-specific fields (model name, base URL, API key, 4 element-format
* selects, 7 feature toggles) directly. The model type is fixed to
* `['ocr']` (SoMark is an OCR provider) and not exposed in the form.
*
* Payload shape (matches the legacy `useSubmitSoMark` hook so the
* backend contract is unchanged):
* {
* instance_name, llm_factory: 'SoMark',
* api_key: somark_api_key || '',
* base_url: somark_base_url,
* max_tokens: 0,
* model_info: [{
* llm_name, model_type: ['ocr'], max_tokens: 0,
* extra: { somark_image_format, somark_formula_format, ... }
* }]
* }
*/
export function SoMarkInstanceCard({
providerName,
instance,
isDraft = false,
onSaved,
onNameSaved,
onDelete,
defaultOpen = false,
}: SoMarkInstanceCardProps) {
const { t } = useTranslation();
const { t: tSetting } = useTranslate('setting');
const [open, setOpen] = useState(isDraft || defaultOpen);
const [draftName, setDraftName] = useState('');
const [nameSaved, setNameSaved] = useState(!isDraft);
const savingRef = useRef(false);
useEffect(() => {
if (isDraft) {
setDraftName('');
setNameSaved(false);
} else {
setNameSaved(true);
}
}, [providerName, isDraft]);
const FormSchema = useMemo(
() =>
z.object({
llm_name: z.string().min(1, {
message: tSetting('somark.modelNameMessage'),
}),
somark_base_url: z.string().min(1, {
message: tSetting('somark.baseUrlMessage'),
}),
somark_api_key: z.string().optional(),
somark_image_format: z.enum(IMAGE_FORMATS),
somark_formula_format: z.enum(FORMULA_FORMATS),
somark_table_format: z.enum(TABLE_FORMATS),
somark_cs_format: z.enum(CS_FORMATS),
somark_enable_text_cross_page: z.boolean(),
somark_enable_table_cross_page: z.boolean(),
somark_enable_title_level_recognition: z.boolean(),
somark_enable_inline_image: z.boolean(),
somark_enable_table_image: z.boolean(),
somark_enable_image_understanding: z.boolean(),
somark_keep_header_footer: z.boolean(),
}),
[tSetting],
);
const { data: instanceDetails, refetch: refetchInstanceDetails } =
useFetchProviderInstance(
isDraft ? '' : providerName,
isDraft ? '' : instance.instance_name,
);
// Lazily fetch full instance details only when the card is open.
// Collapsed cards never hit /providers/<name>/instances/<instance_name>;
// expanding one triggers a fresh refetch.
useEffect(() => {
if (!isDraft && open && providerName && instance.instance_name) {
refetchInstanceDetails();
}
}, [
isDraft,
open,
providerName,
instance.instance_name,
refetchInstanceDetails,
]);
// Build initial values from the persisted instance + lazy-loaded details.
// SoMark stores its provider-specific fields inside
// `model_info[0].extra`; `api_key` and `base_url` live at the
// instance top level. Map them back to the form's flat shape.
const initialValues = useMemo<SoMarkFormValues>(() => {
const merged: any = { ...instance, ...(instanceDetails ?? {}) };
const rawApiKey = merged.api_key;
const apiKey =
typeof rawApiKey === 'string'
? rawApiKey
: rawApiKey && typeof rawApiKey === 'object'
? (rawApiKey.api_key ?? '')
: '';
const modelInfo = Array.isArray(merged.model_info)
? merged.model_info[0]
: null;
const extra = (modelInfo?.extra ?? {}) as Record<string, any>;
return {
llm_name: modelInfo?.llm_name ?? modelInfo?.model_name ?? '',
somark_base_url: (merged.base_url as string) ?? '',
somark_api_key: apiKey,
somark_image_format: (extra.somark_image_format as (typeof IMAGE_FORMATS)[number]) ?? 'url',
somark_formula_format: (extra.somark_formula_format as (typeof FORMULA_FORMATS)[number]) ?? 'latex',
somark_table_format: (extra.somark_table_format as (typeof TABLE_FORMATS)[number]) ?? 'html',
somark_cs_format: (extra.somark_cs_format as (typeof CS_FORMATS)[number]) ?? 'image',
somark_enable_text_cross_page: extra.somark_enable_text_cross_page ?? false,
somark_enable_table_cross_page: extra.somark_enable_table_cross_page ?? false,
somark_enable_title_level_recognition:
extra.somark_enable_title_level_recognition ?? false,
somark_enable_inline_image: extra.somark_enable_inline_image ?? false,
somark_enable_table_image: extra.somark_enable_table_image ?? true,
somark_enable_image_understanding:
extra.somark_enable_image_understanding ?? true,
somark_keep_header_footer: extra.somark_keep_header_footer ?? false,
};
}, [instance, instanceDetails]);
const form = useForm<SoMarkFormValues>({
resolver: zodResolver(FormSchema),
defaultValues: initialValues,
});
useEffect(() => {
// Reset form when initial values change (e.g. instance details load).
form.reset(initialValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
const imageFormatOptions = useMemo(
() => buildFormatOptions(IMAGE_FORMATS),
[],
);
const formulaFormatOptions = useMemo(
() => buildFormatOptions(FORMULA_FORMATS),
[],
);
const tableFormatOptions = useMemo(
() => buildFormatOptions(TABLE_FORMATS),
[],
);
const csFormatOptions = useMemo(() => buildFormatOptions(CS_FORMATS), []);
// Build a SoMark-shaped payload for both submit and verify flows.
// Mirrors the legacy `useSubmitSoMark` hook so the backend contract
// is unchanged: api_key/base_url at the instance level, all somark_*
// feature/format fields inside model_info[0].extra.
const buildPayload = useCallback(
(values: SoMarkFormValues, instanceName: string) => {
const extra = {
somark_image_format: values.somark_image_format,
somark_formula_format: values.somark_formula_format,
somark_table_format: values.somark_table_format,
somark_cs_format: values.somark_cs_format,
somark_enable_text_cross_page: values.somark_enable_text_cross_page,
somark_enable_table_cross_page: values.somark_enable_table_cross_page,
somark_enable_title_level_recognition:
values.somark_enable_title_level_recognition,
somark_enable_inline_image: values.somark_enable_inline_image,
somark_enable_table_image: values.somark_enable_table_image,
somark_enable_image_understanding:
values.somark_enable_image_understanding,
somark_keep_header_footer: values.somark_keep_header_footer,
};
return {
instance_name: instanceName,
llm_factory: providerName,
api_key: values.somark_api_key ?? '',
base_url: values.somark_base_url,
max_tokens: 0,
model_info: [
{
model_name: values.llm_name,
model_type: ['ocr'],
max_tokens: 0,
extra,
},
],
} as unknown as IAddProviderInstanceRequestBody;
},
[providerName],
);
const { verifyProviderConnection } = useVerifyProviderConnection();
const handleVerify = useCallback(
async (params: any) => {
const isValid = await form.trigger();
if (!isValid) {
return {
isValid: false,
logs: tSetting('somark.baseUrlMessage'),
} as VerifyResult;
}
const values = form.getValues();
const payload = buildPayload(
values,
draftName.trim() || instance.instance_name,
);
const ret = await verifyProviderConnection({
provider_name: providerName,
api_key: (payload as any).api_key,
base_url: (payload as any).base_url,
model_info: (payload as any).model_info,
...params,
});
return {
isValid: ret.code === 0,
logs: ret.message,
} as VerifyResult;
},
[
form,
providerName,
buildPayload,
draftName,
instance.instance_name,
verifyProviderConnection,
tSetting,
],
);
const { addProviderInstance } = useAddProviderInstance();
const handleSaveName = useCallback(async () => {
const trimmed = draftName.trim();
if (!trimmed) return;
const ret = await addProviderInstance({
llm_factory: providerName,
instance_name: trimmed,
} as any);
if (ret?.code === 0) {
onNameSaved?.();
}
}, [draftName, addProviderInstance, providerName, onNameSaved]);
// Auto-save in draft mode after the name is locked. Debounced on form
// value changes; refuses to fire until validation passes.
useEffect(() => {
if (!isDraft) return;
if (!nameSaved) return;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const sub = form.watch(() => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (cancelled || savingRef.current) return;
const isValid = await form.trigger();
if (cancelled || savingRef.current) return;
if (!isValid) return;
const trimmed = draftName.trim();
if (!trimmed) return;
savingRef.current = true;
try {
const values = form.getValues();
const payload = buildPayload(values, trimmed);
await onSaved?.(payload as unknown as Record<string, any>);
} finally {
savingRef.current = false;
}
}, 200);
});
return () => {
cancelled = true;
if (saveTimeout) clearTimeout(saveTimeout);
try {
sub?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, nameSaved, form, draftName, buildPayload, onSaved]);
// Saved-mode auto-save. Both blur-driven (text inputs) and
// change-driven (Selects / Switches) edits are coalesced through
// a shared debounced `scheduleSave`. Selects render in Radix portals
// outside the card's blur container, and Switches are click-based
// (no blur), so a `form.watch` watcher is needed to catch them.
const blurSavingRef = useRef(false);
const lastSavedSigRef = useRef('');
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const AUTO_SAVE_DEBOUNCE_MS = 500;
const performSave = useCallback(async () => {
if (isDraft) return;
if (blurSavingRef.current) return;
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
const payload = buildPayload(values, instance.instance_name);
const finalPayload = {
...payload,
id: instanceDetails?.id || instance.id,
};
const sig = JSON.stringify(finalPayload);
if (sig === lastSavedSigRef.current) return;
blurSavingRef.current = true;
try {
const ret = await addProviderInstance(finalPayload as any);
if (ret?.code === 0) {
lastSavedSigRef.current = sig;
}
} finally {
blurSavingRef.current = false;
}
}, [
isDraft,
form,
buildPayload,
instance.instance_name,
instance.id,
instanceDetails?.id,
addProviderInstance,
]);
const scheduleSave = useCallback(() => {
if (isDraft) return;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
void performSave();
}, AUTO_SAVE_DEBOUNCE_MS);
}, [isDraft, performSave]);
const handleFieldsBlur = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (isDraft) return;
if (
e.currentTarget.contains(e.relatedTarget as Node | null) &&
e.relatedTarget !== null
) {
return;
}
scheduleSave();
},
[isDraft, scheduleSave],
);
// Dropdown / Switch change-driven save (saved mode only). Text
// inputs are handled by blur; Selects and Switches commit via click
// and their popovers live in portals, so we watch the form directly.
// Only react to user-driven changes (type === 'change'); ignore
// programmatic resets (form.reset when instanceDetails loads).
useEffect(() => {
if (isDraft) return;
if (!instanceDetails) return;
let cancelled = false;
const subscription = form.watch(
(_values: any, meta: { name?: string; type?: string }) => {
if (cancelled) return;
if (meta?.type !== 'change') return;
if (!meta?.name || !SOMARK_WATCHED_FIELDS.has(meta.name)) return;
scheduleSave();
},
);
return () => {
cancelled = true;
try {
subscription?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, instanceDetails, form, scheduleSave]);
// Clear pending save on unmount.
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, []);
const { deleteProviderInstance } = useDeleteProviderInstance();
const handleDelete = useCallback(async () => {
if (isDraft) {
onDelete?.();
} else {
await deleteProviderInstance({
provider_name: providerName,
instances: [instance.instance_name],
});
}
}, [
isDraft,
providerName,
instance.instance_name,
deleteProviderInstance,
onDelete,
]);
// ──────────────── Field group rendered in both modes ────────────────
const renderFields = () => (
<Form {...form}>
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
<RAGFlowFormItem
name="llm_name"
label={tSetting('modelName')}
required
>
<Input placeholder="somark-from-env-1" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_base_url"
label={tSetting('somark.baseUrl')}
required
>
<Input placeholder={tSetting('somark.baseUrlPlaceholder')} />
</RAGFlowFormItem>
<RAGFlowFormItem name="somark_api_key" label={tSetting('somark.apiKey')}>
<Input
type="password"
placeholder={tSetting('somark.apiKeyPlaceholder')}
/>
</RAGFlowFormItem>
<div className="text-sm font-semibold text-muted-foreground border-b pb-1">
{tSetting('somark.sectionElementFormats')}
</div>
<RAGFlowFormItem
name="somark_image_format"
label={tSetting('somark.imageFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={imageFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_formula_format"
label={tSetting('somark.formulaFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={formulaFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_table_format"
label={tSetting('somark.tableFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={tableFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_cs_format"
label={tSetting('somark.csFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={csFormatOptions}
/>
)}
</RAGFlowFormItem>
<div className="text-sm font-semibold text-muted-foreground border-b pb-1">
{tSetting('somark.sectionFeatureConfig')}
</div>
<RAGFlowFormItem
name="somark_enable_text_cross_page"
label={tSetting('somark.enableTextCrossPage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_table_cross_page"
label={tSetting('somark.enableTableCrossPage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_title_level_recognition"
label={tSetting('somark.enableTitleLevelRecognition')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_inline_image"
label={tSetting('somark.enableInlineImage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_table_image"
label={tSetting('somark.enableTableImage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_image_understanding"
label={tSetting('somark.enableImageUnderstanding')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_keep_header_footer"
label={tSetting('somark.keepHeaderFooter')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
</form>
{/* VerifyButton lives inside <Form> (FormProvider) so its
internal useFormContext() resolves the form instance.
Rendered outside <form> so it never triggers submission. */}
<div className="pt-3">
<VerifyButton
onVerify={handleVerify}
isAbsolute={false}
validLabel={tSetting('somark.verifyPassed')}
invalidLabel={tSetting('somark.verifyFailed')}
/>
</div>
</Form>
);
return (
<div
className="border-b border-border-button mb-5 pb-5"
data-testid={`instance-card-${instance.instance_name || 'draft'}`}
>
{nameSaved ? (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-1 w-full mb-5">
<div
className="group flex items-center flex-1 gap-2 px-2 mx-2 py-1 cursor-pointer bg-bg-input rounded-md"
data-testid="instance-name-row"
>
<Button
variant="ghost"
size="icon-sm"
aria-label={
open ? t('setting.hideModels') : t('setting.showMoreModels')
}
data-testid="instance-collapse"
>
{open ? (
<ListChevronsDownUp className="size-4" />
) : (
<ListChevronsUpDown className="size-4" />
)}
</Button>
<span
className="text-sm font-medium"
data-testid="instance-name-static"
>
{draftName || instance.instance_name}
</span>
</div>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
aria-label={tSetting('deleteInstance')}
data-testid="instance-delete"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
</CollapsibleTrigger>
<CollapsibleContent
forceMount
className="data-[state=closed]:hidden overflow-hidden"
>
<div
className="px-2 pb-4 flex flex-col gap-4"
onBlurCapture={handleFieldsBlur}
>
{renderFields()}
</div>
</CollapsibleContent>
</Collapsible>
) : (
<div className="px-2 py-3 flex flex-col gap-4">
<div
className="flex flex-col gap-1.5"
data-testid="instance-name-section"
>
<label
htmlFor="instance-name-input"
className="text-sm font-medium text-text-primary"
>
<span className="text-destructive mr-0.5">*</span>
{tSetting('instanceName')}
</label>
<div className="flex items-center">
<Input
id="instance-name-input"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={tSetting('instanceNamePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveName();
}
}}
className="flex-1 rounded-r-none"
data-testid="instance-name-input"
/>
<Button
onClick={handleSaveName}
disabled={!draftName.trim()}
data-testid="instance-name-save"
variant="outline"
className="rounded-l-none bg-bg-input shrink-0"
>
{tSetting('save')}
</Button>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
className="ml-2 shrink-0"
aria-label={tSetting('deleteInstance')}
data-testid="draft-delete"
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
<p
className="text-xs text-text-secondary"
data-testid="instance-name-helper"
>
{tSetting('instanceNameSaveTip')}
</p>
</div>
<fieldset
disabled={!nameSaved}
className="contents disabled:[&_*]:pointer-events-none disabled:opacity-60"
data-testid="instance-locked-fields"
>
{renderFields()}
</fieldset>
</div>
)}
</div>
);
}
export default SoMarkInstanceCard;

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useTranslate } from '@/hooks/common-hooks';
import { useMemo } from 'react';
import type { AddCustomModelDialogFields } from './add-custom-model-dialog';

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button } from '@/components/ui/button';
import { useTranslate } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
@@ -6,7 +22,7 @@ import { ApiKeyPostBody } from '@/pages/user-setting/interface';
import { RefreshCcw } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { VerifyResult } from '../../hooks';
import { VerifyResult } from '../hooks';
interface IVerifyButton {
onVerify: (params: any) => Promise<VerifyResult>;
@@ -18,6 +34,15 @@ interface IVerifyButton {
/** Override the failure label shown next to the button. Defaults to t('keyInvalid'). */
invalidLabel?: string;
verifyCallback?: (result: VerifyResult | null) => void;
/**
* Optional ref to a form-like object exposing `trigger()` and
* `getValues()`. Use this when the button is rendered as a *sibling*
* of the form (i.e. outside any FormProvider). When omitted, falls
* back to react-hook-form's `useFormContext()`.
*/
formRef?: {
current: { trigger: () => Promise<boolean>; getValues: () => any } | null;
};
}
const VerifyButton: React.FC<IVerifyButton> = ({
@@ -28,6 +53,7 @@ const VerifyButton: React.FC<IVerifyButton> = ({
validLabel,
invalidLabel,
verifyCallback,
formRef,
}) => {
const { t, i18n } = useTranslate('setting');
const isArabic = (i18n.resolvedLanguage || i18n.language || '')
@@ -35,7 +61,8 @@ const VerifyButton: React.FC<IVerifyButton> = ({
.startsWith('ar');
const [isVerifying, setIsVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
const form = useFormContext();
const contextForm = useFormContext();
const form = formRef?.current ?? contextForm;
const onHandleVerify = useCallback(async () => {
const formValid = await form?.trigger();

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LlmIcon } from '@/components/svg-icon';
import { APIMapUrl } from '@/constants/llm';
import { useTranslate } from '@/hooks/common-hooks';
import { ArrowUpRight } from 'lucide-react';
interface ProviderHeaderBarProps {
providerName: string;
}
/**
* Sticky top bar for the right pane that displays the selected provider's
* icon, name and a doc-link arrow. Stays visible while the user scrolls
* the instance list below.
*/
export function ProviderHeaderBar({ providerName }: ProviderHeaderBarProps) {
const { t: tSetting } = useTranslate('setting');
const docLink = APIMapUrl[providerName as keyof typeof APIMapUrl];
return (
<div
className="sticky top-0 z-10 bg-bg-base flex items-center gap-2 px-4 py-3 border-b border-border-button"
data-testid={`provider-header-${providerName}`}
>
<LlmIcon
name={providerName}
width={24}
height={24}
imgClass="size-6 text-text-primary"
/>
<span className="font-medium text-text-primary">{providerName}</span>
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
className="text-text-secondary hover:text-text-primary"
aria-label={tSetting('docLink')}
>
<ArrowUpRight className="size-4" />
</a>
)}
</div>
);
}
export default ProviderHeaderBar;

View File

@@ -0,0 +1,153 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LlmIcon } from '@/components/svg-icon';
import { SearchInput } from '@/components/ui/input';
import {
useFetchAddedProviders,
useFetchAvailableProviders,
} from '@/hooks/use-llm-request';
import { cn } from '@/lib/utils';
import { ChevronRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
/**
* Sidebar selection type for the right pane.
* - 'default': show the system default models view.
* - any string: a provider (LLM factory) name; show its instance list.
*/
export type SidebarSelection = 'default' | string;
interface SidebarProps {
selection: SidebarSelection;
onSelect: (v: SidebarSelection) => void;
}
/**
* Left column of the v2 settings page.
*
* Layout (top -> bottom):
* 1. "Default models" entry — highlighted when `selection === 'default'`,
* with a chevron on the right.
* 2. "Available models" section header.
* 3. Search input — case-insensitive filter over provider names.
* 4. Scrollable list of available providers — clicking a row highlights
* it and triggers `onSelect(providerName)`. Providers that already
* have at least one configured instance show a green dot on the
* right.
*/
export function Sidebar({ selection, onSelect }: SidebarProps) {
const { t } = useTranslation();
const { data: providers } = useFetchAvailableProviders();
const { data: addedProviders } = useFetchAddedProviders();
const [search, setSearch] = useState('');
// Any provider present in `addedProviders` is treated as "added" and
// sorted first in the list. We deliberately do NOT fetch each added
// provider's instance list on mount — instance details are fetched
// lazily by the right pane only when the user clicks a provider.
const addedSet = useMemo(() => {
return new Set(
addedProviders.filter((p) => p.has_instance).map((p) => p.name),
);
}, [addedProviders]);
const filteredProviders = useMemo(() => {
const q = search.trim().toLowerCase();
const list = q
? providers.filter((p) => p.name.toLowerCase().includes(q))
: providers;
// Stable partition: added providers first, then unadded.
const added = list.filter((p) => addedSet.has(p.name));
const others = list.filter((p) => !addedSet.has(p.name));
return [...added, ...others];
}, [providers, search, addedSet]);
return (
<div className="flex flex-col gap-3 py-4 text-text-primary">
<button
type="button"
className={cn(
'flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors border border-border-button',
selection === 'default'
? 'bg-bg-input text-text-primary'
: 'text-text-secondary hover:bg-bg-input hover:text-text-primary',
)}
onClick={() => onSelect('default')}
data-testid="sidebar-default-models"
>
<span className="font-medium">{t('setting.systemModelSettings')}</span>
<ChevronRight className="size-4" />
</button>
<div className="text-base font-medium text-text-primary px-1">
{t('setting.availableModels')}
</div>
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('setting.search')}
data-testid="sidebar-provider-search"
className="w-full border-none"
/>
<div className="flex flex-col gap-1 overflow-auto scrollbar-auto">
{filteredProviders.map((provider) => {
const isActive = selection === provider.name;
const isAdded = addedSet.has(provider.name);
return (
<button
key={provider.name}
type="button"
onClick={() => onSelect(provider.name)}
data-testid={`sidebar-provider-${provider.name}`}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors',
isActive
? 'bg-bg-input text-text-primary'
: 'text-text-secondary hover:bg-bg-input hover:text-text-primary',
)}
>
<LlmIcon
name={provider.name}
width={24}
height={24}
imgClass="size-6 text-text-primary"
/>
<span className="truncate text-sm flex-1">{provider.name}</span>
{isAdded && (
<span
aria-label="configured"
className="size-2 rounded-full bg-state-success shrink-0"
data-testid={`sidebar-provider-dot-${provider.name}`}
/>
)}
</button>
);
})}
{filteredProviders.length === 0 && (
<div className="text-xs text-text-secondary px-3 py-2">
{t('setting.empty')}
</div>
)}
</div>
</div>
);
}
export default Sidebar;

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ModelTreeSelect, ModelTypeMap } from '@/components/model-tree-select';
import {
Tooltip,
@@ -34,8 +50,8 @@ function ModelFieldItem({
const { t } = useTranslate('setting');
return (
<div className="flex gap-3">
<label className="block text-sm font-normal text-text-secondary mb-1 w-1/4">
<div className="flex gap-3 items-center">
<label className="block text-sm font-normal text-text-secondary w-1/4 max-w-[150px]">
{isRequired && <span className="text-state-error">*</span>}
{label}
{tooltip && (
@@ -50,7 +66,7 @@ function ModelFieldItem({
</Tooltip>
)}
</label>
<div className="w-3/4">
<div className="w-3/4 flex-1">
<ModelTreeSelect
modelTypes={ModelTypeMap[id as keyof typeof ModelTypeMap] ?? ['chat']}
value={value}
@@ -134,7 +150,7 @@ function SystemSetting() {
return (
<article className="rounded-lg w-full">
<header className="py-5">
<header className="py-5 px-10">
<h2 className="text-2xl font-medium text-text-primary">
{t('systemModelSettings')}
</h2>
@@ -143,7 +159,7 @@ function SystemSetting() {
</p>
</header>
<div className="px-7 py-6 space-y-6 max-h-[70vh] overflow-y-auto border border-border-button rounded-lg">
<div className="px-10 py-6 space-y-6 max-h-[70vh] overflow-y-auto ">
{llmList.map((item) => (
<ModelFieldItem
key={item.id}

View File

@@ -1,379 +0,0 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { ButtonLoading } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Modal } from '@/components/ui/modal/modal';
import { MultiSelect } from '@/components/ui/multi-select';
import { Segmented } from '@/components/ui/segmented';
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,
VerifyResult,
} from '@/pages/user-setting/setting-model/hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { LLMHeader } from '../../components/llm-header';
import { BedrockRegionList } from '../../constant';
import VerifyButton from '../../modal/verify-button';
type FieldType = Omit<IAddProviderInstanceRequestBody, 'model_type'> & {
auth_mode?: 'access_key_secret' | 'iam_role' | 'assume_role';
bedrock_ak: string;
bedrock_sk: string;
bedrock_region: string;
aws_role_arn?: string;
model_type: string[];
};
const BedrockModal = ({
visible = false,
hideModal,
onOk,
onVerify,
loading,
llmFactory,
}: IModalProps<IAddProviderInstanceRequestBody> & {
llmFactory: string;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
}) => {
const { t } = useTranslate('setting');
const { t: ct } = useCommonTranslation();
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const { instanceNameSet } = useFetchInstanceNameSet(llmFactory);
const instanceExistsRef = useRef(false);
const FormSchema = useMemo(
() =>
z
.object({
instance_name: z
.string()
.min(1, { message: t('instanceNameMessage') }),
model_type: z
.array(z.enum(['chat', 'embedding']))
.min(1, { message: t('modelTypeMessage') }),
llm_name: z
.string()
.min(1, { message: t('bedrockModelNameMessage') }),
bedrock_region: z.string().optional(),
max_tokens: z
.number({
required_error: t('maxTokensMessage'),
invalid_type_error: t('maxTokensInvalidMessage'),
})
.nonnegative({ message: t('maxTokensMinMessage') }),
auth_mode: z
.enum(['access_key_secret', 'iam_role', 'assume_role'])
.default('access_key_secret'),
bedrock_ak: z.string().optional(),
bedrock_sk: z.string().optional(),
aws_role_arn: z.string().optional(),
})
.superRefine((data, ctx) => {
if (instanceExistsRef.current) return;
if (!data.bedrock_region || data.bedrock_region.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('bedrockRegionMessage'),
path: ['bedrock_region'],
});
}
if (data.auth_mode === 'access_key_secret') {
if (!data.bedrock_ak || data.bedrock_ak.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('bedrockAKMessage'),
path: ['bedrock_ak'],
});
}
if (!data.bedrock_sk || data.bedrock_sk.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('bedrockSKMessage'),
path: ['bedrock_sk'],
});
}
}
if (data.auth_mode === 'iam_role') {
if (!data.aws_role_arn || data.aws_role_arn.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('awsRoleArnMessage'),
path: ['aws_role_arn'],
});
}
}
}),
[t],
);
const form = useForm<FieldType>({
resolver: zodResolver(FormSchema),
defaultValues: {
instance_name: '',
model_type: ['chat'],
auth_mode: 'access_key_secret',
max_tokens: 8192,
},
});
const authMode = useWatch({
control: form.control,
name: 'auth_mode',
});
const instanceName = useWatch({
control: form.control,
name: 'instance_name',
});
const instanceExists = useMemo(() => {
const trimmed = (instanceName || '').trim();
return !!trimmed && instanceNameSet.has(trimmed);
}, [instanceName, instanceNameSet]);
instanceExistsRef.current = instanceExists;
const options = useMemo(
() => BedrockRegionList.map((x) => ({ value: x, label: t(x) })),
[t],
);
const handleOk = async (values: FieldType) => {
const cleanedValues: Record<string, any> = { ...values };
const fieldsByMode: Record<string, string[]> = {
access_key_secret: ['bedrock_ak', 'bedrock_sk'],
iam_role: ['aws_role_arn'],
assume_role: [],
};
cleanedValues.auth_mode = authMode;
Object.keys(fieldsByMode).forEach((mode) => {
if (mode !== authMode) {
fieldsByMode[mode].forEach((field) => {
delete cleanedValues[field];
});
}
});
const data = {
...cleanedValues,
llm_factory: llmFactory,
max_tokens: values.max_tokens,
model_type: values.model_type,
};
onOk?.(data as unknown as IAddProviderInstanceRequestBody);
};
const verifyParamsFunc = useCallback(() => {
const values = form.getValues();
const cleanedValues: Record<string, any> = { ...values };
const fieldsByMode: Record<string, string[]> = {
access_key_secret: ['bedrock_ak', 'bedrock_sk'],
iam_role: ['aws_role_arn'],
assume_role: [],
};
cleanedValues.auth_mode = authMode;
Object.keys(fieldsByMode).forEach((mode) => {
if (mode !== authMode) {
fieldsByMode[mode].forEach((field) => {
delete cleanedValues[field];
});
}
});
return {
...cleanedValues,
llm_factory: llmFactory,
max_tokens: values.max_tokens,
model_type: values.model_type,
};
}, [llmFactory, authMode, form]);
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) {
form.reset();
}
}, [visible, form]);
return (
<Modal
title={<LLMHeader name={llmFactory} />}
open={visible}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
footer={
<div className="flex justify-end gap-2">
<button
type="button"
onClick={hideModal}
className="px-2 py-1 border border-border-button rounded-md hover:bg-bg-card"
>
{t('cancel')}
</button>
<ButtonLoading type="submit" form="bedrock-form" loading={loading}>
{ct('ok')}
</ButtonLoading>
</div>
}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleOk)}
className="space-y-6"
id="bedrock-form"
>
<RAGFlowFormItem
name="instance_name"
label={t('instanceName')}
tooltip={t('instanceNameTip')}
required
>
<Input placeholder={t('instanceNameMessage')} />
</RAGFlowFormItem>
<RAGFlowFormItem name="model_type" label={t('modelType')} required>
{(field) => (
<MultiSelect
options={buildModelTypeOptions(['chat', 'embedding'])}
placeholder={t('modelTypeMessage')}
onValueChange={field.onChange}
defaultValue={field.value}
variant="inverted"
maxCount={100}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem name="llm_name" label={t('modelName')} required>
<Input placeholder={t('bedrockModelNameMessage')} />
</RAGFlowFormItem>
{!instanceExists && (
<>
<div className="mb-4">
<RAGFlowFormItem name="auth_mode">
{(field) => (
<Segmented
value={field.value}
onChange={(value) => {
// Clear non-active fields so they won't be validated/submitted by accident.
if (value !== 'access_key_secret') {
form.setValue('bedrock_ak', '');
form.setValue('bedrock_sk', '');
}
if (value !== 'iam_role') {
form.setValue('aws_role_arn', '');
}
field.onChange(value);
}}
options={[
{
label: t('awsAuthModeAccessKeySecret'),
value: 'access_key_secret',
},
{ label: t('awsAuthModeIamRole'), value: 'iam_role' },
{
label: t('awsAuthModeAssumeRole'),
value: 'assume_role',
},
]}
/>
)}
</RAGFlowFormItem>
</div>
{authMode === 'access_key_secret' && (
<>
<RAGFlowFormItem
name="bedrock_ak"
label={t('awsAccessKeyId')}
required
>
<Input placeholder={t('bedrockAKMessage')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="bedrock_sk"
label={t('awsSecretAccessKey')}
required
>
<Input placeholder={t('bedrockSKMessage')} />
</RAGFlowFormItem>
</>
)}
{authMode === 'iam_role' && (
<RAGFlowFormItem
name="aws_role_arn"
label={t('awsRoleArn')}
required
>
<Input placeholder={t('awsRoleArnMessage')} />
</RAGFlowFormItem>
)}
{authMode === 'assume_role' && (
<div className="text-sm text-text-secondary mt-2 mb-4">
{t('awsAssumeRoleTip')}
</div>
)}
<RAGFlowFormItem
name="bedrock_region"
label={t('bedrockRegion')}
required
>
{(field) => (
<SelectWithSearch
value={field.value}
onChange={field.onChange}
options={options}
placeholder={t('bedrockRegionMessage')}
allowClear
/>
)}
</RAGFlowFormItem>
</>
)}
<RAGFlowFormItem name="max_tokens" label={t('maxTokens')} required>
{(field) => (
<Input
type="number"
placeholder={t('maxTokensTip')}
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
)}
</RAGFlowFormItem>
{onVerify && <VerifyButton onVerify={handleVerify} />}
</form>
</Form>
</Modal>
);
};
export default memo(BedrockModal);

View File

@@ -1,162 +0,0 @@
'use client';
import type { ToggleListOption } from '@/components/ui/toggle-list';
import { ToggleList } from '@/components/ui/toggle-list';
import { useTranslate } from '@/hooks/common-hooks';
import { IProviderModelItem } from '@/interfaces/request/llm';
import { Plus } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import type { AddCustomModelDialogFields } from './add-custom-model-dialog';
import { AddCustomModelDialog } from './add-custom-model-dialog';
export interface AddableToggleListProps {
/** Trigger button text */
btnText: React.ReactNode;
/** ToggleList options */
options: ToggleListOption<string | null>[];
/** Show search input */
searchable?: boolean;
/** Search placeholder */
searchPlaceholder?: string;
/** Empty state text */
emptyText?: React.ReactNode;
/** Search loading indicator */
searchLoading?: boolean;
/** Max height of the list */
maxHeight?: number;
/** ToggleList expand/collapse notification */
onOpenChange?: (open: boolean) => void;
/** Called when user submits a new custom model */
onAdd: (item: IProviderModelItem) => void | Promise<void>;
/** Dialog title */
dialogTitle: React.ReactNode;
/** Fields for the dialog form */
dialogFields: AddCustomModelDialogFields[];
/** Submit button text */
dialogSubmitText?: React.ReactNode;
/** Cancel button text */
dialogCancelText?: React.ReactNode;
/** Container className */
className?: string;
/** Trigger button className */
buttonClassName?: string;
/** Handle selection of models (for auto-checking new items) */
handleSelectModel: (model: IProviderModelItem) => void;
/** Existing model names for uniqueness validation */
existingNames: string[];
}
/**
* Wrapper around ToggleList that renders a pinned "Add custom model" footer
* below the scrollable options. The footer slot is provided by ToggleList,
* so the action stays visible regardless of how many options are scrolled.
*
* Clicking the footer button opens a dialog; submitting the dialog pushes
* the new model through `onAdd` and then auto-selects it via
* `handleSelectModel` (which is the sole owner of selection state).
*/
export const AddableToggleList = ({
btnText,
options,
searchable,
searchPlaceholder,
emptyText,
searchLoading,
maxHeight,
onOpenChange,
onAdd,
dialogTitle,
dialogFields,
dialogSubmitText,
dialogCancelText,
className,
buttonClassName,
handleSelectModel,
existingNames,
}: AddableToggleListProps) => {
const { t } = useTranslate('setting');
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogLoading, setDialogLoading] = useState(false);
// Pinned footer rendered below the scrollable items. The footer is part
// of ToggleList's dropdown panel (outside the scrollable area) so it
// never scrolls away.
const footerNode = useMemo(
() => (
<div
role="button"
tabIndex={0}
aria-label={t('addCustomModel')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDialogOpen(true);
}
}}
onClick={() => setDialogOpen(true)}
className="flex items-center justify-center gap-2 px-3 py-4 cursor-pointer bg-bg-card m-2 rounded-md outline-none hover:bg-border-button"
>
<Plus
className="size-4 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<span className="text-sm text-text-secondary">
{t('addCustomModel')}
</span>
</div>
),
[t],
);
// Handle dialog submission
const handleDialogSubmit = useCallback(
async (item: IProviderModelItem) => {
setDialogLoading(true);
try {
const result = onAdd(item);
if (result instanceof Promise) {
await result;
}
// Auto-select the newly added model. The parent does NOT push the
// item into its own selection state during `onAdd`; this toggle
// is the sole writer of selection.
handleSelectModel(item);
setDialogOpen(false);
} catch {
// Error handling is done in the dialog
} finally {
setDialogLoading(false);
}
},
[onAdd, handleSelectModel],
);
return (
<>
<ToggleList
className={className}
btnText={btnText}
options={options}
searchable={searchable}
searchPlaceholder={searchPlaceholder}
emptyText={emptyText}
searchLoading={searchLoading}
onOpenChange={onOpenChange}
maxHeight={maxHeight}
buttonClassName={buttonClassName}
footer={footerNode}
/>
<AddCustomModelDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={dialogTitle}
fields={dialogFields}
onSubmit={handleDialogSubmit}
submitText={dialogSubmitText}
cancelText={dialogCancelText}
loading={dialogLoading}
existingNames={existingNames}
/>
</>
);
};

View File

@@ -1,5 +0,0 @@
// 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

@@ -1,4 +0,0 @@
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

@@ -1,318 +0,0 @@
import { DynamicForm, DynamicFormRef } from '@/components/dynamic-form';
import { Modal } from '@/components/ui/modal/modal';
import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks';
import { useAddInstanceModel } from '@/hooks/use-llm-request';
import { IProviderModelItem } from '@/interfaces/request/llm';
import { cn } from '@/lib/utils';
import {
useFetchInstanceNameSet,
useHideWhenInstanceExists,
VerifyResult,
} from '@/pages/user-setting/setting-model/hooks';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FieldValues } from 'react-hook-form';
import { LLMHeader } from '../../components/llm-header';
import VerifyButton from '../verify-button';
import { AddCustomModelDialog } from './components/add-custom-model-dialog';
import { AddableToggleList } from './components/addable-toggle-list';
import { useCustomModelFields } from './components/use-custom-model-fields';
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);
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setVerifyResult(null);
return () => {
setVerifyResult(null);
};
}, [visible]);
// When a verify result comes back, the VerifyButton renders new log
// content below the existing form. Scroll the modal's scrollable area
// to the bottom so the user actually sees the result. We walk up the
// DOM from a ref inside the scrollable container (the Modal renders
// it via a Radix Portal) and use rAF to wait for the new content to
// be laid out before measuring scrollHeight.
useEffect(() => {
if (!verifyResult || !scrollAnchorRef.current) {
return;
}
const scrollContainer =
scrollAnchorRef.current.closest<HTMLElement>('.overflow-y-auto');
if (!scrollContainer) {
return;
}
requestAnimationFrame(() => {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
});
}, [verifyResult]);
// 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,
setModels,
editingModel,
editDialogOpen,
setEditDialogOpen,
handleEditModel,
handleSaveEditedModel,
} = useListModelsPicker({
visible,
hasModelNameField,
editMode,
viewMode,
initialValues,
llmFactory,
config,
formRef,
});
// Mutation for adding a model to an existing instance (viewMode path)
const { addInstanceModel } = useAddInstanceModel();
// Dialog field schema for adding a custom model. Derived from
// `IProviderModelItem` (the shape of items in `listModelsOptions`),
// so the form automatically tracks the model interface. The hook lives
// next to the dialog so the schema is the single source of truth.
const customModelDialogFields = useCustomModelFields();
// Get existing model names for uniqueness validation
const existingNames = useMemo(() => models.map((m) => m.name), [models]);
// Handle adding a custom model
// - In viewMode, call the API to persist the model on the existing instance.
// - Always update the local `models` catalog so the new option is visible.
// - Selection is owned by `AddableToggleList` (it calls `handleSelectModel`
// after `onAdd` resolves). Do NOT also push into `selectedModelItems`
// here — that would race with the wrapper's toggle and the new option
// would be inserted then immediately removed.
const handleAddCustomModel = useCallback(
async (item: IProviderModelItem) => {
if (viewMode && initialValues?.instance_name) {
await addInstanceModel({
provider_name: llmFactory,
instance_name: initialValues.instance_name,
model_name: item.name,
model_type: item.model_types,
max_tokens: item.max_tokens,
extra: item.features
? {
is_tools: item.features.includes('is_tools'),
}
: undefined,
});
}
setModels((prev) =>
prev.some((m) => m.name === item.name) ? prev : [...prev, item],
);
},
[viewMode, initialValues, llmFactory, addInstanceModel, setModels],
);
// Render-only: turn the fetched model list into ToggleList options with
// the "All models" sentinel row at the top.
const listModelsOptions = useListModelsOptions({
models,
selectedModelItems,
allSelected,
handleSelectModel,
handleToggleAll,
onEditModel: handleEditModel,
});
// 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 && (
<AddableToggleList
className="w-full"
buttonClassName="self-end"
searchable={listModelsOptions.length > 10}
btnText={t('listModels')}
options={listModelsOptions}
searchPlaceholder={t('listModelsSearchPlaceholder')}
emptyText={t('listModelsEmpty')}
searchLoading={listLoading}
onOpenChange={handleListOpenChange}
maxHeight={400}
dialogTitle={t('addCustomModelTitle')}
dialogFields={customModelDialogFields}
dialogSubmitText={tc('confirm')}
dialogCancelText={tc('cancel')}
onAdd={handleAddCustomModel}
handleSelectModel={handleSelectModel}
existingNames={existingNames}
/>
)}
<div ref={scrollAnchorRef}>
<VerifyButton
onVerify={handleVerify}
verifyCallback={(result: VerifyResult | null) => {
setVerifyResult(result);
}}
className={cn({
'!flex flex-col ![position:inherit] ':
verifyResult && docLinkText && config.docLink,
})}
/>
</div>
<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={cn('text-primary hover:underline', {
'ml-24': !verifyResult,
})}
>
{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>
{editingModel && (
<AddCustomModelDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
title={editingModel.name}
fields={customModelDialogFields.map((f) => ({
...f,
defaultValue:
f.name === 'name'
? editingModel.name
: f.name === 'model_types'
? (editingModel.model_types ?? [])
: f.name === 'max_tokens'
? (editingModel.max_tokens ?? 0)
: f.name === 'features'
? (editingModel.features ?? [])
: f.defaultValue,
}))}
onSubmit={handleSaveEditedModel}
submitText={tc('ok')}
cancelText={tc('cancel')}
existingNames={existingNames.filter((n) => n !== editingModel.name)}
/>
)}
</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

@@ -1,366 +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 } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { LLMFactory } from '@/constants/llm';
import { VerifyResult } from '@/pages/user-setting/setting-model/hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, 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';
const IMAGE_FORMATS = ['url', 'base64', 'none'] as const;
const FORMULA_FORMATS = ['latex', 'mathml', 'ascii'] as const;
const TABLE_FORMATS = ['html', 'markdown', 'image'] as const;
const CS_FORMATS = ['image'] as const;
const FORMAT_LABELS = {
url: 'URL',
base64: 'Base64',
none: 'None',
latex: 'LaTeX',
mathml: 'MathML',
ascii: 'ASCII',
html: 'HTML',
markdown: 'Markdown',
image: 'Image',
} as const;
export type SoMarkFormValues = {
instance_name: string;
llm_name: string;
somark_base_url: string;
somark_api_key?: string;
somark_image_format: (typeof IMAGE_FORMATS)[number];
somark_formula_format: (typeof FORMULA_FORMATS)[number];
somark_table_format: (typeof TABLE_FORMATS)[number];
somark_cs_format: (typeof CS_FORMATS)[number];
somark_enable_text_cross_page: boolean;
somark_enable_table_cross_page: boolean;
somark_enable_title_level_recognition: boolean;
somark_enable_inline_image: boolean;
somark_enable_table_image: boolean;
somark_enable_image_understanding: boolean;
somark_keep_header_footer: boolean;
};
export interface IModalProps<T> {
visible: boolean;
hideModal: () => void;
onOk?: (data: T) => Promise<boolean>;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
loading?: boolean;
}
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<div className="text-sm font-semibold text-muted-foreground border-b pb-1">
{children}
</div>
);
const buildFormatOptions = <T extends keyof typeof FORMAT_LABELS>(
formats: readonly T[],
) => formats.map((value) => ({ label: FORMAT_LABELS[value], value }));
const SoMarkModal = ({
visible,
hideModal,
onOk,
onVerify,
loading,
}: IModalProps<SoMarkFormValues>) => {
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.somark.modelNameMessage'),
}),
somark_base_url: z.string().min(1, {
message: t('setting.somark.baseUrlMessage'),
}),
somark_api_key: z.string().optional(),
somark_image_format: z.enum(IMAGE_FORMATS),
somark_formula_format: z.enum(FORMULA_FORMATS),
somark_table_format: z.enum(TABLE_FORMATS),
somark_cs_format: z.enum(CS_FORMATS),
somark_enable_text_cross_page: z.boolean(),
somark_enable_table_cross_page: z.boolean(),
somark_enable_title_level_recognition: z.boolean(),
somark_enable_inline_image: z.boolean(),
somark_enable_table_image: z.boolean(),
somark_enable_image_understanding: z.boolean(),
somark_keep_header_footer: z.boolean(),
}),
[t],
);
const imageFormatOptions = useMemo(
() => buildFormatOptions(IMAGE_FORMATS),
[],
);
const formulaFormatOptions = useMemo(
() => buildFormatOptions(FORMULA_FORMATS),
[],
);
const tableFormatOptions = useMemo(
() => buildFormatOptions(TABLE_FORMATS),
[],
);
const csFormatOptions = useMemo(() => buildFormatOptions(CS_FORMATS), []);
const form = useForm<SoMarkFormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
instance_name: '',
llm_name: '',
somark_base_url: '',
somark_api_key: '',
somark_image_format: 'url',
somark_formula_format: 'latex',
somark_table_format: 'html',
somark_cs_format: 'image',
somark_enable_text_cross_page: false,
somark_enable_table_cross_page: false,
somark_enable_title_level_recognition: false,
somark_enable_inline_image: false,
somark_enable_table_image: true,
somark_enable_image_understanding: true,
somark_keep_header_footer: false,
},
});
const handleOk = async (values: SoMarkFormValues) => {
const ret = await onOk?.(values as any);
if (ret) {
hideModal?.();
}
};
return (
<Dialog open={visible} onOpenChange={hideModal}>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
<LLMHeader name={LLMFactory.SoMark} />
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleOk)}
className="space-y-5"
id="somark-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="somark-from-env-1" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_base_url"
label={t('setting.somark.baseUrl')}
required
>
<Input placeholder={t('setting.somark.baseUrlPlaceholder')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_api_key"
label={t('setting.somark.apiKey')}
>
<Input
type="password"
placeholder={t('setting.somark.apiKeyPlaceholder')}
/>
</RAGFlowFormItem>
<SectionTitle>
{t('setting.somark.sectionElementFormats')}
</SectionTitle>
<RAGFlowFormItem
name="somark_image_format"
label={t('setting.somark.imageFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={imageFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_formula_format"
label={t('setting.somark.formulaFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={formulaFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_table_format"
label={t('setting.somark.tableFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={tableFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_cs_format"
label={t('setting.somark.csFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={csFormatOptions}
/>
)}
</RAGFlowFormItem>
<SectionTitle>
{t('setting.somark.sectionFeatureConfig')}
</SectionTitle>
<RAGFlowFormItem
name="somark_enable_text_cross_page"
label={t('setting.somark.enableTextCrossPage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_table_cross_page"
label={t('setting.somark.enableTableCrossPage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_title_level_recognition"
label={t('setting.somark.enableTitleLevelRecognition')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_inline_image"
label={t('setting.somark.enableInlineImage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_table_image"
label={t('setting.somark.enableTableImage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_image_understanding"
label={t('setting.somark.enableImageUnderstanding')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_keep_header_footer"
label={t('setting.somark.keepHeaderFooter')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
{onVerify && (
<VerifyButton
onVerify={onVerify as (postBody: any) => Promise<VerifyResult>}
isAbsolute={false}
validLabel={t('setting.somark.verifyPassed')}
invalidLabel={t('setting.somark.verifyFailed')}
/>
)}
</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="somark-form" loading={loading}>
{t('common.add')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default memo(SoMarkModal);

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IAddInstanceModelRequestBody,
IAddProviderInstanceRequestBody,

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LLMFactory } from '@/constants/llm';
/**

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FormFieldType } from '@/components/dynamic-form';
import { LLMFactory } from '@/constants/llm';
import type { ProviderConfig } from '../types';

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ProviderConfig } from '../types';
import { GenericApiKeyConfig } from './generic-api-key-config';
import { LocalLlmConfigs } from './local-llm-configs';

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// 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

@@ -1,7 +1,23 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FormFieldType } from '@/components/dynamic-form';
import { LLMFactory } from '@/constants/llm';
import type { FieldConfig, ProviderConfig } from '../types';
import { buildModelInfoFromValues, capitalize } from './utils';
import { buildModelInfoFromValues } from './utils';
/**
* Factory configuration for local/compatible factories
@@ -175,20 +191,20 @@ function buildLocalConfig(
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: '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',
@@ -205,23 +221,23 @@ function buildLocalConfig(
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,
},
// {
// 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) {

View File

@@ -1,7 +1,22 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FormFieldType } from '@/components/dynamic-form';
import { LLMFactory } from '@/constants/llm';
import type { ProviderConfig } from '../types';
import { buildModelInfoFromValues } from './utils';
/**
* Factory configuration mapping table
@@ -23,18 +38,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -53,15 +56,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -70,27 +64,11 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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) => ({
apiKey: values.api_key,
baseUrl: values.api_base,
modelInfo: buildModelInfoFromValues(values),
modelInfo: [],
}),
submitTransform: (values) => ({
instance_name: values.instance_name,
@@ -98,7 +76,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
api_base: values.api_base,
api_key: values.api_key,
api_version: values.api_version,
model_info: buildModelInfoFromValues(values),
model_info: [],
}),
},
@@ -118,35 +96,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -156,27 +105,16 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
shouldRender: 'hideWhenInstanceExists',
validation: { message: 'ArkApiKeyMessage' },
},
{
name: 'max_tokens',
label: 'maxTokens',
type: FormFieldType.Number,
required: true,
placeholder: 'maxTokensTip',
defaultValue: 8192,
validation: { min: 0 },
},
],
verifyTransform: (values) => ({
apiKey: values.api_key,
endpoint_id: values.endpoint_id,
modelInfo: buildModelInfoFromValues(values),
modelInfo: [],
}),
submitTransform: (values) => ({
instance_name: values.instance_name,
llm_factory: LLMFactory.VolcEngine,
endpoint_id: values.endpoint_id,
api_key: values.api_key,
model_info: buildModelInfoFromValues(values),
model_info: [],
}),
},
@@ -194,25 +132,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -240,15 +159,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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) => ({
apiKey: {
@@ -256,7 +166,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
google_region: values.google_region,
google_service_account_key: values.google_service_account_key,
},
modelInfo: buildModelInfoFromValues(values),
modelInfo: [],
}),
submitTransform: (values) => ({
instance_name: values.instance_name,
@@ -264,7 +174,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
google_project_id: values.google_project_id,
google_region: values.google_region,
google_service_account_key: values.google_service_account_key,
model_info: buildModelInfoFromValues(values),
model_info: [],
}),
},
@@ -284,46 +194,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -348,14 +218,14 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
TencentCloud_sid: values.TencentCloud_sid,
TencentCloud_sk: values.TencentCloud_sk,
},
modelInfo: buildModelInfoFromValues(values),
modelInfo: [],
}),
submitTransform: (values) => ({
instance_name: values.instance_name,
llm_factory: LLMFactory.TencentCloud,
TencentCloud_sid: values.TencentCloud_sid,
TencentCloud_sk: values.TencentCloud_sk,
model_info: buildModelInfoFromValues(values),
model_info: [],
}),
},
@@ -373,25 +243,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -407,7 +258,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
type: FormFieldType.Text,
required: true,
placeholder: 'SparkAPPIDMessage',
shouldRender: 'modelTypeIncludesTtsAndNotExists',
shouldRender: 'hideWhenInstanceExists',
validation: { message: 'SparkAPPIDMessage' },
},
{
@@ -416,7 +267,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
type: FormFieldType.Text,
required: true,
placeholder: 'SparkAPISecretMessage',
shouldRender: 'modelTypeIncludesTtsAndNotExists',
shouldRender: 'hideWhenInstanceExists',
validation: { message: 'SparkAPISecretMessage' },
},
{
@@ -425,18 +276,9 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
type: FormFieldType.Text,
required: true,
placeholder: 'SparkAPIKeyMessage',
shouldRender: 'modelTypeIncludesTtsAndNotExists',
shouldRender: 'hideWhenInstanceExists',
validation: { message: 'SparkAPIKeyMessage' },
},
{
name: 'max_tokens',
label: 'maxTokens',
type: FormFieldType.Number,
required: true,
placeholder: 'maxTokensTip',
defaultValue: 8192,
validation: { min: 0, message: 'maxTokensInvalidMessage' },
},
],
verifyTransform: (values) => ({
apiKey: {
@@ -445,7 +287,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
spark_api_secret: values.spark_api_secret,
spark_api_key: values.spark_api_key,
},
modelInfo: buildModelInfoFromValues(values),
modelInfo: [],
}),
submitTransform: (values) => ({
instance_name: values.instance_name,
@@ -456,7 +298,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
spark_api_secret: values.spark_api_secret,
spark_api_key: values.spark_api_key,
},
model_info: buildModelInfoFromValues(values),
model_info: [],
}),
},
@@ -474,26 +316,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -512,22 +334,13 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
shouldRender: 'hideWhenInstanceExists',
validation: { message: 'yiyanSKMessage' },
},
{
name: 'max_tokens',
label: 'maxTokens',
type: FormFieldType.Number,
required: true,
placeholder: 'maxTokensTip',
defaultValue: 8192,
validation: { min: 0 },
},
],
verifyTransform: (values) => ({
apiKey: {
yiyan_ak: values.yiyan_ak,
yiyan_sk: values.yiyan_sk,
},
modelInfo: buildModelInfoFromValues(values),
modelInfo: [],
}),
submitTransform: (values) => ({
instance_name: values.instance_name,
@@ -536,7 +349,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
yiyan_ak: values.yiyan_ak,
yiyan_sk: values.yiyan_sk,
},
model_info: buildModelInfoFromValues(values),
model_info: [],
}),
},
@@ -556,22 +369,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -590,29 +387,20 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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) => ({
apiKey: {
fish_audio_ak: values.fish_audio_ak,
fish_audio_refid: values.fish_audio_refid,
},
modelInfo: buildModelInfoFromValues(values),
modelInfo: [],
}),
submitTransform: (values) => ({
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: buildModelInfoFromValues(values),
model_info: [],
}),
},
@@ -630,14 +418,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
tooltip: 'instanceNameTip',
validation: { message: 'instanceNameMessage' },
},
{
name: 'model_name',
label: 'modelName',
type: FormFieldType.Text,
required: true,
placeholder: 'modelNameMessage',
validation: { message: 'modelNameMessage' },
},
{
name: 'opendataloader_apiserver',
label: 'baseUrl',
@@ -662,17 +442,10 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
if (values.opendataloader_api_key) {
cfg.opendataloader_api_key = values.opendataloader_api_key;
}
cfg.llm_name = values.model_name;
return {
apiKey: cfg,
baseUrl: values.opendataloader_apiserver,
modelInfo: [
{
model_name: values.model_name,
model_type: ['ocr'],
max_tokens: 0,
},
],
modelInfo: [],
};
},
submitTransform: (values) => {
@@ -683,19 +456,12 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
if (values.opendataloader_api_key) {
cfg.opendataloader_api_key = values.opendataloader_api_key;
}
cfg.llm_name = values.model_name;
return {
instance_name: values.instance_name,
llm_factory: LLMFactory.OpenDataLoader,
api_key: cfg,
api_base: '',
model_info: [
{
model_name: values.model_name,
model_type: ['ocr'],
max_tokens: 0,
},
],
model_info: [],
};
},
},
@@ -714,14 +480,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -766,10 +524,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
return {
apiKey: cfg,
baseUrl: values.paddleocr_api_url,
modelInfo: buildModelInfoFromValues({
...values,
model_type: ['ocr'],
}),
modelInfo: [],
};
},
submitTransform: (values) => {
@@ -785,10 +540,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
llm_factory: LLMFactory.PaddleOCR,
api_key: cfg,
api_base: '',
model_info: buildModelInfoFromValues({
...values,
model_type: ['ocr'],
}),
model_info: [],
};
},
},
@@ -807,14 +559,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
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',
@@ -869,7 +613,6 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
verifyTransform: (values) => {
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;
@@ -877,16 +620,12 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
return {
apiKey: cfg,
baseUrl: values.mineru_apiserver,
modelInfo: buildModelInfoFromValues({
...values,
model_type: ['ocr'],
}),
modelInfo: [],
};
},
submitTransform: (values) => {
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;
@@ -896,10 +635,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
llm_factory: LLMFactory.MinerU,
api_key: cfg,
api_base: '',
model_info: buildModelInfoFromValues({
...values,
model_type: ['ocr'],
}),
model_info: [],
};
},
},

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IModelInfo } from '@/interfaces/request/llm';
/**

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Checkbox } from '@/components/ui/checkbox';
import { useTranslate } from '@/hooks/common-hooks';
import { IProviderModelItem } from '@/interfaces/request/llm';

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynamicFormRef } from '@/components/dynamic-form';
import { useListProviderModels } from '@/hooks/use-llm-request';
import { IModelInfo, IProviderModelItem } from '@/interfaces/request/llm';

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FormFieldConfig, FormFieldType } from '@/components/dynamic-form';
import { Input } from '@/components/ui/input';
import { InputSelect } from '@/components/ui/input-select';
@@ -283,14 +299,7 @@ export const useProviderFields = ({
setNestedValue(result, 'model_type', []);
}
return result;
}, [
editMode,
viewMode,
initialValues,
config.fields,
llmFactory,
baseUrlRegionMaps,
]);
}, [editMode, viewMode, initialValues, config.fields, baseUrlRegionMaps]);
// Documentation link text (rendered at the bottom of the modal)
const docLinkText = useMemo(() => {

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynamicFormRef } from '@/components/dynamic-form';
import message from '@/components/ui/message';
import { useTranslate } from '@/hooks/common-hooks';

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FormFieldType } from '@/components/dynamic-form';
import type { IModelInfo } from '@/interfaces/request/llm';
import type { ReactNode } from 'react';

View File

@@ -13,8 +13,8 @@ import { Routes } from '@/routes';
import { TFunction } from 'i18next';
import {
LucideBox,
LucideMessagesSquare,
LucideLogOut,
LucideMessagesSquare,
LucideServer,
LucideUnplug,
LucideUser,

View File

@@ -16,6 +16,9 @@ const {
editInstanceModel,
deleteProviderInstance,
updateModelStatus,
patchInstanceModel,
deleteInstanceModels,
updateProviderInstance,
} = api;
const methods = {
@@ -79,6 +82,18 @@ const methods = {
url: updateModelStatus,
method: 'patch',
},
patchInstanceModel: {
url: patchInstanceModel,
method: 'patch',
},
deleteInstanceModels: {
url: deleteInstanceModels,
method: 'delete',
},
updateProviderInstance: {
url: updateProviderInstance,
method: 'put',
},
} as const;
const llmService = registerNextServer<keyof typeof methods>(methods);

View File

@@ -69,6 +69,13 @@ export default {
`${restAPIv1}/providers/${provider_name}/instances/${instance_name}/models`,
deleteProviderInstance: ({ provider_name }: { provider_name: string }) =>
`${restAPIv1}/providers/${provider_name}/instances`,
updateProviderInstance: ({
provider_name,
instance_name,
}: {
provider_name: string;
instance_name: string;
}) => `${restAPIv1}/providers/${provider_name}/instances/${instance_name}`,
updateModelStatus: ({
provider_name,
instance_name,
@@ -79,6 +86,24 @@ export default {
model_name: string;
}) =>
`${restAPIv1}/providers/${provider_name}/instances/${instance_name}/models/${model_name}`,
patchInstanceModel: ({
provider_name,
instance_name,
model_name,
}: {
provider_name: string;
instance_name: string;
model_name: string;
}) =>
`${restAPIv1}/providers/${provider_name}/instances/${instance_name}/models/${model_name}`,
deleteInstanceModels: ({
provider_name,
instance_name,
}: {
provider_name: string;
instance_name: string;
}) =>
`${restAPIv1}/providers/${provider_name}/instances/${instance_name}/models`,
// data source
dataSourceUpdate: (id: string) => `${restAPIv1}/connectors/${id}`,