refactor(setting-model): migrate SoMark to generic provider card withmodel-level extra fields (#17582)

This commit is contained in:
chanx
2026-07-31 11:35:35 +08:00
committed by GitHub
parent 2c6da4113d
commit c6f946a562
16 changed files with 453 additions and 807 deletions

View File

@@ -108,6 +108,14 @@ export interface IInstanceModel {
* without relying solely on the (possibly unfetched) catalog.
*/
is_tools?: boolean;
/**
* Per-model extra config persisted in `tenant_model.extra`.
* Carries provider-specific fields such as SoMark's element-format
* selects and feature-config toggles. Echoed back by the backend's
* `_hybrid_get_instance_models` so the frontend can pre-fill the
* edit dialog.
*/
extra?: Record<string, any>;
}
export interface IDefaultModel {

View File

@@ -138,6 +138,13 @@ export interface IProviderModelItem {
max_tokens: number;
model_types: string[];
features: string[] | null;
/**
* Per-model extra config forwarded through `model_info[].extra`
* (e.g. SoMark's element-format / feature-config fields).
* Catalog models typically omit this; it is populated by the
* edit dialog and the `useModelsDerived` echo path.
*/
extra?: Record<string, any>;
}
/**

View File

@@ -2285,6 +2285,17 @@ Example: Virtual Hosted Style`,
formulaFormat: 'Formula Format',
tableFormat: 'Table Format',
csFormat: 'Chemical Structural Formula Format',
formatOptions: {
url: 'URL',
base64: 'Base64',
none: 'None',
latex: 'LaTeX',
mathml: 'MathML',
ascii: 'ASCII',
html: 'HTML',
markdown: 'Markdown',
image: 'Image',
},
sectionFeatureConfig: 'Feature Config',
enableInlineImage: 'Enable Inline Image',
enableTableImage: 'Enable Table Image',

View File

@@ -1927,6 +1927,17 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
formulaFormat: '公式格式',
tableFormat: '表格格式',
csFormat: '化学结构式格式',
formatOptions: {
url: 'URL',
base64: 'Base64',
none: 'None',
latex: 'LaTeX',
mathml: 'MathML',
ascii: 'ASCII',
html: 'HTML',
markdown: 'Markdown',
image: 'Image',
},
sectionFeatureConfig: '特色功能配置',
enableInlineImage: '返回文中图',
enableTableImage: '返回表中图',

View File

@@ -144,9 +144,9 @@ export const useVerifyConnection = () => {
};
// ============ Hooks for retained special modals ============
// Bedrock and SoMark have been migrated to inline instance cards
// (BedrockInstanceCard / SoMarkInstanceCard); these legacy modal
// hooks are kept only for backward-compat references.
// Bedrock has been migrated to an inline instance card
// (BedrockInstanceCard); these legacy modal hooks are kept only
// for backward-compat references.
export const useSubmitBedrock = () => {
const [saveLoading, setSaveLoading] = useState(false);

View File

@@ -49,12 +49,13 @@ import SystemSetting from './layout/system-setting';
* Save flow: the top Save button validates every visible card through
* the imperative ref API; if all are valid it collects each card's
* payload (skipping non-dirty saved cards) and dispatches one API call
* per dirty card - `addProviderInstance` for drafts and Bedrock/SoMark
* per dirty card - `addProviderInstance` for drafts and Bedrock
* saved cards, `updateProviderInstance` for generic saved cards.
*
* Special-case providers (handled inside `ProviderInstanceCard`):
* - `Bedrock`: rendered inline via `BedrockInstanceCard`.
* - `SoMark`: rendered inline via `SoMarkInstanceCard`.
* - All other providers (including SoMark) use the generic
* `GenericProviderInstanceCard` path.
*/
const SettingModelV2: FC = () => {
const { t: tSetting } = useTranslate('setting');
@@ -163,10 +164,10 @@ const SettingModelV2: FC = () => {
// 3. If any card is invalid (or a draft has no name), abort the
// whole batch - errors are surfaced in the form UI by `trigger()`.
// 4. Dispatch one API call per dirty card, in order. Drafts and
// Bedrock/SoMark saved cards go through `addProviderInstance`
// (Bedrock/SoMark saved cards carry an `id` so the backend
// updates instead of creating); generic saved cards go through
// `updateProviderInstance`.
// Bedrock saved cards go through `addProviderInstance`
// (Bedrock saved cards carry an `id` so the backend
// updates instead of creating); generic and SoMark saved cards
// go through `updateProviderInstance`.
// 5. On success: clear drafts (they're persisted now), mark each
// saved card's baseline so the next save short-circuits, and
// invalidate the instance query so the new/updated cards appear.

View File

@@ -44,8 +44,15 @@ export interface AddCustomModelDialogFields {
/** Display label */
label: string;
/** Form field type */
type: 'text' | 'number' | 'multi-select' | 'switch-group';
/** Options for multi-select / switch-group types */
type:
| 'text'
| 'number'
| 'multi-select'
| 'switch-group'
| 'select'
| 'switch'
| 'section';
/** Options for multi-select / switch-group / select types */
options?: { label: string; value: string }[];
/** Whether the field is required */
required?: boolean;
@@ -74,6 +81,15 @@ interface AddCustomModelDialogProps {
loading?: boolean;
/** Existing model names for uniqueness validation */
existingNames: string[];
/**
* Whitelist of feature keys that are provider-specific (e.g.
* SoMark's `somark_enable_*`). On submit, these are moved from the
* `features` array into `extra` as boolean `true` values so each
* provider's payload stays self-describing. Standard features like
* `is_tools` stay in `features`. When omitted, no features are
* moved to `extra`.
*/
providerFeatureKeys?: string[];
/** Initial form values (overrides field-level defaults). Useful for edit mode. */
defaultValues?: Record<string, unknown>;
}
@@ -95,6 +111,7 @@ export const AddCustomModelDialog = ({
loading = false,
existingNames,
defaultValues,
providerFeatureKeys,
}: AddCustomModelDialogProps) => {
const { t } = useTranslate('setting');
const { t: commonT } = useTranslate('common');
@@ -109,7 +126,53 @@ export const AddCustomModelDialog = ({
field.type === 'multi-select' || field.type === 'switch-group';
const defaultValue =
field.defaultValue ??
(field.type === 'number' ? 0 : isArrayType ? [] : '');
(field.type === 'number'
? 0
: field.type === 'switch'
? false
: isArrayType
? []
: '');
if (field.type === 'section') {
return {
name: `__section_${field.name}`,
label: field.label,
type: FormFieldType.Custom,
hideLabel: true,
schema: z.any().optional(),
render: () => (
<div className="text-sm font-semibold text-muted-foreground border-b pb-1 mt-4">
{field.label}
</div>
),
};
}
if (field.type === 'switch') {
return {
name: field.name,
label: field.label,
type: FormFieldType.Switch,
required: field.required,
defaultValue: defaultValue ?? false,
disabled: field.disabled,
labelClassName: '!mb-0',
};
}
if (field.type === 'select') {
return {
name: field.name,
label: field.label,
type: FormFieldType.Select,
required: field.required,
defaultValue,
disabled: field.disabled,
options: field.options,
placeholder: field.label,
};
}
if (field.type === 'switch-group') {
return {
@@ -125,14 +188,16 @@ export const AddCustomModelDialog = ({
render: (fieldProps) => {
const currentValues = (fieldProps.value as string[]) ?? [];
return (
<div className="space-y-2 rounded-md border border-border-button p-3">
{field.options?.map((opt) => {
<div className="rounded-md border border-border-button overflow-hidden">
{field.options?.map((opt, index) => {
const isChecked = currentValues.includes(opt.value);
const switchId = `${field.name}-${opt.value}`;
return (
<div
key={opt.value}
className="flex items-center justify-between gap-3"
className={`flex items-center justify-between gap-3 px-3 py-2.5 ${
index % 2 === 1 ? 'bg-bg-card' : ''
}`}
>
<Label
htmlFor={switchId}
@@ -205,18 +270,61 @@ export const AddCustomModelDialog = ({
const handleSubmit = useCallback(
(values: FormValues) => {
const features = values.features;
const featuresArray = Array.isArray(features)
? (features as string[])
: [];
// Use the caller-supplied `providerFeatureKeys` whitelist to
// separate provider-specific feature flags from standard ones.
// Provider-specific flags are stored as boolean values in `extra`
// rather than as array entries in `features`, so each provider's
// payload stays self-describing. When no whitelist is supplied,
// all features stay in `features` (backward compatible).
const providerSet = new Set(providerFeatureKeys ?? []);
const standardFeatures = featuresArray.filter(
(f) => typeof f === 'string' && !providerSet.has(f),
);
const providerFeatures = featuresArray.filter(
(f) => typeof f === 'string' && providerSet.has(f),
);
const item: IProviderModelItem = {
name: (values.name as string) ?? '',
max_tokens: (values.max_tokens as number) ?? 0,
model_types: (values.model_types as string[]) ?? [],
features:
Array.isArray(features) && features.length > 0
? (features as string[])
: null,
features: standardFeatures.length > 0 ? standardFeatures : null,
};
// Collect provider-specific extra fields (e.g. SoMark's
// element-format selects) that are not part of the standard
// IProviderModelItem shape.
const standardKeys = new Set([
'name',
'model_types',
'max_tokens',
'features',
]);
const extra: Record<string, any> = {};
for (const [key, value] of Object.entries(values)) {
if (
!standardKeys.has(key) &&
!key.startsWith('__section_') &&
value !== undefined
) {
extra[key] = value;
}
}
// Convert ALL provider-specific features to boolean extra values.
// Selected features get `true`, unselected get `false` so the
// backend clears toggles the user turned off (not just sets the
// ones that are on).
const selectedSet = new Set(providerFeatures);
for (const key of providerFeatureKeys ?? []) {
extra[key] = selectedSet.has(key);
}
if (Object.keys(extra).length > 0) {
item.extra = extra;
}
onSubmit(item);
},
[onSubmit],
[onSubmit, providerFeatureKeys],
);
// Reset form whenever the dialog opens/closes, applying defaultValues for edit mode.
@@ -230,32 +338,34 @@ export const AddCustomModelDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md" onClick={(e) => e.stopPropagation()}>
<DialogContent className="max-w-2xl" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DynamicForm.Root
ref={formRef}
fields={dynamicFields}
onSubmit={handleSubmit}
defaultValues={defaultValues}
>
<DialogFooter className="mb-0 pb-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelText ?? t('cancel')}
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitText ?? commonT('confirm')}
</Button>
</DialogFooter>
</DynamicForm.Root>
<div className=" max-h-[70vh] overflow-y-auto mb-12">
<DynamicForm.Root
ref={formRef}
fields={dynamicFields}
onSubmit={handleSubmit}
defaultValues={defaultValues}
className="pr-2 pb-2"
>
<DialogFooter className="absolute bottom-5 right-5 left-0 mb-0 pb-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelText ?? t('cancel')}
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitText ?? commonT('confirm')}
</Button>
</DialogFooter>
</DynamicForm.Root>
</div>
</DialogContent>
</Dialog>
);

View File

@@ -165,7 +165,7 @@ export function SavedModeCard({
className="text-sm font-medium truncate overflow-hidden flex-1 cursor-text"
onDoubleClick={(e) => {
e.stopPropagation();
startRename();
// startRename();
}}
title={tSetting('editInstanceName')}
data-testid="instance-name-static"

View File

@@ -77,7 +77,7 @@ export interface InstanceSavePayload {
/**
* Which save endpoint the parent should dispatch to:
* - `'add'`: call `addProviderInstance` (drafts of any provider, plus
* Bedrock / SoMark saved cards which carry an `id` inside the
* Bedrock saved cards which carry an `id` inside the
* `addProviderInstance` body).
* - `'update'`: call `updateProviderInstance` (generic saved cards,
* whose payload matches `IUpdateProviderInstanceRequestBody`).

View File

@@ -83,7 +83,7 @@ export const buildModelInfo = (items: IProviderModelItem[]): IModelInfo[] =>
model_name: m.name,
model_type: m.model_types ?? [],
max_tokens: m.max_tokens ?? 0,
extra: { is_tools: hasToolFeature(m.features) },
extra: { is_tools: hasToolFeature(m.features), ...(m.extra ?? {}) },
}));
/** Resolved credentials for catalog / verify / batch calls. */
@@ -279,6 +279,7 @@ export function useModelsDerived({
max_tokens: im.max_tokens ?? 0,
model_types,
features,
extra: im.extra,
};
});
}, [sourceItems, catalogFeatures]);
@@ -651,7 +652,7 @@ export function useModelMutations({
model_name: model.name,
model_type: model.model_types ?? [],
max_tokens: model.max_tokens ?? 0,
extra: { is_tools: hasToolFeature(model.features) },
extra: { is_tools: hasToolFeature(model.features), ...(model.extra ?? {}) },
});
};
@@ -691,7 +692,7 @@ export function useModelMutations({
model_name: item.name,
model_type: item.model_types ?? [],
max_tokens: item.max_tokens ?? 0,
extra: { is_tools: hasToolFeature(item.features) },
extra: { is_tools: hasToolFeature(item.features), ...(item.extra ?? {}) },
});
};
@@ -749,11 +750,18 @@ export function useModelMutations({
interface UseModelEditArgs {
providerName: string;
instanceName: string;
isDraftInstance?: boolean;
updateDraftModel?: (item: IProviderModelItem) => void;
}
export function useModelEdit({ providerName, instanceName }: UseModelEditArgs) {
export function useModelEdit({
providerName,
instanceName,
isDraftInstance,
updateDraftModel,
}: UseModelEditArgs) {
const queryClient = useQueryClient();
const customModelDialogFields = useCustomModelFields();
const customModelDialogFields = useCustomModelFields(providerName);
const { patchInstanceModel, loading: editLoading } = usePatchInstanceModel();
// Model currently being edited via AddCustomModelDialog (with `name`
// pinned/disabled and the dialog initial values pre-populated from the
@@ -773,30 +781,76 @@ export function useModelEdit({ providerName, instanceName }: UseModelEditArgs) {
[customModelDialogFields],
);
// Initial form values for the edit dialog, derived from the model
// currently being edited.
// Whitelist of provider-specific feature keys derived from the
// `features` switch-group options. Any option value that is not
// `is_tools` is treated as provider-specific: on submit it is moved
// from `features` to `extra` as a boolean; on echo it is converted
// back from an `extra` boolean to a features array entry.
const providerFeatureKeys = useMemo(() => {
const featuresField = customModelDialogFields.find(
(f) => f.name === 'features',
);
return (featuresField?.options ?? [])
.filter((o) => o.value !== 'is_tools')
.map((o) => o.value);
}, [customModelDialogFields]);
// Initial form values for the edit dialog, derived from the model's
// persisted `extra` state. The `features` switch-group shows
// enabled/disabled state, so it must be built from `extra` booleans
// rather than from `editingModel.features` which merges in
// catalog-supported features and would incorrectly pre-select
// features the user has disabled.
const editDefaultValues = useMemo(() => {
if (!editingModel) return undefined;
const extra = editingModel.extra ?? {};
// Build the features array from `extra` booleans whose keys match
// the standard feature (`is_tools`) or the provider-specific
// whitelist. Only `true` values become selected switch-group entries.
const featureKeySet = new Set<string>([
'is_tools',
...providerFeatureKeys,
]);
const features: string[] = [];
const featureBooleans = new Set<string>();
for (const [key, value] of Object.entries(extra)) {
if (featureKeySet.has(key) && typeof value === 'boolean') {
featureBooleans.add(key);
if (value === true) {
features.push(key);
}
}
}
// Remaining extra fields (non-feature: element-format selects, etc.).
const remainingExtra = Object.fromEntries(
Object.entries(extra).filter(
([k]) => !featureBooleans.has(k),
),
);
return {
name: editingModel.name,
model_types: editingModel.model_types ?? [],
max_tokens: editingModel.max_tokens ?? 0,
features: editingModel.features ?? [],
features,
...remainingExtra,
};
}, [editingModel]);
}, [editingModel, providerFeatureKeys]);
// Persist edits to an existing model. The instance-models cache
// (the source of truth for already-added models) is patched so the
// UI reflects the new `max_tokens` / `model_types` / `is_tools`
// values immediately, before the PATCH's invalidation refetches.
// Updating `catalog` instead would be a no-op here: the union in
// `useModelsDerived` lets `instanceItems` win on name conflicts, so
// a catalog-only patch is invisible for any model already attached
// to the instance.
// Persist edits to an existing model. For drafts the backend has no
// instance yet, so we update the local `draftModels` list instead of
// calling PATCH. For saved cards the instance-models cache is patched
// so the UI reflects the new values immediately, before the PATCH's
// invalidation refetches.
const handleEditSubmit = async (item: IProviderModelItem) => {
if (!editingModel) return;
const targetName = editingModel.name;
if (isDraftInstance && updateDraftModel) {
updateDraftModel(item);
setEditingModel(null);
return;
}
queryClient.setQueryData<IInstanceModel[]>(
LlmKeys.instanceModels(providerName, instanceName),
(prev) => {
@@ -810,6 +864,7 @@ export function useModelEdit({ providerName, instanceName }: UseModelEditArgs) {
max_tokens: item.max_tokens ?? 0,
model_type: item.model_types ?? [],
is_tools: hasToolFeature(item.features),
extra: { is_tools: hasToolFeature(item.features), ...(item.extra ?? {}) },
};
return next;
},
@@ -821,7 +876,7 @@ export function useModelEdit({ providerName, instanceName }: UseModelEditArgs) {
model_name: targetName,
max_tokens: item.max_tokens ?? 0,
model_type: item.model_types ?? [],
extra: { is_tools: hasToolFeature(item.features) },
extra: { is_tools: hasToolFeature(item.features), ...(item.extra ?? {}) },
});
setEditingModel(null);
};
@@ -834,5 +889,6 @@ export function useModelEdit({ providerName, instanceName }: UseModelEditArgs) {
handleEditSubmit,
editLoading,
customModelDialogFields,
providerFeatureKeys,
};
}

View File

@@ -142,6 +142,11 @@ export function ModelsSection(props: ModelsSectionProps) {
const removeDraftModel = useCallback((name: string) => {
setDraftModels((prev) => prev.filter((m) => m.name !== name));
}, []);
const updateDraftModel = useCallback((item: IProviderModelItem) => {
setDraftModels((prev) =>
prev.map((m) => (m.name === item.name ? { ...m, ...item } : m)),
);
}, []);
// 4. Derived union list (instance catalog) + push to host.
const { instanceItems, models, addedSet } = useModelsDerived({
@@ -246,9 +251,12 @@ export function ModelsSection(props: ModelsSectionProps) {
handleEditSubmit,
editLoading,
customModelDialogFields,
providerFeatureKeys,
} = useModelEdit({
providerName,
instanceName,
isDraftInstance,
updateDraftModel,
});
// Add-custom-model dialog open state (local UI state).
@@ -421,6 +429,7 @@ export function ModelsSection(props: ModelsSectionProps) {
title={tSetting('addCustomModelTitle')}
fields={customModelDialogFields}
existingNames={models.map((m) => m.name)}
providerFeatureKeys={providerFeatureKeys}
onSubmit={async (item) => {
await handleAddCustom(item);
setDialogOpen(false);
@@ -439,6 +448,7 @@ export function ModelsSection(props: ModelsSectionProps) {
existingNames={models
.filter((m) => m.name !== editingModel?.name)
.map((m) => m.name)}
providerFeatureKeys={providerFeatureKeys}
defaultValues={editDefaultValues}
loading={editLoading}
onSubmit={async (item) => {

View File

@@ -47,7 +47,6 @@ import {
ProviderInstanceCardProps,
ProviderInstanceCardRef,
} from './interface';
import { SoMarkInstanceCard } from './somark-instance-card';
/**
* One inline provider-instance card. The provider name + doc-link arrow
@@ -284,18 +283,11 @@ export const ProviderInstanceCard = forwardRef<
// 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} ref={ref} />;
}
if (props.providerName === 'SoMark') {
return <SoMarkInstanceCard {...props} ref={ref} />;
}
return <GenericProviderInstanceCard {...props} ref={ref} />;
});

View File

@@ -1,722 +0,0 @@
/*
* 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 {
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 {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
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 {
ProviderInstanceCardProps,
ProviderInstanceCardRef,
} from './interface';
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 }));
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;
onDelete?: () => void;
defaultOpen?: boolean;
}
/**
* Inline instance card for SoMark. 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.
*
* All fields are editable from the start (no name-first lock); the
* parent page's top Save button drives persistence through the
* imperative ref API.
*
* 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 const SoMarkInstanceCard = forwardRef<
ProviderInstanceCardRef,
SoMarkInstanceCardProps
>(function SoMarkInstanceCard(
{ providerName, instance, isDraft = false, onDelete, defaultOpen = false },
ref,
) {
const { t } = useTranslation();
const { t: tSetting } = useTranslate('setting');
const [open, setOpen] = useState(isDraft || defaultOpen);
const [draftName, setDraftName] = useState('');
useEffect(() => {
if (isDraft) {
setDraftName('');
}
}, [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.id,
);
// 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);
// oxlint-disable-next-line react/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 { 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,
]);
// ── Dirty tracking (no auto-save) ────────────────────────────────
// Baseline signature mirrors the persisted state so `getSavePayload`
// can skip redundant saves. For drafts the baseline stays empty
// (drafts are always dirty once a name is typed).
const baselinePayloadRef = useRef<string>('');
const draftNameRef = useRef(draftName);
useEffect(() => {
draftNameRef.current = draftName;
});
useEffect(() => {
if (isDraft) {
baselinePayloadRef.current = '';
return;
}
if (!instanceDetails && !instance.id) return;
const baseline = buildPayload(initialValues, instance.instance_name);
const finalBaseline = {
...baseline,
id: instanceDetails?.id || instance.id,
};
baselinePayloadRef.current = JSON.stringify(finalBaseline);
}, [
isDraft,
initialValues,
buildPayload,
instance.instance_name,
instance.id,
instanceDetails,
]);
const getSavePayload = useCallback(() => {
const trimmed = draftNameRef.current.trim();
if (isDraft) {
if (!trimmed) return null;
const values = form.getValues();
const payload = buildPayload(values, trimmed);
return {
payload,
instanceName: trimmed,
isDraft: true,
// SoMark drafts use the add endpoint (no id).
apiKind: 'add' as const,
};
}
const values = form.getValues();
if (!form.formState.isDirty) return null;
const payload = buildPayload(values, instance.instance_name);
const finalPayload = {
...payload,
id: instanceDetails?.id || instance.id,
};
const sig = JSON.stringify(finalPayload);
if (sig === baselinePayloadRef.current) return null;
return {
payload: finalPayload,
instanceName: instance.instance_name,
isDraft: false,
// SoMark saved cards update via `addProviderInstance` with an `id`
// (matches the legacy auto-save behaviour).
apiKind: 'add' as const,
};
}, [
isDraft,
form,
buildPayload,
instance.instance_name,
instance.id,
instanceDetails,
]);
const markSaved = useCallback(() => {
const result = getSavePayload();
if (result) {
baselinePayloadRef.current = JSON.stringify(result.payload);
}
}, [getSavePayload]);
useImperativeHandle(
ref,
() => ({
validate: async () => {
if (isDraft && !draftNameRef.current.trim()) return false;
const isValid = await form.trigger();
return !!isValid;
},
getSavePayload,
markSaved,
}),
[isDraft, form, getSavePayload, markSaved],
);
// ──────────────── 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'}`}
>
{isDraft ? (
<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')}
className="flex-1"
data-testid="instance-name-input"
/>
<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>
</div>
{renderFields()}
</div>
) : (
<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">
{renderFields()}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
);
});
export default SoMarkInstanceCard;
// Ensure the component is usable with the same props shape as the
// generic card (keeps the dispatch in provider-instance-card.tsx happy
// when forwarding props + ref).
export type { ProviderInstanceCardProps };

View File

@@ -14,13 +14,14 @@
* limitations under the License.
*/
import { LLMFactory } from '@/constants/llm';
import { useTranslate } from '@/hooks/common-hooks';
import { useMemo } from 'react';
import type { AddCustomModelDialogFields } from './add-custom-model-dialog';
/**
* Single source of truth for the custom-model dialog schema. Mirrors
* the shape of `IProviderModelItem` 1:1 adding a new property to the
* the shape of `IProviderModelItem` 1:1 - adding a new property to the
* interface means adding an entry here, and the dialog auto-adapts.
*
* `label` and each option's `label` are i18n keys (under the `setting`
@@ -69,22 +70,136 @@ export const MODEL_FIELD_SCHEMA: AddCustomModelDialogFields[] = [
];
/**
* Dialog field schema for adding a custom model. Returns
* `MODEL_FIELD_SCHEMA` with i18n keys resolved.
* SoMark-specific element-format selects appended to the model dialog
* schema when the provider is SoMark. These fields are stored per-model
* in `model_info[].extra`.
*
* `label` values are i18n keys under `setting.somark.*`. Option labels
* use `somark.formatOptions.*` i18n keys.
*/
export const useCustomModelFields = (): AddCustomModelDialogFields[] => {
export const SOMARK_EXTRA_FIELDS: AddCustomModelDialogFields[] = [
{
name: 'elementFormats',
label: 'somark.sectionElementFormats',
type: 'section',
},
{
name: 'somark_image_format',
label: 'somark.imageFormat',
type: 'select',
defaultValue: 'url',
options: [
{ value: 'url', label: 'somark.formatOptions.url' },
{ value: 'base64', label: 'somark.formatOptions.base64' },
{ value: 'none', label: 'somark.formatOptions.none' },
],
},
{
name: 'somark_formula_format',
label: 'somark.formulaFormat',
type: 'select',
defaultValue: 'latex',
options: [
{ value: 'latex', label: 'somark.formatOptions.latex' },
{ value: 'mathml', label: 'somark.formatOptions.mathml' },
{ value: 'ascii', label: 'somark.formatOptions.ascii' },
],
},
{
name: 'somark_table_format',
label: 'somark.tableFormat',
type: 'select',
defaultValue: 'html',
options: [
{ value: 'html', label: 'somark.formatOptions.html' },
{ value: 'markdown', label: 'somark.formatOptions.markdown' },
{ value: 'image', label: 'somark.formatOptions.image' },
],
},
{
name: 'somark_cs_format',
label: 'somark.csFormat',
type: 'select',
defaultValue: 'image',
options: [{ value: 'image', label: 'somark.formatOptions.image' }],
},
];
/**
* SoMark feature-config options appended to the `features` switch-group
* when the provider is SoMark. Selected entries (prefixed `somark_`) are
* converted to boolean values in `model_info[].extra` by the dialog's
* `handleSubmit`, so the data shape stays compatible with the legacy
* `SoMarkInstanceCard` payload.
*/
export const SOMARK_FEATURE_OPTIONS: { value: string; label: string }[] = [
{ value: 'somark_enable_text_cross_page', label: 'somark.enableTextCrossPage' },
{ value: 'somark_enable_table_cross_page', label: 'somark.enableTableCrossPage' },
{
value: 'somark_enable_title_level_recognition',
label: 'somark.enableTitleLevelRecognition',
},
{ value: 'somark_enable_inline_image', label: 'somark.enableInlineImage' },
{ value: 'somark_enable_table_image', label: 'somark.enableTableImage' },
{
value: 'somark_enable_image_understanding',
label: 'somark.enableImageUnderstanding',
},
{ value: 'somark_keep_header_footer', label: 'somark.keepHeaderFooter' },
];
/**
* Default features pre-selected for SoMark models. Mirrors the defaults
* from the legacy `SoMarkInstanceCard` (table_image and image_understanding
* default to `true`).
*/
export const SOMARK_DEFAULT_FEATURES = [
'somark_enable_table_image',
'somark_enable_image_understanding',
];
/**
* Dialog field schema for adding a custom model. Returns
* `MODEL_FIELD_SCHEMA` with i18n keys resolved. When `providerName`
* is `SoMark`:
* - Appends `SOMARK_EXTRA_FIELDS` (element-format selects) after the
* base schema.
* - Replaces the `features` switch-group options with SoMark-only
* feature toggles (no "Tool Call") and pre-selects the defaults.
*/
export const useCustomModelFields = (
providerName?: string,
): AddCustomModelDialogFields[] => {
const { t } = useTranslate('setting');
return useMemo<AddCustomModelDialogFields[]>(
() =>
MODEL_FIELD_SCHEMA.map((field) => ({
...field,
label: t(field.label),
options: field.options?.map((opt) => ({
value: opt.value,
label: t(opt.label),
})),
})),
[t],
() => {
const isSoMark = providerName === LLMFactory.SoMark;
const schema = isSoMark
? [...MODEL_FIELD_SCHEMA, ...SOMARK_EXTRA_FIELDS]
: MODEL_FIELD_SCHEMA;
return schema.map((field) => {
const mapped = {
...field,
label: t(field.label),
options: field.options?.map((opt) => ({
value: opt.value,
label: t(opt.label),
})),
};
// For SoMark, replace the features switch-group options with
// SoMark-only feature toggles (no "Tool Call") and pre-select
// the default features.
if (isSoMark && field.name === 'features') {
mapped.options = SOMARK_FEATURE_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.label),
}));
mapped.defaultValue = [...SOMARK_DEFAULT_FEATURES];
}
return mapped;
});
},
[t, providerName],
);
};

View File

@@ -46,6 +46,7 @@ export const LIST_MODEL_PROVIDERS = new Set<string>([
LLMFactory.BaiduYiYan,
LLMFactory.NewAPI,
LLMFactory.RAGcon,
LLMFactory.SoMark,
// LLMFactory.HuggingFace,
// LLMFactory.GoogleCloud,

View File

@@ -702,4 +702,50 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
};
},
},
// ============ SoMark ============
[LLMFactory.SoMark]: {
llmFactory: LLMFactory.SoMark,
title: 'SoMark',
fields: [
{
name: 'instance_name',
label: 'instanceName',
type: FormFieldType.Text,
required: true,
placeholder: 'instanceNameMessage',
tooltip: 'instanceNameTip',
validation: { message: 'instanceNameMessage' },
},
{
name: 'base_url',
label: 'somark.baseUrl',
type: 'inputSelect',
required: true,
placeholder: 'somark.baseUrlPlaceholder',
shouldRender: 'hideWhenInstanceExists',
validation: { message: 'somark.baseUrlMessage' },
},
{
name: 'api_key',
label: 'somark.apiKey',
type: FormFieldType.Password,
required: false,
placeholder: 'somark.apiKeyPlaceholder',
shouldRender: 'hideWhenInstanceExists',
},
],
verifyTransform: (values) => ({
apiKey: values.api_key ?? '',
baseUrl: values.base_url,
modelInfo: [],
}),
submitTransform: (values) => ({
instance_name: values.instance_name,
llm_factory: LLMFactory.SoMark,
api_key: values.api_key ?? '',
base_url: values.base_url,
model_info: [],
}),
},
};