mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 13:33:48 +08:00
refactor(setting-model): migrate SoMark to generic provider card withmodel-level extra fields (#17582)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '返回表中图',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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} />;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export const LIST_MODEL_PROVIDERS = new Set<string>([
|
||||
LLMFactory.BaiduYiYan,
|
||||
LLMFactory.NewAPI,
|
||||
LLMFactory.RAGcon,
|
||||
LLMFactory.SoMark,
|
||||
|
||||
// LLMFactory.HuggingFace,
|
||||
// LLMFactory.GoogleCloud,
|
||||
|
||||
@@ -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: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user