mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-21 07:01:04 +08:00
Feat: v0.27.0 model provider (#16604)
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -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),
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
816
web/src/pages/user-setting/setting-model/instance-card/hooks.tsx
Normal file
816
web/src/pages/user-setting/setting-model/instance-card/hooks.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
153
web/src/pages/user-setting/setting-model/layout/sidebar.tsx
Normal file
153
web/src/pages/user-setting/setting-model/layout/sidebar.tsx
Normal 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;
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
@@ -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: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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(() => {
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -13,8 +13,8 @@ import { Routes } from '@/routes';
|
||||
import { TFunction } from 'i18next';
|
||||
import {
|
||||
LucideBox,
|
||||
LucideMessagesSquare,
|
||||
LucideLogOut,
|
||||
LucideMessagesSquare,
|
||||
LucideServer,
|
||||
LucideUnplug,
|
||||
LucideUser,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user