fix(model-provider): replace per-card auto-save with batch Save-all (#17025)

This commit is contained in:
chanx
2026-07-17 14:26:49 +08:00
committed by GitHub
parent 6b3a350a57
commit c5cf9b473d
14 changed files with 1345 additions and 1302 deletions

View File

@@ -1809,6 +1809,7 @@ Example: Virtual Hosted Style`,
addInstance: 'Add instance',
addInstanceText: 'Add instance',
noInstancesConfigured: 'No instances configured yet.',
saveAll: 'Save all',
editInstanceName: 'Edit instance name',
models: 'Models',
chatModel: 'LLM',
@@ -1837,11 +1838,6 @@ Example: Virtual Hosted Style`,
instanceNameTip:
'A unique name to identify this provider instance under the same factory.',
instanceNamePlaceholder: 'Please input instance name',
instanceNameSaveTip:
'Enter an instance name and save it. Once saved, it cannot be changed.',
instanceNameSavePrompt:
'Please save the instance name first before editing other fields.',
instanceNameLockedHint: 'Instance name is locked',
deleteInstance: 'Delete instance',
modelName: 'Model name',
modelID: 'Model ID',

View File

@@ -1500,6 +1500,7 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
addInstance: '添加实例',
addInstanceText: '添加实例',
noInstancesConfigured: '尚未配置任何实例。',
saveAll: '保存',
editInstanceName: '编辑实例名称',
models: '模型',
chatModel: 'LLM',
@@ -1527,9 +1528,6 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
instanceNameMessage: '请输入实例名称!',
instanceNameTip: '用于在同一厂商下唯一标识该实例的名称。',
instanceNamePlaceholder: '请输入实例名称',
instanceNameSaveTip: '请输入实例名称并保存。保存后不可修改。',
instanceNameSavePrompt: '请先保存实例名称,再编辑其他字段。',
instanceNameLockedHint: '实例名称已锁定',
deleteInstance: '删除实例',
modelName: '模型名称',
modelID: '模型ID',

View File

@@ -21,11 +21,13 @@ import {
useAddProviderInstance,
useFetchAddedProviders,
useFetchProviderInstances,
useUpdateProviderInstance,
} from '@/hooks/use-llm-request';
import { IProviderInstance } from '@/interfaces/database/llm';
import { useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ProviderInstanceCardRef } from './instance-card/interface';
import { ProviderInstanceCard } from './instance-card/provider-instance-card';
import { ProviderHeaderBar } from './layout/provider-header-bar';
import { Sidebar, SidebarSelection } from './layout/sidebar';
@@ -38,11 +40,17 @@ import SystemSetting from './layout/system-setting';
* - Left: `Sidebar` (Default-models entry, search, provider list).
* - Right:
* * 'default' selection -> `SystemSetting`.
* * provider selection -> a sticky `ProviderHeaderBar` at the top,
* a vertical stack of `ProviderInstanceCard` in the middle, and
* a sticky "+ Instance" button at the bottom. Each click of
* that button adds a new draft card; multiple drafts can coexist
* and be saved / cancelled independently.
* * provider selection -> a sticky `ProviderHeaderBar` at the top
* (with a batch Save button), a vertical stack of
* `ProviderInstanceCard` in the middle, and a sticky "+ Instance"
* button at the bottom. Each click of that button adds a new
* draft card; multiple drafts can coexist.
*
* 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
* saved cards, `updateProviderInstance` for generic saved cards.
*
* Special-case providers (handled inside `ProviderInstanceCard`):
* - `Bedrock`: rendered inline via `BedrockInstanceCard`.
@@ -52,20 +60,43 @@ const SettingModelV2: FC = () => {
const { t: tSetting } = useTranslate('setting');
const [selection, setSelection] = useState<SidebarSelection>('default');
// Stack of draft-instance identifiers, rendered as `ProviderInstanceCard`
// entries below the persisted instances. Each draft can be saved or
// cancelled independently; saving removes the draft from this list and
// triggers a refetch that surfaces the newly-saved instance above.
// entries below the persisted instances. Each draft can be cancelled
// independently; saving is driven by the top Save button.
const [draftIds, setDraftIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
// Tracks the instance name that was just persisted by the top Save
// button. The corresponding saved card mounts expanded so the user
// can immediately see (and edit) what was just saved. Reset on every
// selection change so it does not bleed across providers.
const [newlySavedInstanceName, setNewlySavedInstanceName] = useState<
string | null
>(null);
// Monotonic counter so each draft card has a stable, unique React key.
const draftIdCounterRef = useRef(0);
// Tracks whether the user explicitly cancelled the auto-shown draft
// for the current selection. Reset on every selection change.
const cancelledRef = useRef(false);
// Imperative refs to every visible card, keyed by the card's React key
// (instance name for saved cards, draft id for drafts). Used by the
// top Save button to validate + collect payloads in a single batch.
const cardRefs = useRef<Map<string, ProviderInstanceCardRef | null>>(
new Map(),
);
const setCardRef = useCallback(
(id: string) => (ref: ProviderInstanceCardRef | null) => {
if (ref) {
cardRefs.current.set(id, ref);
} else {
cardRefs.current.delete(id);
}
},
[],
);
const { data: addedProviders } = useFetchAddedProviders();
const providerQueryName = useMemo(() => {
if (selection === 'default') return '';
@@ -84,16 +115,18 @@ const SettingModelV2: FC = () => {
setDraftIds((prev) => [...prev, id]);
}, []);
// Remove a draft id from the visible list (called on save / cancel).
// Remove a draft id from the visible list (called on cancel).
const removeDraft = useCallback((id: string) => {
setDraftIds((prev) => prev.filter((d) => d !== id));
}, []);
// When the selection changes, clear the cancelled flag and drop any
// in-flight drafts so the user starts fresh on the new provider.
// When the selection changes, clear the cancelled flag, drop any
// in-flight drafts, and reset the ref registry so stale refs from
// the previous provider don't leak into the next save batch.
useEffect(() => {
cancelledRef.current = false;
setDraftIds([]);
cardRefs.current.clear();
setNewlySavedInstanceName(null);
}, [selection]);
@@ -118,60 +151,106 @@ const SettingModelV2: FC = () => {
}, [selection, instances, instancesLoading, draftIds, addDraft]);
const { addProviderInstance } = useAddProviderInstance();
const { updateProviderInstance } = useUpdateProviderInstance();
// Save handler for a draft card. Calls `addProviderInstance` with the
// values supplied by the draft form (instance_name, api_key, base_url,
// model_info...). After a successful save, removes the draft from the
// list and invalidates the instance query so the new card appears in
// the persisted list automatically.
const handleDraftSave = useCallback(
async (id: string, values: Record<string, any>) => {
const ret = await addProviderInstance({
llm_factory: selection as string,
instance_name: values.instance_name,
api_key: values.api_key,
base_url: values.base_url ?? values.api_base,
model_info: values.model_info,
} as any);
if (ret?.code === 0) {
// Mark this selection as "user-engaged" so the auto-show effect
// below does not spawn another draft while the providerInstances
// refetch is still in flight (during that window both `instances`
// and `draftIds` are empty and would otherwise re-trigger
// `addDraft()`).
cancelledRef.current = true;
setNewlySavedInstanceName(values.instance_name);
removeDraft(id);
queryClient.invalidateQueries({
queryKey: LlmKeys.providerInstances(providerQueryName),
});
// Batch save handler, wired to the top Save button.
//
// Flow:
// 1. Collect every card ref.
// 2. Ask each for its save payload. Drafts always return one
// (provided the name is non-empty); saved cards return `null`
// when not dirty so we skip the redundant API call.
// 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`.
// 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.
const handleSaveAll = useCallback(async () => {
const refs = Array.from(cardRefs.current.values()).filter(
(r): r is ProviderInstanceCardRef => r !== null,
);
if (refs.length === 0) return;
// 1. Validate every card up front. Block all saves if any is invalid.
const validations = await Promise.all(
refs.map(async (r) => ({ ref: r, valid: await r.validate() })),
);
if (validations.some((v) => !v.valid)) {
return;
}
// 2. Collect dirty payloads (null = nothing to save for this card).
const entries = validations
.map((v) => ({ ref: v.ref, payload: v.ref.getSavePayload() }))
.filter((e) => e.payload !== null);
if (entries.length === 0) return;
setSaving(true);
// Pin the auto-show guard so a draft isn't re-spawned while the
// providerInstances refetch is in flight (during that window both
// `instances` and `draftIds` are empty and would otherwise re-trigger
// `addDraft()`).
cancelledRef.current = true;
try {
// 3. Dispatch one API call per dirty card. Sequential so any
// backend error stops the batch and the user can retry the
// remaining cards after fixing the issue.
for (const { ref, payload } of entries) {
if (!payload) continue;
if (payload.apiKind === 'add') {
const ret = await addProviderInstance(payload.payload as any);
if (ret?.code !== 0) {
// Stop on the first failure so the user can see the error.
return;
}
// Remember the just-saved name so the persisted card mounts
// expanded once it surfaces via the invalidated instances
// query below.
if (payload.isDraft) {
setNewlySavedInstanceName(payload.instanceName);
}
} else {
const ret = await updateProviderInstance(payload.payload as any);
if (ret?.code !== 0) {
return;
}
// Saved card: update its dirty baseline so the next save
// short-circuits. Drafts are removed below so they don't
// need this.
ref.markSaved();
}
}
},
[
addProviderInstance,
selection,
queryClient,
providerQueryName,
removeDraft,
],
);
// 4. Clear drafts (all valid drafts were just persisted) and
// invalidate the instance query so the newly-saved cards
// appear in the persisted list.
setDraftIds([]);
queryClient.invalidateQueries({
queryKey: LlmKeys.providerInstances(providerQueryName),
});
} finally {
setSaving(false);
}
}, [
addProviderInstance,
updateProviderInstance,
queryClient,
providerQueryName,
]);
// The instance name has just been persisted (via the dedicated
// "Save name" button inside the draft card). Remove the draft and
// pin the auto-show guard so a new placeholder draft is not
// auto-injected during the brief window between draft removal and
// the providerInstances refetch landing. Remember the saved name so
// the persisted card mounts expanded.
const handleNameSaved = useCallback(
(id: string, instanceName: string) => {
cancelledRef.current = true;
setNewlySavedInstanceName(instanceName);
removeDraft(id);
},
[removeDraft],
);
// Whether the Save button should be enabled. We avoid an O(n) ref
// scan on every render by treating "has any draft OR any saved
// instance" as a conservative proxy - if there is nothing on screen
// there is nothing to save, and if there is something the user can
// always attempt a save (dirty saved cards short-circuit inside
// `getSavePayload`). The button is disabled while a save is in flight.
const canSave = !saving && (draftIds.length > 0 || instances.length > 0);
// User clicked Cancel on a specific draft remove it from the list
// User clicked Cancel on a specific draft - remove it from the list
// and stop the auto-show effect from re-opening it for the current
// empty-instance selection.
const handleDraftCancel = useCallback(
@@ -200,8 +279,13 @@ const SettingModelV2: FC = () => {
</div>
) : (
<>
{/* Sticky top: provider name + doc-link arrow */}
<ProviderHeaderBar providerName={selection as string} />
{/* Sticky top: provider name + doc-link arrow + batch Save */}
<ProviderHeaderBar
providerName={selection as string}
onSave={handleSaveAll}
saving={saving}
canSave={canSave}
/>
{/* Scrollable middle: instance cards + optional draft cards */}
<div className="flex-1 overflow-auto scrollbar-auto p-4 flex flex-col gap-4">
@@ -213,6 +297,7 @@ const SettingModelV2: FC = () => {
{instances.map((instance, index) => (
<ProviderInstanceCard
key={instance.instance_name}
ref={setCardRef(instance.instance_name)}
providerName={selection as string}
instance={instance}
defaultOpen={
@@ -224,17 +309,14 @@ const SettingModelV2: FC = () => {
{draftIds.map((id) => (
<ProviderInstanceCard
key={id}
ref={setCardRef(id)}
providerName={selection as string}
instance={draftInstance}
isDraft
onDelete={() => handleDraftCancel(id)}
onNameSaved={(instanceName) =>
handleNameSaved(id, instanceName)
}
onSaved={(values) => handleDraftSave(id, values)}
/>
))}
<div className=" bottom-0 z-10 border-border-button py-4">
<div className="z-10 border-border-button py-4">
<button
type="button"
className="w-full flex items-center justify-center gap-2 px-3 py-1 rounded-md border border-dashed border-border-button text-text-secondary hover:bg-bg-input hover:text-text-primary transition-colors"

View File

@@ -30,7 +30,6 @@ import { Segmented } from '@/components/ui/segmented';
import { useTranslate } from '@/hooks/common-hooks';
import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options';
import {
useAddProviderInstance,
useDeleteProviderInstance,
useFetchProviderInstance,
useVerifyProviderConnection,
@@ -39,13 +38,25 @@ import { IProviderInstance } from '@/interfaces/database/llm';
import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm';
import { zodResolver } from '@hookform/resolvers/zod';
import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { BedrockRegionList } from '../constants';
import { VerifyResult } from '../hooks';
import { splitProviderPayload } from '../payload-utils';
import {
ProviderInstanceCardProps,
ProviderInstanceCardRef,
} from './interface';
import { ModelsSection } from './models-section';
import VerifyButton from './verify-button';
@@ -62,61 +73,38 @@ type BedrockFormValues = {
model_type: ('chat' | 'embedding')[];
};
// Field names whose value commits via click (Segmented, Select,
// MultiSelect) rather than blur. Their popovers render in Radix
// portals outside the card's blur container, so blur-driven saves
// don't catch them — a form.watch watcher is used instead.
const BEDROCK_WATCHED_FIELDS = new Set([
'auth_mode',
'bedrock_region',
'model_type',
]);
interface BedrockInstanceCardProps {
providerName: string;
instance: IProviderInstance;
isDraft?: boolean;
onSaved?: (values: Record<string, any>) => void | Promise<void>;
onNameSaved?: (instanceName: string) => void;
onDelete?: () => void;
/**
* When true, this card starts expanded and fetches its instance
* details on mount. Default `false` so non-first cards stay
* collapsed until the user opens them.
*/
defaultOpen?: boolean;
}
/**
* Inline instance card for AWS Bedrock. Mirrors the two-stage UX of
* `ProviderInstanceCard` (save name first, then edit fields) but renders
* Bedrock-specific fields (auth_mode segmented, ak/sk/arn, region, model
* name, max tokens, model_type) directly instead of going through the
* generic DynamicForm path.
* Inline instance card for AWS Bedrock. Renders Bedrock-specific fields
* (auth_mode segmented, ak/sk/arn, region, model name, max tokens,
* model_type) directly instead of going through the generic DynamicForm
* path. 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.
*/
export function BedrockInstanceCard({
providerName,
instance,
isDraft = false,
onSaved,
onNameSaved,
onDelete,
defaultOpen = false,
}: BedrockInstanceCardProps) {
export const BedrockInstanceCard = forwardRef<
ProviderInstanceCardRef,
BedrockInstanceCardProps
>(function BedrockInstanceCard(
{ providerName, instance, isDraft = false, onDelete, defaultOpen = false },
ref,
) {
const { t } = useTranslation();
const { t: tSetting } = useTranslate('setting');
const { buildModelTypeOptions } = useBuildModelTypeOptions();
const [open, setOpen] = useState(isDraft || defaultOpen);
const [draftName, setDraftName] = useState('');
const [nameSaved, setNameSaved] = useState(!isDraft);
const savingRef = useRef(false);
useEffect(() => {
if (isDraft) {
setDraftName('');
setNameSaved(false);
} else {
setNameSaved(true);
}
}, [providerName, isDraft]);
@@ -183,9 +171,8 @@ export function BedrockInstanceCard({
);
// Lazily fetch full instance details only when the card is open.
// Mirrors the generic ProviderInstanceCard: collapsed cards never
// hit /providers/<name>/instances/<instance_name>; expanding one
// triggers a fresh refetch.
// Collapsed cards never hit /providers/<name>/instances/<instance_name>;
// expanding one triggers a fresh refetch.
useEffect(() => {
if (!isDraft && open && providerName && instance.instance_name) {
refetchInstanceDetails();
@@ -314,168 +301,6 @@ export function BedrockInstanceCard({
],
);
const { addProviderInstance } = useAddProviderInstance();
const handleSaveName = useCallback(async () => {
const trimmed = draftName.trim();
if (!trimmed) return;
const ret = await addProviderInstance({
llm_factory: providerName,
instance_name: trimmed,
} as any);
if (ret?.code === 0) {
onNameSaved?.(trimmed);
}
}, [draftName, addProviderInstance, providerName, onNameSaved]);
// Auto-save in draft mode after the name is locked. Debounced on form
// value changes; refuses to fire until validation passes.
useEffect(() => {
if (!isDraft) return;
if (!nameSaved) return;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const sub = form.watch(() => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (cancelled || savingRef.current) return;
const isValid = await form.trigger();
if (cancelled || savingRef.current) return;
if (!isValid) return;
const trimmed = draftName.trim();
if (!trimmed) return;
savingRef.current = true;
try {
const values = form.getValues();
const payload = buildPayload(values, trimmed);
await onSaved?.(payload as unknown as Record<string, any>);
} finally {
savingRef.current = false;
}
}, 200);
});
return () => {
cancelled = true;
if (saveTimeout) clearTimeout(saveTimeout);
try {
sub?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, nameSaved, form, draftName, buildPayload, onSaved]);
// Saved-mode auto-save. Both blur-driven (text inputs) and
// change-driven (Segmented / Select / MultiSelect) edits are
// coalesced through a shared debounced `scheduleSave`. Selects render
// in Radix portals outside the card's blur container, so blur-driven
// saves don't catch them — a form.watch watcher is used instead.
const blurSavingRef = useRef(false);
// Flipped to true while a child (e.g. ModelsSection's
// AddCustomModelDialog) opens a Portal-based dialog. Suppresses the
// spurious blur-save fired when focus moves into the Portal.
const blurSuppressRef = useRef(false);
const lastSavedSigRef = useRef('');
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const AUTO_SAVE_DEBOUNCE_MS = 500;
const performSave = useCallback(async () => {
if (isDraft) return;
if (blurSavingRef.current) return;
if (blurSuppressRef.current) return;
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
const payload = buildPayload(values, instance.instance_name);
const finalPayload = {
...payload,
id: instanceDetails?.id || instance.id,
};
const sig = JSON.stringify(finalPayload);
if (sig === lastSavedSigRef.current) return;
blurSavingRef.current = true;
try {
const ret = await addProviderInstance(finalPayload as any);
if (ret?.code === 0) {
lastSavedSigRef.current = sig;
}
} finally {
blurSavingRef.current = false;
}
}, [
isDraft,
form,
buildPayload,
instance.instance_name,
instance.id,
instanceDetails?.id,
addProviderInstance,
]);
const scheduleSave = useCallback(() => {
if (isDraft) return;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
void performSave();
}, AUTO_SAVE_DEBOUNCE_MS);
}, [isDraft, performSave]);
const handleFieldsBlur = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (isDraft) return;
if (
e.currentTarget.contains(e.relatedTarget as Node | null) &&
e.relatedTarget !== null
) {
return;
}
scheduleSave();
},
[isDraft, scheduleSave],
);
// Segmented / Select / MultiSelect change-driven save (saved mode
// only). These commit via click and their popovers render in portals,
// so blur-driven saves don't catch them. Watch the form directly.
// Only react to user-driven changes (type === 'change'); ignore
// programmatic resets (form.reset when instanceDetails loads).
useEffect(() => {
if (isDraft) return;
if (!instanceDetails) return;
let cancelled = false;
const subscription = form.watch(
(_values: any, meta: { name?: string; type?: string }) => {
if (cancelled) return;
if (meta?.type !== 'change') return;
if (!meta?.name || !BEDROCK_WATCHED_FIELDS.has(meta.name)) return;
scheduleSave();
},
);
return () => {
cancelled = true;
try {
subscription?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, instanceDetails, form, scheduleSave]);
// Clear pending save on unmount.
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, []);
const { deleteProviderInstance } = useDeleteProviderInstance();
const handleDelete = useCallback(async () => {
if (isDraft) {
@@ -494,6 +319,98 @@ export function BedrockInstanceCard({
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 baselineValues = initialValues;
const baseline = buildPayload(baselineValues, 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,
// Bedrock drafts use the add endpoint (no id).
apiKind: 'add' as const,
};
}
const values = form.getValues();
const payload = buildPayload(values, instance.instance_name);
const finalPayload = {
...payload,
id: instanceDetails?.id || instance.id,
};
const sig = JSON.stringify(finalPayload);
if (sig === baselinePayloadRef.current) return null;
return {
payload: finalPayload,
instanceName: instance.instance_name,
isDraft: false,
// Bedrock 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}>
@@ -631,7 +548,56 @@ export function BedrockInstanceCard({
className="border-b border-border-button mb-5 pb-5"
data-testid={`instance-card-${instance.instance_name || 'draft'}`}
>
{nameSaved ? (
{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 className="pt-3">
<ModelsSection
providerName={providerName}
instanceName={instance.instance_name || '__draft__'}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => form.getValues()}
/>
</div>
</div>
) : (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-1 w-full mb-5">
@@ -677,10 +643,7 @@ export function BedrockInstanceCard({
forceMount
className="data-[state=closed]:hidden overflow-hidden"
>
<div
className="px-2 pb-4 flex flex-col gap-4"
onBlurCapture={handleFieldsBlur}
>
<div className="px-2 pb-4 flex flex-col gap-4">
{renderFields()}
<div className="pt-3">
@@ -691,93 +654,19 @@ export function BedrockInstanceCard({
hideActions={false}
hideIfEmpty={false}
getFormValues={() => form.getValues()}
onBlurSuppressChange={(s) => {
blurSuppressRef.current = s;
}}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<div className="px-2 py-3 flex flex-col gap-4">
<div
className="flex flex-col gap-1.5"
data-testid="instance-name-section"
>
<label
htmlFor="instance-name-input"
className="text-sm font-medium text-text-primary"
>
<span className="text-destructive mr-0.5">*</span>
{tSetting('instanceName')}
</label>
<div className="flex items-center">
<Input
id="instance-name-input"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={tSetting('instanceNamePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveName();
}
}}
className="flex-1 rounded-r-none"
data-testid="instance-name-input"
/>
<Button
onClick={handleSaveName}
disabled={!draftName.trim()}
data-testid="instance-name-save"
variant="outline"
className="rounded-l-none bg-bg-input shrink-0"
>
{tSetting('save')}
</Button>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
className="ml-2 shrink-0"
aria-label={tSetting('deleteInstance')}
data-testid="draft-delete"
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
<p
className="text-xs text-text-secondary"
data-testid="instance-name-helper"
>
{tSetting('instanceNameSaveTip')}
</p>
</div>
<fieldset
disabled={!nameSaved}
className="contents disabled:[&_*]:pointer-events-none disabled:opacity-60"
data-testid="instance-locked-fields"
>
{renderFields()}
<div className="pt-3">
<ModelsSection
providerName={providerName}
instanceName={instance.instance_name || '__draft__'}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => form.getValues()}
/>
</div>
</fieldset>
</div>
)}
</div>
);
}
});
export default BedrockInstanceCard;
// 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

@@ -24,12 +24,12 @@ import { InstanceNameSection } from './instance-name-section';
/**
* The draft (unsaved) variant of the provider instance card.
*
* Renders the instance name input section at the top, followed by a
* `<fieldset disabled>` that wraps the form fields, verify button, and
* per-instance models section. Fields are visually locked (and
* pointer-events disabled) until the user saves the instance name —
* after which the parent removes this draft and replaces it with a
* saved card.
* Renders the instance name input section at the top (without a Save
* button - the parent drives save through the imperative ref API),
* followed by the form fields, verify button, and per-instance models
* section. All fields are editable from the start; there is no
* fieldset lock - the user can fill in everything and submit via the
* top-of-page Save button.
*/
export function DraftModeCard({
formFields,
@@ -37,7 +37,6 @@ export function DraftModeCard({
formRef,
handleVerify,
handleDelete,
handleSaveName,
handleInstanceModelsEdited,
providerName,
instanceName,
@@ -51,47 +50,40 @@ export function DraftModeCard({
<InstanceNameSection
draftName={draftName}
setDraftName={setDraftName}
handleSaveName={handleSaveName}
handleDelete={handleDelete}
/>
<fieldset
disabled
className="contents disabled:[&_*]:pointer-events-none disabled:opacity-60"
data-testid="instance-locked-fields"
>
<DynamicForm.Root
key={`${providerName}-${instanceName}-true`}
ref={formRef as RefObject<DynamicFormRef>}
fields={formFields}
onSubmit={() => undefined}
defaultValues={formDefaultValues}
labelClassName="font-normal"
<DynamicForm.Root
key={`${providerName}-${instanceName}-true`}
ref={formRef as RefObject<DynamicFormRef>}
fields={formFields}
onSubmit={() => undefined}
defaultValues={formDefaultValues}
labelClassName="font-normal"
/>
<div className="pt-3">
<VerifyButton
onVerify={handleVerify}
isAbsolute={false}
formRef={formRef}
/>
</div>
<div className=" pt-3">
<VerifyButton
onVerify={handleVerify}
isAbsolute={false}
formRef={formRef}
/>
</div>
<div className=" pt-3">
<ModelsSection
providerName={providerName}
instanceName={instanceName || DRAFT_INSTANCE_SENTINEL}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => formRef.current?.getValues?.() ?? {}}
onInstanceModelsChange={(info) => {
modelInfoRef.current = info;
}}
onInstanceModelsEdited={handleInstanceModelsEdited}
/>
</div>
</fieldset>
<div className="pt-3">
<ModelsSection
providerName={providerName}
instanceName={instanceName || DRAFT_INSTANCE_SENTINEL}
instance={instance}
hideActions={false}
hideIfEmpty={false}
getFormValues={() => formRef.current?.getValues?.() ?? {}}
onInstanceModelsChange={(info) => {
modelInfoRef.current = info;
}}
onInstanceModelsEdited={handleInstanceModelsEdited}
/>
</div>
</div>
);
}

View File

@@ -23,20 +23,19 @@ import { Trash2 } from 'lucide-react';
export interface InstanceNameSectionProps {
draftName: string;
setDraftName: (name: string) => void;
handleSaveName: () => Promise<void>;
handleDelete: () => Promise<void>;
}
/**
* The instance-name input section shown at the top of a draft (unsaved)
* card. The input itself carries the destructive red border (no wrapping
* red box) and is paired with a Save button + delete icon. The helper
* text below explains the workflow.
* card. The input carries the destructive red border and is paired with
* a delete icon. There is no inline Save button - the parent page's
* top Save button drives persistence through the imperative ref API.
* The helper text below explains the workflow.
*/
export function InstanceNameSection({
draftName,
setDraftName,
handleSaveName,
handleDelete,
}: InstanceNameSectionProps) {
const { t: tSetting } = useTranslate('setting');
@@ -56,26 +55,11 @@ export function InstanceNameSection({
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={tSetting('instanceNamePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleSaveName();
}
}}
// The input itself carries the red border (not a wrapping
// box). Persists while the name is unsaved.
className="flex-1 rounded-r-none"
// The input itself carries the red border. Persists while the
// name is unsaved.
className="flex-1"
data-testid="instance-name-input"
/>
<Button
onClick={() => void handleSaveName()}
disabled={!draftName.trim()}
data-testid="instance-name-save"
variant="outline"
className="rounded-l-none bg-bg-input shrink-0"
>
{tSetting('save')}
</Button>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
@@ -88,12 +72,6 @@ export function InstanceNameSection({
</Button>
</ConfirmDeleteDialog>
</div>
<p
className="text-xs text-text-secondary"
data-testid="instance-name-helper"
>
{tSetting('instanceNameSaveTip')}
</p>
</div>
);
}

View File

@@ -22,9 +22,16 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { useTranslate } from '@/hooks/common-hooks';
import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react';
import { RefObject } from 'react';
import {
KeyboardEvent,
useEffect,
useRef,
useState,
type RefObject,
} from 'react';
import { useTranslation } from 'react-i18next';
import { DRAFT_INSTANCE_SENTINEL, SavedModeCardProps } from '../interface';
import { ModelsSection } from '../models-section';
@@ -33,24 +40,28 @@ import VerifyButton from '../verify-button';
/**
* The saved (non-draft) variant of the provider instance card.
*
* Renders a Collapsible whose trigger shows the instance name + delete
* button, and whose content holds the form fields, the verify button,
* and (when expanded) the per-instance models section.
* Renders a Collapsible whose trigger shows a chevron toggle + the
* instance name (double-click to rename) + delete button, and whose
* content holds the form fields, the verify button, and (when
* expanded) the per-instance models section.
*
* Auto-save has been removed; the parent's top Save button drives all
* persistence through the imperative ref API.
*/
export function SavedModeCard({
formFields,
formDefaultValues,
formRef,
handleFieldsBlur,
handleVerify,
handleDelete,
handleInstanceModelsEdited,
providerName,
instanceName,
editedInstanceName,
onRename,
instance,
instanceDetailsLoaded,
modelInfoRef,
blurSuppressRef,
draftName,
open,
setOpen,
@@ -58,6 +69,62 @@ export function SavedModeCard({
const { t } = useTranslation();
const { t: tSetting } = useTranslate('setting');
// Inline rename state: when true, the name turns into an editable
// Input. The user double-clicks the name to enter rename mode,
// commits on Enter/blur, cancels on Escape.
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(editedInstanceName);
const renameInputRef = useRef<HTMLInputElement>(null);
// Sync the rename buffer when the displayed name changes externally
// (e.g. after a successful save + refetch).
useEffect(() => {
setRenameValue(editedInstanceName);
}, [editedInstanceName]);
// Focus + select-all when entering rename mode.
useEffect(() => {
if (renaming) {
const id = requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
return () => cancelAnimationFrame(id);
}
}, [renaming]);
const startRename = () => {
setRenameValue(editedInstanceName);
setRenaming(true);
};
const commitRename = () => {
setRenaming(false);
const trimmed = renameValue.trim();
if (trimmed && trimmed !== editedInstanceName) {
onRename(trimmed);
} else {
setRenameValue(editedInstanceName);
}
};
const cancelRename = () => {
setRenaming(false);
setRenameValue(editedInstanceName);
};
const handleRenameKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
};
const displayName = editedInstanceName || instanceName;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
@@ -80,12 +147,31 @@ export function SavedModeCard({
<ListChevronsUpDown className="size-4" />
)}
</Button>
<div
className="text-sm font-medium truncate overflow-hidden w-[calc(100%-40px)]"
data-testid="instance-name-static"
>
{draftName || instanceName}
</div>
{renaming ? (
<Input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="text-sm font-medium h-7"
data-testid="instance-name-rename-input"
/>
) : (
<div
className="text-sm font-medium truncate overflow-hidden flex-1 cursor-text"
onDoubleClick={(e) => {
e.stopPropagation();
startRename();
}}
title={tSetting('editInstanceName')}
data-testid="instance-name-static"
>
{draftName || displayName}
</div>
)}
</div>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
@@ -101,10 +187,7 @@ export function SavedModeCard({
</div>
</CollapsibleTrigger>
<CollapsibleContent forceMount className="data-[state=closed]:hidden">
<div
className="pb-4 flex flex-col gap-4"
onBlurCapture={handleFieldsBlur}
>
<div className="pb-4 flex flex-col gap-4">
<DynamicForm.Root
key={`${providerName}-${instanceName}-false-${instanceDetailsLoaded ? 'loaded' : 'pending'}`}
ref={formRef as RefObject<DynamicFormRef>}
@@ -114,7 +197,7 @@ export function SavedModeCard({
labelClassName="font-normal"
/>
<div className=" pt-3">
<div className="pt-3">
<VerifyButton
onVerify={handleVerify}
isAbsolute={false}
@@ -123,7 +206,7 @@ export function SavedModeCard({
</div>
{open && (
<div className=" pt-3">
<div className="pt-3">
<ModelsSection
providerName={providerName}
instanceName={instanceName || DRAFT_INSTANCE_SENTINEL}
@@ -132,9 +215,6 @@ export function SavedModeCard({
hideIfEmpty={false}
instanceDetailsLoaded={instanceDetailsLoaded}
getFormValues={() => formRef.current?.getValues?.() ?? {}}
onBlurSuppressChange={(s) => {
blurSuppressRef.current = s;
}}
onInstanceModelsChange={(info) => {
modelInfoRef.current = info;
}}

View File

@@ -14,34 +14,33 @@
* limitations under the License.
*/
import { DynamicFormRef, FormFieldType } from '@/components/dynamic-form';
import { DynamicFormRef } from '@/components/dynamic-form';
import {
useAddProviderInstance,
useDeleteProviderInstance,
useFetchAvailableProviders,
useFetchProviderInstance,
useUpdateProviderInstance,
useVerifyProviderConnection,
} from '@/hooks/use-llm-request';
import { IProviderInstance } from '@/interfaces/database/llm';
import {
IModelInfo,
IUpdateProviderInstanceRequestBody,
} from '@/interfaces/request/llm';
import { IModelInfo } from '@/interfaces/request/llm';
import { RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { useProviderFields } from '../provider-schema/hooks';
import { SelectOption } from '../provider-schema/types';
import { API_KEY_NESTED_FIELDS, ApiKeyNestedField } from './interface';
import {
API_KEY_NESTED_FIELDS,
ApiKeyNestedField,
InstanceSavePayload,
} from './interface';
// ---------------------------------------------------------------------------
// Pure helpers api_key shape normalization
// Pure helpers - api_key shape normalization
// ---------------------------------------------------------------------------
/**
* Build the `api_key` payload value from the flat form values. If any of
* the nested credential fields (see `API_KEY_NESTED_FIELDS`) carry a
* value, returns `{ api_key, ...nested }`; otherwise returns the plain
* api_key string. Used by both the auto-save payload and its change
* api_key string. Used by both the payload builder and its change
* signature baseline so the two stay byte-identical.
*/
export function buildApiKeyValue(
@@ -101,7 +100,7 @@ function pickDefaultUrl(
}
// ---------------------------------------------------------------------------
// useProviderBaseUrlOptions fetch provider catalog and build URL options
// useProviderBaseUrlOptions - fetch provider catalog and build URL options
// ---------------------------------------------------------------------------
/**
@@ -150,7 +149,7 @@ export function useProviderBaseUrlOptions(providerName: string) {
}
// ---------------------------------------------------------------------------
// useProviderInitialValues form initial values for draft vs saved
// useProviderInitialValues - form initial values for draft vs saved
// ---------------------------------------------------------------------------
/**
@@ -211,7 +210,7 @@ export function useProviderInitialValues(
// submit logic can echo it back.
if ((merged as any).region) values.region = (merged as any).region;
// Fallback: some backends may still surface these credential fields
// at the top level rather than nested in api_key echo any that
// at the top level rather than nested in api_key - echo any that
// weren't already lifted from the api_key object above.
for (const field of API_KEY_NESTED_FIELDS) {
if (values[field] === undefined && (merged as any)[field] !== undefined) {
@@ -223,7 +222,7 @@ export function useProviderInitialValues(
}
// ---------------------------------------------------------------------------
// useLazyInstanceDetails fetch full instance details when card opens
// useLazyInstanceDetails - fetch full instance details when card opens
// ---------------------------------------------------------------------------
/**
@@ -231,7 +230,7 @@ export function useProviderInitialValues(
* sensitive/heavy fields like `api_key` or `base_url`. Pull the full
* instance via `showProviderInstance` so the form can be pre-filled when
* the user clicks an existing provider on the left. The hook is
* `enabled: false` by default we trigger it manually here so we
* `enabled: false` by default - we trigger it manually here so we
* don't change behavior of other call sites.
*/
export function useLazyInstanceDetails(
@@ -256,7 +255,7 @@ export function useLazyInstanceDetails(
}
// ---------------------------------------------------------------------------
// useFormResetOnDetailsLoad re-fill form when instanceDetails resolves
// useFormResetOnDetailsLoad - re-fill form when instanceDetails resolves
// ---------------------------------------------------------------------------
/**
@@ -284,28 +283,66 @@ export function useFormResetOnDetailsLoad(
}
// ---------------------------------------------------------------------------
// useVerifyProvider wraps useVerifyProviderConnection for the card
// useVerifyProvider - wraps useVerifyProviderConnection for the card
// ---------------------------------------------------------------------------
/** Optional transform supplied by the provider config. When present it
* maps provider-specific form field names (e.g. OpenDataLoader's
* `opendataloader_apiserver`) onto the `{ apiKey, baseUrl, modelInfo }`
* shape the verify endpoint expects. When absent the generic mapping
* (`values.api_key` / `values.base_url ?? values.api_base`) is used. */
type VerifyTransform = (values: Record<string, any>) => {
apiKey: string | object | Record<string, any>;
baseUrl?: string;
region?: string;
modelInfo?: IModelInfo[];
};
/**
* Adapter that reads the current form values and proxies them into
* `verifyProviderConnection`, then shapes the response into the
* `VerifyResult` consumed by {@link VerifyButton}.
*
* When `verifyTransform` is supplied (provider-specific field mapping,
* e.g. OpenDataLoader's nested `opendataloader_apiserver` /
* `opendataloader_api_key`), it is used to build the verify args;
* otherwise the generic `values.api_key` / `values.base_url` mapping
* is used.
*/
export function useVerifyProvider(
providerName: string,
formRef: RefObject<DynamicFormRef>,
verifyTransform?: VerifyTransform,
) {
const { verifyProviderConnection } = useVerifyProviderConnection();
return useCallback(
async (params: any) => {
const values = { ...(formRef.current?.getValues?.() ?? {}), ...params };
let verifyArgs: {
api_key: string | object;
base_url?: string;
model_info?: IModelInfo[];
region?: string;
};
if (verifyTransform) {
const transformed = verifyTransform(values);
verifyArgs = {
api_key: transformed.apiKey,
base_url: transformed.baseUrl,
model_info: transformed.modelInfo ?? values.model_info,
region: transformed.region,
};
} else {
verifyArgs = {
api_key: values.api_key ?? '',
base_url: values.base_url ?? values.api_base,
model_info: values.model_info,
};
}
const ret = await verifyProviderConnection({
provider_name: providerName,
api_key: values.api_key ?? '',
base_url: values.base_url ?? values.api_base,
model_info: values.model_info,
...(verifyArgs as any),
});
if (ret.code === 0) {
return { isValid: true, logs: ret.message } as {
@@ -318,42 +355,12 @@ export function useVerifyProvider(
logs: string;
};
},
[providerName, formRef, verifyProviderConnection],
[providerName, formRef, verifyProviderConnection, verifyTransform],
);
}
// ---------------------------------------------------------------------------
// useSaveInstanceName — dedicated hook for the draft name Save button
// ---------------------------------------------------------------------------
/**
* Save the instance name on its own. Calls `addProviderInstance` with
* only the instance name (backend now supports creating an instance with
* just a name). On success notifies the parent via `onNameSaved` so it
* can remove this draft — the invalidated `providerInstances` query
* will surface the persisted card automatically.
*/
export function useSaveInstanceName(
providerName: string,
draftName: string,
onNameSaved?: (instanceName: string) => void,
) {
const { addProviderInstance } = useAddProviderInstance();
return useCallback(async () => {
const trimmed = draftName.trim();
if (!trimmed) return;
const ret = await addProviderInstance({
llm_factory: providerName,
instance_name: trimmed,
} as any);
if (ret?.code === 0) {
onNameSaved?.(trimmed);
}
}, [draftName, addProviderInstance, providerName, onNameSaved]);
}
// ---------------------------------------------------------------------------
// useDeleteInstance — wires the card's delete button
// useDeleteInstance - wires the card's delete button
// ---------------------------------------------------------------------------
/**
@@ -380,287 +387,184 @@ export function useDeleteInstance(
}
// ---------------------------------------------------------------------------
// useDraftAutoSave — 200ms-debounced watch-based save for draft mode
// useInstanceSaveState - payload builder + dirty tracking (no auto-save)
// ---------------------------------------------------------------------------
interface UseInstanceSaveStateArgs {
formRef: RefObject<DynamicFormRef>;
providerName: string;
/** Persisted instance name - used for the dirty-tracking baseline. */
instanceName: string;
/**
* The instance name currently shown in the UI (may differ from
* `instanceName` when the user has double-clicked to rename a saved
* card). Used in the save payload so a rename is persisted. Falls
* back to `instanceName` when not provided (drafts use `draftName`).
*/
editedInstanceName?: string;
instanceId: string | undefined;
isDraft: boolean;
draftName: string;
instanceDetails: IProviderInstance | undefined;
initialValues: Record<string, any>;
modelInfoRef: { current: IModelInfo[] };
/**
* Optional provider-specific transform that maps form values to the
* submit API body (e.g. OpenDataLoader's nested
* `opendataloader_apiserver` / `opendataloader_api_key` fields). When
* absent the generic `buildApiKeyValue` + `values.base_url` mapping is
* used.
*/
submitTransform?: (values: Record<string, any>) => Record<string, any>;
}
/**
* Auto-save: whenever the form's other fields change (in draft mode),
* watch the form values and, after a 200ms debounce (acting as a blur
* proxy — fires shortly after the user stops typing / blurs out of a
* field), trigger validation. If all required fields are valid AND
* the instance name has been entered and saved, call `onSaved` with
* the merged values.
* Owns payload construction + dirty tracking for a single instance card.
*
* Replaces the old `useDraftAutoSave` + `useSavedAutoSave` pair. The
* auto-save effects are gone - the parent page now drives save
* explicitly through the imperative ref API. What remains is:
* - `buildPayload()`: assemble the API body from current form values.
* - `getSavePayload()`: return the body if dirty (or a draft with a
* name), else `null` so the parent can skip the redundant call.
* - `markSaved()`: re-baseline after a successful save.
* - `markModelsEdited()`: absorb a model PATCH into the baseline so
* the next top-save does not re-PUT the same model_info (the PATCH
* endpoint already persisted it).
*
* The dirty check compares a JSON signature of the current payload to
* the baseline signature, mirroring the old `lastSavedPayloadRef`
* approach. `model_info` is folded into the signature so editing a
* nested credential field (e.g. MiniMax `group_id`, which is nested
* inside `api_key` by `buildApiKeyValue`) still counts as dirty.
*/
export function useDraftAutoSave(
formRef: RefObject<DynamicFormRef>,
isDraft: boolean,
nameSaved: boolean,
draftName: string,
onSaved: ((values: Record<string, any>) => void | Promise<void>) | undefined,
modelInfoRef: { current: IModelInfo[] },
) {
// Keep the latest `onSaved` and `draftName` in refs so the auto-save
// effect below can read them without re-subscribing on every render
// (the parent passes a fresh `onSaved` arrow each render).
const onSavedRef = useRef(onSaved);
useEffect(() => {
onSavedRef.current = onSaved;
});
export function useInstanceSaveState({
formRef,
providerName,
instanceName,
editedInstanceName,
instanceId,
isDraft,
draftName,
instanceDetails,
initialValues,
modelInfoRef,
submitTransform,
}: UseInstanceSaveStateArgs) {
const baselinePayloadRef = useRef<string>('');
const draftNameRef = useRef(draftName);
useEffect(() => {
draftNameRef.current = draftName;
});
// Keep the latest edited name in a ref so `buildPayload` reads the
// current value without being recreated on every keystroke.
const editedNameRef = useRef(editedInstanceName ?? instanceName);
useEffect(() => {
if (!isDraft) return;
editedNameRef.current = editedInstanceName ?? instanceName;
});
const formInstance = (formRef.current as any)?.form;
if (!formInstance || typeof formInstance.watch !== 'function') return;
// Build the API payload from the current form values. For drafts the
// body targets `addProviderInstance` (no `id`, `llm_factory` at the
// top level); for saved cards it targets `updateProviderInstance`
// (`provider_name`, `id`, `verify: false`). `api_key` is normalised
// via `buildApiKeyValue` so nested credential fields (group_id /
// api_version / provider_order) are bundled inside `api_key` as the
// backend expects.
const buildPayload = useCallback((): Record<string, any> | null => {
const values = (formRef.current?.getValues?.() ?? {}) as Record<
string,
any
>;
const modelInfo =
modelInfoRef.current.length > 0 ? modelInfoRef.current : [];
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const savingRef = { current: false };
const subscription = formInstance.watch(() => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (cancelled || savingRef.current) return;
const isValid = await formRef.current?.trigger();
if (cancelled || savingRef.current) return;
if (!isValid) return;
// Name gate: refuse to actually save if the name is empty or
// has not been "saved" (locked). The red border on the name
// section is the visible signal — it stays on while
// `!nameSaved` regardless of whether the user has touched
// other fields.
if (!draftNameRef.current.trim() || !nameSaved) return;
if (!onSavedRef.current) return;
savingRef.current = true;
try {
const values = formRef.current?.getValues?.() ?? {};
const payload: Record<string, any> = {
...values,
instance_name: draftNameRef.current.trim(),
};
// Forward the latest per-instance model list as `model_info`
// when the user has attached models to the draft. The list
// is normally empty for drafts (the backend has nothing yet),
// but it can be populated once ModelsSection has fetched /
// received data — we only set the key when non-empty so an
// absent value is preserved as `undefined` rather than
// forcing an empty-array clear on the server.
if (modelInfoRef.current.length > 0) {
payload.model_info = modelInfoRef.current;
}
await onSavedRef.current(payload);
} finally {
savingRef.current = false;
}
}, 200);
});
return () => {
cancelled = true;
if (saveTimeout) clearTimeout(saveTimeout);
try {
subscription?.unsubscribe?.();
} catch {
// ignore cleanup errors
// Provider-specific field mapping (e.g. OpenDataLoader's nested
// `opendataloader_apiserver` / `opendataloader_api_key`). The
// transform produces the canonical submit body shape
// (`instance_name`, `llm_factory`, `api_key`, `api_base`,
// `model_info`); we then layer on the card's own state (typed /
// edited name, model_info ref, update-only fields for saved cards).
if (submitTransform) {
const transformed = submitTransform({
...values,
model_info: modelInfo,
}) as Record<string, any>;
if (isDraft) {
const trimmed = draftNameRef.current.trim();
if (!trimmed) return null;
return {
...transformed,
llm_factory: providerName,
instance_name: trimmed,
model_info: modelInfo,
};
}
};
}, [isDraft, nameSaved, formRef, modelInfoRef]);
}
const resolvedId = instanceDetails?.id || instanceId;
return {
...transformed,
provider_name: providerName,
instance_name: editedNameRef.current,
id: resolvedId,
base_url: transformed.base_url ?? transformed.api_base ?? '',
region: values.region || 'default',
model_info: modelInfo,
verify: false,
};
}
// ---------------------------------------------------------------------------
// useSavedAutoSave — blur + dropdown-driven auto-save for saved cards
// ---------------------------------------------------------------------------
if (isDraft) {
const trimmed = draftNameRef.current.trim();
if (!trimmed) return null;
const payload: Record<string, any> = {
llm_factory: providerName,
instance_name: trimmed,
api_key: buildApiKeyValue(values) ?? '',
base_url: values.base_url ?? values.api_base,
};
if (modelInfoRef.current.length > 0) {
payload.model_info = modelInfoRef.current;
}
return payload;
}
/** Field types whose value is committed via click/select (not blur). The
* card's `onBlurCapture` auto-save fires before the dropdown click
* handler commits the new value, and the popover content is rendered in
* a Radix portal outside the card's blur container, so blur-based saves
* are unreliable for these. We watch the form values directly and
* trigger the same auto-save on value change. */
const DROPDOWN_FIELD_TYPES = new Set<FormFieldType>([
FormFieldType.Select,
FormFieldType.MultiSelect,
FormFieldType.Segmented,
// `Custom` is the form-field type used by `inputSelect` in this
// codebase (see use-provider-fields). Every `Custom` field rendered
// inside the provider instance card is an `InputSelect` dropdown.
FormFieldType.Custom,
]);
interface UseSavedAutoSaveArgs {
formRef: RefObject<DynamicFormRef>;
formFields: Array<{ name: string; type: FormFieldType; [k: string]: any }>;
providerName: string;
instanceName: string;
instanceId: string | undefined;
isDraft: boolean;
instanceDetails: IProviderInstance | undefined;
initialValues: Record<string, any>;
modelInfoRef: { current: IModelInfo[] };
}
/**
* Wires the blur-driven + dropdown-driven auto-save flow used by saved
* (non-draft) cards. Returns a `handleFieldsBlur` for the card body to
* attach to `onBlurCapture`, plus a `markModelsEdited` callback for
* `onInstanceModelsEdited` so the next auto-save short-circuits after
* a PATCH-driven model change.
*/
export function useSavedAutoSave({
formRef,
formFields,
providerName,
instanceName,
instanceId,
isDraft,
instanceDetails,
initialValues,
modelInfoRef,
}: UseSavedAutoSaveArgs) {
const { updateProviderInstance } = useUpdateProviderInstance();
const blurSavingRef = useRef(false);
const blurSuppressRef = useRef(false);
const lastSavedPayloadRef = useRef<string>('');
const hasSyncedInstanceRef = useRef(false);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const AUTO_SAVE_DEBOUNCE_MS = 500;
// Shared auto-save routine. Triggered by:
// - `handleFieldsBlur` (focus leaves a non-dropdown field), and
// - the dropdown value-change watcher (a dropdown field's value
// commits via click, not blur — and the popover is rendered in a
// Radix portal outside the card's blur container, so blur-based
// saves are unreliable for dropdowns).
const performAutoSave = useCallback(async () => {
if (isDraft) return;
if (blurSavingRef.current) return;
if (blurSuppressRef.current) return;
const isValid = await formRef.current?.trigger();
if (!isValid) return;
const values = formRef.current?.getValues?.() ?? {};
const resolvedId = instanceDetails?.id || instanceId;
// Providers like MiniMax / Azure-OpenAI / OpenRouter carry extra
// credential fields (group_id / api_version / provider_order) that
// the backend expects bundled *inside* api_key as an object rather
// than as top-level keys. Nesting them here also folds their values
// into the change signature below, so editing one actually triggers
// a blur-save.
const apiKeyValue = buildApiKeyValue(values as Record<string, any>);
const payload: IUpdateProviderInstanceRequestBody = {
const apiKeyValue = buildApiKeyValue(values);
const payload: Record<string, any> = {
provider_name: providerName,
instance_name: instanceName,
// Use the edited name (may differ from the persisted `instanceName`
// when the user has renamed the instance via double-click).
instance_name: editedNameRef.current,
id: resolvedId,
api_key: apiKeyValue ?? '',
base_url: values.base_url ?? values.api_base,
region: values.region || 'default',
model_info: [],
model_info: modelInfoRef.current.length > 0 ? modelInfoRef.current : [],
verify: false,
};
// Pull the latest model list from ModelsSection (via the ref it
// updates). Only attach when non-empty so we don't accidentally
// wipe the persisted model set with an empty array.
if (modelInfoRef.current.length > 0) {
payload.model_info = modelInfoRef.current;
}
// Skip if nothing actually changed since the last save (or initial
// mount): prevents a no-op PUT on every focus shift.
const signature = JSON.stringify(payload);
if (signature === lastSavedPayloadRef.current) return;
blurSavingRef.current = true;
try {
const ret = await updateProviderInstance(payload);
if (ret?.code === 0) {
lastSavedPayloadRef.current = signature;
hasSyncedInstanceRef.current = true;
}
} finally {
blurSavingRef.current = false;
}
return payload;
}, [
isDraft,
providerName,
instanceName,
instanceId,
instanceDetails?.id,
updateProviderInstance,
formRef,
modelInfoRef,
submitTransform,
]);
// Debounced auto-save: coalesces rapid edits (blur cascade,
// successive dropdown changes, typing in filterable dropdowns) into
// a single delayed `performAutoSave` call.
const scheduleAutoSave = useCallback(() => {
if (isDraft) return;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
// Seed the "last saved" baseline once instance details (or the
// draft's empty state) are available, so the first `getSavePayload()`
// after mount doesn't flag a phantom dirty. For drafts the baseline
// stays empty - a draft is always considered dirty once it has a
// name, so the baseline is only consulted for saved cards.
useEffect(() => {
if (isDraft) {
baselinePayloadRef.current = '';
return;
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
void performAutoSave();
}, AUTO_SAVE_DEBOUNCE_MS);
}, [isDraft, performAutoSave]);
const handleFieldsBlur = useCallback(
async (e: React.FocusEvent<HTMLDivElement>) => {
if (isDraft) return;
// Ignore focus moves that stay inside the same container.
if (
e.currentTarget.contains(e.relatedTarget as Node | null) &&
e.relatedTarget !== null
) {
return;
}
scheduleAutoSave();
},
[isDraft, scheduleAutoSave],
);
// Refs so the dropdown watcher effect can invoke the latest callbacks
// without re-subscribing on every render (the parent passes a fresh
// `onBlurCapture` arrow each render, and `performAutoSave` changes
// whenever its deps change — e.g. when `instanceDetails` loads).
const performAutoSaveRef = useRef(performAutoSave);
useEffect(() => {
performAutoSaveRef.current = performAutoSave;
});
const scheduleAutoSaveRef = useRef<() => void>(() => {});
useEffect(() => {
scheduleAutoSaveRef.current = scheduleAutoSave;
});
// Clear any pending debounced save when the card unmounts so we don't
// fire a stale request after teardown.
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, []);
// Seed the "last saved" signature once initial values are loaded so
// the first blur after mount doesn't trigger an unnecessary save.
useEffect(() => {
if (isDraft) return;
const resolvedId = instanceDetails?.id || instanceId;
if (!resolvedId) return;
if (hasSyncedInstanceRef.current) return;
// Match the api_key shape performAutoSave produces (extra credential
// fields nested inside api_key) so the first blur after mount
// doesn't see a signature diff and fire a redundant save. model_info
// is omitted for the same reason as in performAutoSave: model
// changes are owned by the per-model endpoints, not this auto-save.
const baseline = {
provider_name: providerName,
instance_name: instanceName,
@@ -668,9 +572,15 @@ export function useSavedAutoSave({
api_key: buildApiKeyValue(initialValues),
base_url: initialValues.base_url ?? initialValues.api_base,
region: initialValues.region,
// model_info baseline is `[]`; `markModelsEdited` rewrites it after
// a model PATCH so the next top-save short-circuits. The first
// save after the models initially load may re-send the same
// model_info (idempotent) - acceptable, matches the old
// `lastSavedPayloadRef` seeding behaviour.
model_info: [] as IModelInfo[],
verify: false,
};
lastSavedPayloadRef.current = JSON.stringify(baseline);
baselinePayloadRef.current = JSON.stringify(baseline);
}, [
isDraft,
providerName,
@@ -680,120 +590,102 @@ export function useSavedAutoSave({
initialValues,
]);
// Dropdown value-change auto-save (saved mode only). A dropdown
// field's value commits via click, not blur — and the popover is
// rendered in a Radix portal outside the card's blur container, so
// blur-based saves are unreliable for dropdowns.
//
// We subscribe to the *raw* RHF form so we can read the change
// metadata `{ name, type }`. Only genuine user edits carry
// `type === 'change'`; programmatic updates (the `form.reset` that
// runs when `instanceDetails` loads, `setValue`, etc.) come through
// with an undefined type. Gating on `type === 'change'` is what stops
// merely opening the card / loading its details from firing a save —
// only an actual user selection in a dropdown schedules one. The
// shared debounce (`scheduleAutoSave`) coalesces it with any
// blur-triggered save. Skipped in draft mode (which has its own
// 200ms-debounced watch) and until `instanceDetails` loads.
const dropdownFieldNames = useMemo(
() =>
formFields
.filter((f) => DROPDOWN_FIELD_TYPES.has(f.type))
.map((f) => f.name),
[formFields],
);
useEffect(() => {
if (isDraft) return;
if (dropdownFieldNames.length === 0) return;
if (!instanceDetails) return;
const form = (formRef.current as any)?.form;
if (!form || typeof form.watch !== 'function') return;
let cancelled = false;
const dropdownFieldSet = new Set(dropdownFieldNames);
const subscription = form.watch(
(_values: any, meta: { name?: string; type?: string }) => {
if (cancelled) return;
// Ignore programmatic value changes (reset/setValue) — they have
// no `type`. Only react to user-driven dropdown selections.
if (meta?.type !== 'change') return;
if (!meta?.name || !dropdownFieldSet.has(meta.name)) return;
scheduleAutoSaveRef.current();
},
);
return () => {
cancelled = true;
try {
subscription?.unsubscribe?.();
} catch {
// ignore cleanup errors
}
// `getSavePayload()` is the imperative entry point the parent calls
// when the user clicks the top Save button. For drafts it always
// returns a payload (provided the name is non-empty); for saved
// cards it returns `null` when the current signature matches the
// baseline, so the parent skips the no-op PUT.
const getSavePayload = useCallback((): InstanceSavePayload | null => {
const payload = buildPayload();
if (!payload) return null;
if (!isDraft) {
const sig = JSON.stringify(payload);
if (sig === baselinePayloadRef.current) return null;
}
return {
payload,
instanceName: isDraft
? draftNameRef.current.trim()
: editedNameRef.current,
isDraft,
// Generic drafts go through `addProviderInstance`; generic saved
// cards go through `updateProviderInstance` (their payload matches
// `IUpdateProviderInstanceRequestBody`).
apiKind: isDraft ? 'add' : 'update',
};
}, [isDraft, dropdownFieldNames, instanceDetails, formRef]);
}, [buildPayload, isDraft, instanceName]);
// Absorb a model patch into the host's last-saved baseline. When the
// user saves the edit modal, patchInstanceModel has already persisted
// the new max_tokens / model_type / features server-side, so the next
// blur auto-save should NOT re-PUT the same model_info. By parsing
// the previously-saved payload and overwriting ONLY model_info, the
// baseline now matches the current state and the signature check in
// performAutoSave short-circuits — while any in-flight edits to
// api_key / base_url / region remain in `lastSavedPayloadRef`
// unchanged and will still trigger a save on blur via signature
// After a successful save the parent calls `markSaved()` so the
// baseline catches up to the just-persisted values. Without this,
// the next `getSavePayload()` would re-fire the same PUT.
const markSaved = useCallback(() => {
const payload = buildPayload();
if (payload) {
baselinePayloadRef.current = JSON.stringify(payload);
}
}, [buildPayload]);
// Absorb a model patch into the baseline. `patchInstanceModel` has
// already persisted the new max_tokens / model_type / features
// server-side, so the next top-save should NOT re-PUT the same
// model_info. By parsing the previously-saved baseline and overwriting
// ONLY model_info, the baseline now matches the current state and the
// signature check in `getSavePayload` short-circuits - while any
// in-flight edits to api_key / base_url / region remain in the
// baseline unchanged and will still trigger a save via signature
// mismatch.
//
// Skipped until the host has synced at least once. Before that the
// baseline still carries the initial `model_info: []`; rewriting it
// here would skip the very first PUT that syncs the user's first
// add/edit into the persisted model_info.
// Skipped for drafts (the baseline is empty there) and until the
// baseline has been seeded.
const markModelsEdited = useCallback(() => {
if (isDraft) return;
if (!hasSyncedInstanceRef.current) return;
const prev = lastSavedPayloadRef.current;
const prev = baselinePayloadRef.current;
if (!prev) return;
const parsed = JSON.parse(prev) as IUpdateProviderInstanceRequestBody;
parsed.model_info =
modelInfoRef.current.length > 0 ? modelInfoRef.current : [];
// Mirror the `verify: false` field that performAutoSave always
// attaches, otherwise the next signature comparison would diff on
// this key alone and re-fire the save.
(parsed as any).verify = false;
lastSavedPayloadRef.current = JSON.stringify(parsed);
try {
const parsed = JSON.parse(prev) as Record<string, any>;
parsed.model_info =
modelInfoRef.current.length > 0 ? modelInfoRef.current : [];
parsed.verify = false;
baselinePayloadRef.current = JSON.stringify(parsed);
} catch {
// ignore parse errors - baseline will be re-seeded on next details load
}
}, [isDraft, modelInfoRef]);
return {
handleFieldsBlur,
performAutoSave,
scheduleAutoSave,
blurSuppressRef,
buildPayload,
getSavePayload,
markSaved,
markModelsEdited,
};
}
// ---------------------------------------------------------------------------
// useFormFields wraps useProviderFields and strips instance_name
// useFormFields - wraps useProviderFields and strips instance_name
// ---------------------------------------------------------------------------
/**
* Wraps `useProviderFields` and removes the `instance_name` field from
* both the field list and the default values the card header owns
* both the field list and the default values - the card header owns
* the instance name (editable on hover), so we keep a single source of
* truth and avoid showing it twice in the form.
*/
export function useFormFields(
providerName: string,
isDraft: boolean,
_isDraft: boolean,
initialValues: Record<string, any>,
baseUrlOptions: SelectOption[] | undefined,
hideWhenInstanceExists: (values: any) => boolean,
) {
const { fields, defaultValues } = useProviderFields({
llmFactory: providerName,
editMode: !isDraft,
viewMode: isDraft,
// Always seed initial values (drafts need the default base_url;
// saved cards need the persisted api_key / base_url).
editMode: true,
// Never disable fields - the old name-first lock is gone. Drafts
// are fully editable from the start; saved cards are edited via the
// top Save button.
viewMode: false,
initialValues,
baseUrlOptions,
hideWhenInstanceExists,

View File

@@ -19,6 +19,59 @@ import { IProviderInstance } from '@/interfaces/database/llm';
import { IModelInfo } from '@/interfaces/request/llm';
import { RefObject } from 'react';
/**
* Imperative save API exposed by each instance card (generic, Bedrock,
* SoMark) so the parent page can drive a single batch save from the
* top-of-page Save button.
*
* The card owns its form state and dirty tracking; the parent only needs
* to validate, collect payloads, and dispatch the API calls.
*/
export interface ProviderInstanceCardRef {
/**
* Trigger form validation (and the draft-name check for drafts).
* Returns true if the card is valid and ready to save. Errors are
* surfaced in the form UI as a side effect of `trigger()`.
*/
validate: () => Promise<boolean>;
/**
* Build the save payload for this card, or return `null` when there
* is nothing to persist.
*
* - Drafts: always returns a payload (provided the instance name is
* non-empty), since a draft is by definition unsaved.
* - Saved cards: returns a payload only when the current form values
* differ from the last-synced baseline; otherwise `null` so the
* parent can skip the redundant API call.
*/
getSavePayload: () => InstanceSavePayload | null;
/**
* Update the card's dirty-tracking baseline to the current form
* values. Called by the parent after a successful save so the next
* `getSavePayload()` call short-circuits as a no-op.
*/
markSaved: () => void;
}
/** Payload returned by {@link ProviderInstanceCardRef.getSavePayload}. */
export interface InstanceSavePayload {
/** Ready-to-send body for the save API. Shape depends on `apiKind`. */
payload: Record<string, any>;
/** Instance name to save under. For drafts this is the typed-in name. */
instanceName: string;
/** True for a new (unsaved) instance, false for an existing one. */
isDraft: boolean;
/**
* 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
* `addProviderInstance` body).
* - `'update'`: call `updateProviderInstance` (generic saved cards,
* whose payload matches `IUpdateProviderInstanceRequestBody`).
*/
apiKind: 'add' | 'update';
}
/** Public props for {@link ProviderInstanceCard}. */
export interface ProviderInstanceCardProps {
providerName: string;
@@ -30,20 +83,11 @@ export interface ProviderInstanceCardProps {
instance: IProviderInstance;
/**
* True when this card represents a freshly-added (unsaved) instance.
* Renders Save / Cancel buttons and treats all fields as editable.
* Renders the instance-name input section and treats all fields as
* editable. Saving is driven by the parent through the imperative
* ref API (see {@link ProviderInstanceCardRef}).
*/
isDraft?: boolean;
/** Called after a draft instance is successfully saved. */
onSaved?: (values: Record<string, any>) => void | Promise<void>;
/**
* Called after a draft instance's *name* has been persisted via
* `addProviderInstance` (with just `instance_name`). The parent should
* remove this draft from its visible list; the freshly invalidated
* `providerInstances` query will surface the persisted card. The
* saved `instanceName` is passed so the parent can keep the newly
* persisted card expanded.
*/
onNameSaved?: (instanceName: string) => void;
/**
* Called when the user deletes a draft instance.
* For drafts this is equivalent to onCancel; for saved instances
@@ -88,29 +132,35 @@ export interface SavedModeCardProps {
formFields: FormFieldConfig[];
formDefaultValues: Record<string, any>;
formRef: RefObject<DynamicFormRef>;
handleFieldsBlur: (e: React.FocusEvent<HTMLDivElement>) => void;
handleVerify: (params: any) => Promise<{ isValid: boolean; logs: string }>;
handleDelete: () => Promise<void>;
handleInstanceModelsEdited: () => void;
providerName: string;
/** Persisted instance name (from the backend). */
instanceName: string;
/**
* The instance name currently displayed (may differ from `instanceName`
* when the user has renamed via double-click). Falls back to
* `instanceName` when not set.
*/
editedInstanceName: string;
/** Commit a rename: updates the card's edited-name state. */
onRename: (name: string) => void;
instance: IProviderInstance;
instanceDetailsLoaded: boolean;
modelInfoRef: React.MutableRefObject<IModelInfo[]>;
blurSuppressRef: React.MutableRefObject<boolean>;
draftName: string;
open: boolean;
setOpen: (open: boolean) => void;
}
/** Props for the draft-mode card (instance name + locked form fields). */
/** Props for the draft-mode card (instance name + form fields). */
export interface DraftModeCardProps {
formFields: FormFieldConfig[];
formDefaultValues: Record<string, any>;
formRef: RefObject<DynamicFormRef>;
handleVerify: (params: any) => Promise<{ isValid: boolean; logs: string }>;
handleDelete: () => Promise<void>;
handleSaveName: () => Promise<void>;
handleInstanceModelsEdited: () => void;
providerName: string;
instanceName: string;

View File

@@ -120,20 +120,24 @@ interface UseModelsCatalogArgs {
providerName: string;
instanceName: string;
hideActions: boolean;
isDraftInstance: boolean;
resolveCreds: () => ResolvedCreds;
instanceModels: IInstanceModel[] | undefined;
instanceDetailsLoaded: boolean;
/**
* Current api_key value (read from the host form / instance). Used to
* gate the auto-fetch for providers that require an api_key to list
* models (currently only VolcEngine). For other providers the value
* is ignored and the catalog is fetched on mount regardless.
*/
apiKeyValue: string;
}
export function useModelsCatalog({
providerName,
instanceName,
hideActions,
isDraftInstance,
resolveCreds,
instanceModels,
instanceDetailsLoaded,
apiKeyValue,
}: UseModelsCatalogArgs) {
const { listProviderModels } = useListProviderModels();
const [catalog, setCatalog] = useState<IProviderModelItem[]>([]);
@@ -168,18 +172,21 @@ export function useModelsCatalog({
};
// Auto-fetch the provider's available-models catalog when this section
// mounts (effectively "when the card is expanded"). Skipped for draft
// instances and catalog-preview-only hosts.
// mounts (effectively "when the card is expanded"). For VolcEngine we
// wait until an api_key is available (typed in the draft form or loaded
// from instance details); for every other provider we fetch on mount
// regardless of draft / saved state - the catalog endpoint does not
// require credentials and the user expects to see the model list as
// soon as they open the "Add instance" page.
const requiresApiKey = providerName === LLMFactory.VolcEngine;
const credsReady = !requiresApiKey || instanceDetailsLoaded;
const credsReady = !requiresApiKey || !!apiKeyValue;
const hasAutoFetchedRef = useRef(false);
useEffect(() => {
if (hasAutoFetchedRef.current) return;
if (hideActions) return;
if (!providerName) return;
if (isDraftInstance) return;
if (!credsReady) return;
hasAutoFetchedRef.current = true;
handleListModels();
@@ -210,6 +217,20 @@ export function useModelsCatalog({
interface UseModelsDerivedArgs {
catalog: IProviderModelItem[];
instanceModels: IInstanceModel[] | undefined;
/**
* Locally-added models for a draft (unsaved) instance. The hook uses
* this list as the "instance models" source when `isDraftInstance` is
* true, so per-model add/remove/batch on a draft updates the derived
* list without a backend round-trip. The host's save handler then
* flushes the latest snapshot through `model_info` on save.
*/
draftModels: IProviderModelItem[];
/**
* True when this card represents a draft instance (no backend id yet).
* Picks between `instanceModels` (saved) and `draftModels` (draft) as
* the source for `instanceItems` / `addedSet`.
*/
isDraftInstance: boolean;
onInstanceModelsChange: ModelsSectionProps['onInstanceModelsChange'];
onInstanceModelsEdited?: ModelsSectionProps['onInstanceModelsEdited'];
}
@@ -217,6 +238,8 @@ interface UseModelsDerivedArgs {
export function useModelsDerived({
catalog,
instanceModels,
draftModels,
isDraftInstance,
onInstanceModelsChange,
onInstanceModelsEdited,
}: UseModelsDerivedArgs) {
@@ -230,11 +253,20 @@ export function useModelsDerived({
return map;
}, [catalog]);
// For drafts the backend has no per-instance models yet, so the local
// `draftModels` array stands in. For saved cards the backend list is
// authoritative. The hook signature normalises both into the same
// shape (`IProviderModelItem[]`) downstream.
const sourceItems = useMemo(
() => (isDraftInstance ? draftModels : ((instanceModels ?? []) as any[])),
[isDraftInstance, draftModels, instanceModels],
);
const instanceItems: IProviderModelItem[] = useMemo(() => {
// `im` is typed `any` because the backend may return either
// `model_type` or `model_types`, and `features` is not on the
// declared IInstanceModel interface.
return (instanceModels ?? []).map((im: any) => {
return sourceItems.map((im: any) => {
const model_types = normalizeModelTypes(
im.model_types ?? im.model_type ?? [],
);
@@ -250,7 +282,7 @@ export function useModelsDerived({
features,
};
});
}, [instanceModels, catalogFeatures]);
}, [sourceItems, catalogFeatures]);
// Union of instance models + catalog, keyed by `name`. Catalog entries
// win on conflict; instance set listed first so already-added models
@@ -262,9 +294,12 @@ export function useModelsDerived({
return Array.from(byName.values());
}, [instanceItems, catalog]);
// Mirror of `instanceItems` names - drives the +/- toggle on each row
// and the batch-toggle button. For drafts this is the local "added"
// set; for saved cards it tracks what the backend has persisted.
const addedSet = useMemo(
() => new Set((instanceModels ?? []).map((m: IInstanceModel) => m.name)),
[instanceModels],
() => new Set(sourceItems.map((m: any) => m.name)),
[sourceItems],
);
// Keep the latest callbacks in refs so the effect below only fires
@@ -424,6 +459,15 @@ interface UseModelMutationsArgs {
filteredModels: IProviderModelItem[];
addedSet: Set<string>;
setCatalog: Dispatch<SetStateAction<IProviderModelItem[]>>;
/**
* Local mutators for the draft instance's model list. Required when
* `isDraftInstance` is true so per-model add / remove / batch updates
* stay local until the host saves the instance. Ignored for saved
* cards (the backend mutations below fire as before).
*/
addDraftModel?: (model: IProviderModelItem) => void;
removeDraftModel?: (name: string) => void;
setDraftModelsList?: (models: IProviderModelItem[]) => void;
}
export function useModelMutations({
@@ -437,6 +481,9 @@ export function useModelMutations({
filteredModels,
addedSet,
setCatalog,
addDraftModel,
removeDraftModel,
setDraftModelsList,
}: UseModelMutationsArgs) {
const { addInstanceModel } = useAddInstanceModel();
const { deleteInstanceModels } = useDeleteInstanceModels();
@@ -453,6 +500,12 @@ export function useModelMutations({
);
const handleAddModel = async (model: IProviderModelItem) => {
// Drafts have no backend instance yet — defer the call so the model
// rides along with the instance save (model_info in the add body).
if (isDraftInstance) {
addDraftModel?.(model);
return;
}
await addInstanceModel({
provider_name: providerName,
instance_name: instanceName,
@@ -464,6 +517,10 @@ export function useModelMutations({
};
const handleRemoveModel = async (model: IProviderModelItem) => {
if (isDraftInstance) {
removeDraftModel?.(model.name);
return;
}
await deleteInstanceModels({
provider_name: providerName,
instance_name: instanceName,
@@ -479,6 +536,14 @@ export function useModelMutations({
prev.some((m) => m.name === item.name) ? prev : [...prev, item],
);
if (hideActions || isDraftInstance) {
// For drafts the catalog entry alone is not enough — we also need
// to mark the model as added so it flows into the save payload's
// `model_info`. Without this, custom models added on a draft
// would render as "available" but not as "added", and would be
// dropped on save.
if (isDraftInstance) {
addDraftModel?.(item);
}
return;
}
await addInstanceModel({
@@ -491,12 +556,13 @@ export function useModelMutations({
});
};
// Batch attach/detach the currently visible (filtered) models via the
// PUT `/providers/{name}/instances/{name}` endpoint (replaces
// `model_info` wholesale).
// Batch attach/detach the currently visible (filtered) models.
// - Saved card: PUT `/providers/{name}/instances/{name}` to replace
// `model_info` wholesale.
// - Draft: just rewrite the local draft list. The host save handler
// flushes the latest snapshot through the add-instance payload.
const handleBatchToggleModels = async () => {
if (filteredModels.length === 0) return;
const { apiKey, baseUrl } = resolveCreds();
const byName = new Map<string, IProviderModelItem>();
instanceItems.forEach((m) => byName.set(m.name, m));
@@ -510,6 +576,12 @@ export function useModelMutations({
nextModels = Array.from(byName.values());
}
if (isDraftInstance) {
setDraftModelsList?.(nextModels);
return;
}
const { apiKey, baseUrl } = resolveCreds();
await updateProviderInstance({
provider_name: providerName,
instance_name: instanceName,

View File

@@ -18,8 +18,9 @@ import { Button } from '@/components/ui/button';
import { SearchInput } from '@/components/ui/input';
import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks';
import { useFetchInstanceModels } from '@/hooks/use-llm-request';
import { IProviderModelItem } from '@/interfaces/request/llm';
import { ListMinus, ListPlus, Loader2, Plus, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddCustomModelDialog } from '../add-custom-model-dialog';
import { mapModelKey } from '../available-models';
@@ -48,7 +49,6 @@ export function ModelsSection(props: ModelsSectionProps) {
instance,
hideActions = false,
hideIfEmpty = false,
instanceDetailsLoaded = false,
getFormValues,
onBlurSuppressChange,
onInstanceModelsChange,
@@ -61,6 +61,12 @@ export function ModelsSection(props: ModelsSectionProps) {
// 1. Credentials for catalog / verify / batch calls.
const { resolveCreds } = useResolveCreds(instance, getFormValues);
// Snapshot of the current api_key so `useModelsCatalog` can gate the
// auto-fetch for VolcEngine on the user actually having typed one.
// Recomputed on every render so the effect re-runs as soon as the
// form value lands.
const currentCreds = resolveCreds();
// 2. Per-instance saved models (shared by catalog, derived, verify).
const { data: instanceModels } = useFetchInstanceModels(
providerName,
@@ -78,16 +84,38 @@ export function ModelsSection(props: ModelsSectionProps) {
providerName,
instanceName,
hideActions,
isDraftInstance,
resolveCreds,
instanceModels,
instanceDetailsLoaded,
apiKeyValue: currentCreds.apiKey,
});
// 3a. Draft-only: locally-tracked "added models" list.
// The backend has no per-instance models yet, so per-model add /
// remove / batch-toggle on a draft mutates this array instead of
// firing a mutation. The host save handler then flushes the latest
// snapshot through `model_info` on save. Reset when the provider
// or instance changes (rare in practice since the host remounts
// the section on draft switch, but kept as a safety net).
const [draftModels, setDraftModels] = useState<IProviderModelItem[]>([]);
useEffect(() => {
setDraftModels([]);
}, [providerName, instanceName]);
const addDraftModel = useCallback((model: IProviderModelItem) => {
setDraftModels((prev) =>
prev.some((m) => m.name === model.name) ? prev : [...prev, model],
);
}, []);
const removeDraftModel = useCallback((name: string) => {
setDraftModels((prev) => prev.filter((m) => m.name !== name));
}, []);
// 4. Derived union list (instance catalog) + push to host.
const { instanceItems, models, addedSet } = useModelsDerived({
catalog,
instanceModels,
draftModels,
isDraftInstance,
onInstanceModelsChange,
onInstanceModelsEdited,
});
@@ -122,6 +150,9 @@ export function ModelsSection(props: ModelsSectionProps) {
filteredModels,
addedSet,
setCatalog,
addDraftModel,
removeDraftModel,
setDraftModelsList: setDraftModels,
});
// 8. Edit dialog state + submit.

View File

@@ -16,101 +16,105 @@
import { DynamicFormRef } from '@/components/dynamic-form';
import { IModelInfo } from '@/interfaces/request/llm';
import { useEffect, useRef, useState } from 'react';
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useFetchInstanceNameSet, useHideWhenInstanceExists } from '../hooks';
import { getProviderConfig } from '../provider-schema/field-config';
import { BedrockInstanceCard } from './bedrock-instance-card';
import { DraftModeCard } from './components/draft-mode-card';
import { InstanceNameSection } from './components/instance-name-section';
import { SavedModeCard } from './components/saved-mode-card';
import { SoMarkInstanceCard } from './somark-instance-card';
import {
useDeleteInstance,
useDraftAutoSave,
useFormFields,
useFormResetOnDetailsLoad,
useInstanceSaveState,
useLazyInstanceDetails,
useProviderBaseUrlOptions,
useProviderInitialValues,
useSaveInstanceName,
useSavedAutoSave,
useVerifyProvider,
} from './hooks';
import { ProviderInstanceCardProps } from './interface';
import {
ProviderInstanceCardProps,
ProviderInstanceCardRef,
} from './interface';
import { SoMarkInstanceCard } from './somark-instance-card';
/**
* One inline provider-instance card. The provider name + doc-link arrow
* live in the parent page's sticky `ProviderHeaderBar`; this card only
* shows the **instance**-level details (name, fields, verify, models).
*
* Two visual modes (driven by the `nameSaved` flag, not the `isDraft`
* prop — `isDraft` only controls whether the form is editable):
* 1. **Unsaved name** (`!nameSaved`): the instance name lives in a
* dedicated form-field section at the top of the body, wrapped in
* a red border with a label, input, inline Save button, and
* always-visible helper text. The form fields are always visible
* (no collapsible). The auto-save on blur is *active* but will
* refuse to call `onSaved` until the name is entered and saved.
* 2. **Saved name** (`nameSaved`): the form-field section collapses
* into a single collapsible row showing the name as plain text
* with a hover-only key/lock icon. The form fields live inside
* the collapsible content and can be collapsed/expanded.
* Two visual modes driven by `isDraft`:
* 1. **Draft** (`isDraft`): the instance name lives in a dedicated
* input section at the top of the body, and the form fields are
* always editable (no fieldset lock). The parent drives save
* through the imperative ref API.
* 2. **Saved** (`!isDraft`): the form-field section collapses into a
* single collapsible row showing the name as plain text with a
* hover-only key/lock icon. The form fields live inside the
* collapsible content and can be collapsed/expanded.
*
* Auto-save has been removed; the parent page's top Save button
* collects payloads from all cards via the ref and dispatches the API
* calls in a single batch.
*/
export function ProviderInstanceCard(props: ProviderInstanceCardProps) {
// AWS Bedrock has provider-specific fields (auth_mode, region, AK/SK,
// role ARN, model name, max_tokens) that don't fit the generic
// DynamicForm path. Render its own inline card instead.
//
// SoMark is similar: its many provider-specific fields (image /
// formula / table / cs formats + 7 boolean feature toggles) don't
// fit the generic DynamicForm path. Render its own inline card too.
//
// Dispatch BEFORE any hooks so each branch component has a stable
// hook-call order (Rules of Hooks).
if (props.providerName === 'Bedrock') {
return <BedrockInstanceCard {...props} />;
}
if (props.providerName === 'SoMark') {
return <SoMarkInstanceCard {...props} />;
}
return <GenericProviderInstanceCard {...props} />;
}
function GenericProviderInstanceCard({
providerName,
instance,
isDraft = false,
onSaved,
onNameSaved,
onDelete,
defaultOpen = false,
}: ProviderInstanceCardProps) {
const GenericProviderInstanceCard = forwardRef<
ProviderInstanceCardRef,
ProviderInstanceCardProps
>(function GenericProviderInstanceCard(
{ providerName, instance, isDraft = false, onDelete, defaultOpen = false },
ref,
) {
// Drafts always start open (the user just added them and needs to
// fill the fields); saved cards default to collapsed unless the
// parent flagged this card as the one to expand initially (typically
// the first instance in the list).
const [open, setOpen] = useState(isDraft || defaultOpen);
// Drafts start with an empty name the user types it themselves.
// Drafts start with an empty name - the user types it themselves.
const [draftName, setDraftName] = useState('');
// Tracks whether the instance name has been saved for the current
// draft/saved state. Saved instances start with `true` (the name is
// persisted in the backend); draft instances start with `false` and
// flip to `true` after the dedicated "Save" button on the name
// section is pressed.
const [nameSaved, setNameSaved] = useState(!isDraft);
// For saved cards: the instance name as shown in the UI. Initialized
// from the persisted name and updated when the user double-clicks the
// name to rename it. Persisted via the top Save button.
const [editedInstanceName, setEditedInstanceName] = useState(
instance.instance_name,
);
const formRef = useRef<DynamicFormRef>(null);
// Mirror of the per-instance model list written by ModelsSection
// via `setModelInfo`, read by the auto-save payload assembler.
// Mirror of the per-instance model list - written by ModelsSection
// via `setModelInfo`, read by the payload builder.
const modelInfoRef = useRef<IModelInfo[]>([]);
// Provider-specific config: carries `verifyTransform` / `submitTransform`
// for providers whose form field names don't map directly onto
// `api_key` / `base_url` (e.g. OpenDataLoader's nested
// `opendataloader_apiserver` / `opendataloader_api_key`). When present
// the transforms take precedence over the generic field mapping inside
// `useVerifyProvider` and `useInstanceSaveState.buildPayload`.
const providerConfig = useMemo(
() => getProviderConfig(providerName),
[providerName],
);
useEffect(() => {
if (isDraft) {
setDraftName('');
setNameSaved(false);
} else {
setNameSaved(true);
}
}, [providerName, isDraft]);
// Reset the edited name when the persisted instance changes (e.g.
// after a successful rename + refetch, or when switching providers).
useEffect(() => {
if (!isDraft) {
setEditedInstanceName(instance.instance_name);
}
}, [instance.instance_name, isDraft]);
// ── Data fetching ────────────────────────────────────────────────
const { instanceNameSet } = useFetchInstanceNameSet(
isDraft ? providerName : '',
@@ -146,11 +150,10 @@ function GenericProviderInstanceCard({
);
// ── Action handlers ─────────────────────────────────────────────
const handleVerify = useVerifyProvider(providerName, formRef);
const handleSaveName = useSaveInstanceName(
const handleVerify = useVerifyProvider(
providerName,
draftName,
onNameSaved,
formRef,
providerConfig.verifyTransform,
);
const handleDelete = useDeleteInstance(
providerName,
@@ -159,64 +162,54 @@ function GenericProviderInstanceCard({
onDelete,
);
// ── Auto-save wiring ─────────────────────────────────────────────
// Draft: 200ms-debounced watch effect, gated on the instance name
// being entered and saved.
useDraftAutoSave(
// ── Save state (payload builder + dirty tracking) ───────────────
const { getSavePayload, markSaved, markModelsEdited } = useInstanceSaveState({
formRef,
providerName,
instanceName: instance.instance_name,
editedInstanceName,
instanceId: instance.id,
isDraft,
nameSaved,
draftName,
isDraft ? onSaved : undefined,
instanceDetails,
initialValues,
modelInfoRef,
);
submitTransform: providerConfig.submitTransform,
});
// Saved: blur-driven + dropdown value-change auto-save via PUT.
const { handleFieldsBlur, blurSuppressRef, markModelsEdited } =
useSavedAutoSave({
formRef,
formFields,
providerName,
instanceName: instance.instance_name,
instanceId: instance.id,
isDraft,
instanceDetails,
initialValues,
modelInfoRef,
});
// Expose the imperative save API to the parent so the top-of-page
// Save button can validate, collect payloads, and dispatch calls
// in a single batch without each card wiring its own auto-save.
useImperativeHandle(
ref,
() => ({
validate: async () => {
// Drafts need a non-empty instance name (the DynamicForm does
// not include the instance_name field, so `trigger()` won't
// catch it). For both drafts and saved cards, run the form's
// own validation so errors surface in the UI.
if (isDraft && !draftName.trim()) return false;
const isValid = await formRef.current?.trigger();
return !!isValid;
},
getSavePayload,
markSaved,
}),
[isDraft, draftName, getSavePayload, markSaved],
);
return (
<div
className="border-b border-border-button mb-5 pb-5"
data-testid={`instance-card-${instance.instance_name || 'draft'}`}
>
{nameSaved ? (
<SavedModeCard
formFields={formFields}
formDefaultValues={formDefaultValues}
formRef={formRef}
handleFieldsBlur={handleFieldsBlur}
handleVerify={handleVerify}
handleDelete={handleDelete}
handleInstanceModelsEdited={markModelsEdited}
providerName={providerName}
instanceName={instance.instance_name}
instance={instance}
instanceDetailsLoaded={Boolean(instanceDetails)}
modelInfoRef={modelInfoRef}
blurSuppressRef={blurSuppressRef}
draftName={draftName}
open={open}
setOpen={setOpen}
/>
) : (
{isDraft ? (
<DraftModeCard
formFields={formFields}
formDefaultValues={formDefaultValues}
formRef={formRef}
handleVerify={handleVerify}
handleDelete={handleDelete}
handleSaveName={handleSaveName}
handleInstanceModelsEdited={markModelsEdited}
providerName={providerName}
instanceName={instance.instance_name}
@@ -225,10 +218,71 @@ function GenericProviderInstanceCard({
draftName={draftName}
setDraftName={setDraftName}
/>
) : (
<SavedModeCard
formFields={formFields}
formDefaultValues={formDefaultValues}
formRef={formRef}
handleVerify={handleVerify}
handleDelete={handleDelete}
handleInstanceModelsEdited={markModelsEdited}
providerName={providerName}
instanceName={instance.instance_name}
editedInstanceName={editedInstanceName}
onRename={setEditedInstanceName}
instance={instance}
instanceDetailsLoaded={Boolean(instanceDetails)}
modelInfoRef={modelInfoRef}
draftName={draftName}
open={open}
setOpen={setOpen}
/>
)}
</div>
);
}
});
/**
* One inline provider-instance card. The provider name + doc-link arrow
* live in the parent page's sticky `ProviderHeaderBar`; this card only
* shows the **instance**-level details (name, fields, verify, models).
*
* Two visual modes driven by `isDraft`:
* 1. **Draft** (`isDraft`): the instance name lives in a dedicated
* input section at the top of the body, and the form fields are
* always editable (no fieldset lock). The parent drives save
* through the imperative ref API.
* 2. **Saved** (`!isDraft`): the form-field section collapses into a
* single collapsible row showing the name as plain text with a
* hover-only key/lock icon. The form fields live inside the
* collapsible content and can be collapsed/expanded.
*
* Auto-save has been removed; the parent page's top Save button
* collects payloads from all cards via the ref and dispatches the API
* calls in a single batch.
*/
export const ProviderInstanceCard = forwardRef<
ProviderInstanceCardRef,
ProviderInstanceCardProps
>(function ProviderInstanceCard(props, ref) {
// AWS Bedrock has provider-specific fields (auth_mode, region, AK/SK,
// role ARN, model name, max_tokens) that don't fit the generic
// DynamicForm path. Render its own inline card instead.
//
// SoMark is similar: its many provider-specific fields (image /
// formula / table / cs formats + 7 boolean feature toggles) don't
// fit the generic DynamicForm path. Render its own inline card too.
//
// Dispatch BEFORE any hooks so each branch component has a stable
// hook-call order (Rules of Hooks).
if (props.providerName === 'Bedrock') {
return <BedrockInstanceCard {...props} ref={ref} />;
}
if (props.providerName === 'SoMark') {
return <SoMarkInstanceCard {...props} ref={ref} />;
}
return <GenericProviderInstanceCard {...props} ref={ref} />;
});
// Re-export the name section for callers that need to embed it
// (e.g. parent pages with custom layouts).

View File

@@ -28,7 +28,6 @@ import { RAGFlowSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useTranslate } from '@/hooks/common-hooks';
import {
useAddProviderInstance,
useDeleteProviderInstance,
useFetchProviderInstance,
useVerifyProviderConnection,
@@ -37,11 +36,23 @@ import { IProviderInstance } from '@/interfaces/database/llm';
import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm';
import { zodResolver } from '@hookform/resolvers/zod';
import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
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;
@@ -64,24 +75,6 @@ const buildFormatOptions = <T extends keyof typeof FORMAT_LABELS>(
formats: readonly T[],
) => formats.map((value) => ({ label: FORMAT_LABELS[value], value }));
// Field names whose value commits via click (Selects, Switches) rather
// than blur. Their popovers render in Radix portals outside the card's
// blur container, so blur-driven saves don't catch them — a form.watch
// watcher is used instead to schedule a save when they change.
const SOMARK_WATCHED_FIELDS = new Set([
'somark_image_format',
'somark_formula_format',
'somark_table_format',
'somark_cs_format',
'somark_enable_text_cross_page',
'somark_enable_table_cross_page',
'somark_enable_title_level_recognition',
'somark_enable_inline_image',
'somark_enable_table_image',
'somark_enable_image_understanding',
'somark_keep_header_footer',
]);
type SoMarkFormValues = {
llm_name: string;
somark_base_url: string;
@@ -103,23 +96,19 @@ interface SoMarkInstanceCardProps {
providerName: string;
instance: IProviderInstance;
isDraft?: boolean;
onSaved?: (values: Record<string, any>) => void | Promise<void>;
onNameSaved?: (instanceName: string) => void;
onDelete?: () => void;
/**
* When true, this card starts expanded and fetches its instance
* details on mount. Default `false` so non-first cards stay
* collapsed until the user opens them.
*/
defaultOpen?: boolean;
}
/**
* Inline instance card for SoMark. Mirrors the two-stage UX of
* `BedrockInstanceCard` (save name first, then edit fields) but renders
* SoMark-specific fields (model name, base URL, API key, 4 element-format
* selects, 7 feature toggles) directly. The model type is fixed to
* `['ocr']` (SoMark is an OCR provider) and not exposed in the form.
* 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):
@@ -134,28 +123,21 @@ interface SoMarkInstanceCardProps {
* }]
* }
*/
export function SoMarkInstanceCard({
providerName,
instance,
isDraft = false,
onSaved,
onNameSaved,
onDelete,
defaultOpen = false,
}: SoMarkInstanceCardProps) {
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('');
const [nameSaved, setNameSaved] = useState(!isDraft);
const savingRef = useRef(false);
useEffect(() => {
if (isDraft) {
setDraftName('');
setNameSaved(false);
} else {
setNameSaved(true);
}
}, [providerName, isDraft]);
@@ -226,12 +208,19 @@ export function SoMarkInstanceCard({
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_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,
@@ -345,163 +334,6 @@ export function SoMarkInstanceCard({
],
);
const { addProviderInstance } = useAddProviderInstance();
const handleSaveName = useCallback(async () => {
const trimmed = draftName.trim();
if (!trimmed) return;
const ret = await addProviderInstance({
llm_factory: providerName,
instance_name: trimmed,
} as any);
if (ret?.code === 0) {
onNameSaved?.(trimmed);
}
}, [draftName, addProviderInstance, providerName, onNameSaved]);
// Auto-save in draft mode after the name is locked. Debounced on form
// value changes; refuses to fire until validation passes.
useEffect(() => {
if (!isDraft) return;
if (!nameSaved) return;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const sub = form.watch(() => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (cancelled || savingRef.current) return;
const isValid = await form.trigger();
if (cancelled || savingRef.current) return;
if (!isValid) return;
const trimmed = draftName.trim();
if (!trimmed) return;
savingRef.current = true;
try {
const values = form.getValues();
const payload = buildPayload(values, trimmed);
await onSaved?.(payload as unknown as Record<string, any>);
} finally {
savingRef.current = false;
}
}, 200);
});
return () => {
cancelled = true;
if (saveTimeout) clearTimeout(saveTimeout);
try {
sub?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, nameSaved, form, draftName, buildPayload, onSaved]);
// Saved-mode auto-save. Both blur-driven (text inputs) and
// change-driven (Selects / Switches) edits are coalesced through
// a shared debounced `scheduleSave`. Selects render in Radix portals
// outside the card's blur container, and Switches are click-based
// (no blur), so a `form.watch` watcher is needed to catch them.
const blurSavingRef = useRef(false);
const lastSavedSigRef = useRef('');
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const AUTO_SAVE_DEBOUNCE_MS = 500;
const performSave = useCallback(async () => {
if (isDraft) return;
if (blurSavingRef.current) return;
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
const payload = buildPayload(values, instance.instance_name);
const finalPayload = {
...payload,
id: instanceDetails?.id || instance.id,
};
const sig = JSON.stringify(finalPayload);
if (sig === lastSavedSigRef.current) return;
blurSavingRef.current = true;
try {
const ret = await addProviderInstance(finalPayload as any);
if (ret?.code === 0) {
lastSavedSigRef.current = sig;
}
} finally {
blurSavingRef.current = false;
}
}, [
isDraft,
form,
buildPayload,
instance.instance_name,
instance.id,
instanceDetails?.id,
addProviderInstance,
]);
const scheduleSave = useCallback(() => {
if (isDraft) return;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
void performSave();
}, AUTO_SAVE_DEBOUNCE_MS);
}, [isDraft, performSave]);
const handleFieldsBlur = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (isDraft) return;
if (
e.currentTarget.contains(e.relatedTarget as Node | null) &&
e.relatedTarget !== null
) {
return;
}
scheduleSave();
},
[isDraft, scheduleSave],
);
// Dropdown / Switch change-driven save (saved mode only). Text
// inputs are handled by blur; Selects and Switches commit via click
// and their popovers live in portals, so we watch the form directly.
// Only react to user-driven changes (type === 'change'); ignore
// programmatic resets (form.reset when instanceDetails loads).
useEffect(() => {
if (isDraft) return;
if (!instanceDetails) return;
let cancelled = false;
const subscription = form.watch(
(_values: any, meta: { name?: string; type?: string }) => {
if (cancelled) return;
if (meta?.type !== 'change') return;
if (!meta?.name || !SOMARK_WATCHED_FIELDS.has(meta.name)) return;
scheduleSave();
},
);
return () => {
cancelled = true;
try {
subscription?.unsubscribe?.();
} catch {
// ignore
}
};
}, [isDraft, instanceDetails, form, scheduleSave]);
// Clear pending save on unmount.
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, []);
const { deleteProviderInstance } = useDeleteProviderInstance();
const handleDelete = useCallback(async () => {
if (isDraft) {
@@ -520,15 +352,102 @@ export function SoMarkInstanceCard({
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();
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
>
<RAGFlowFormItem name="llm_name" label={tSetting('modelName')} required>
<Input placeholder="somark-from-env-1" />
</RAGFlowFormItem>
@@ -540,7 +459,10 @@ export function SoMarkInstanceCard({
<Input placeholder={tSetting('somark.baseUrlPlaceholder')} />
</RAGFlowFormItem>
<RAGFlowFormItem name="somark_api_key" label={tSetting('somark.apiKey')}>
<RAGFlowFormItem
name="somark_api_key"
label={tSetting('somark.apiKey')}
>
<Input
type="password"
placeholder={tSetting('somark.apiKeyPlaceholder')}
@@ -613,10 +535,7 @@ export function SoMarkInstanceCard({
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
</RAGFlowFormItem>
@@ -626,10 +545,7 @@ export function SoMarkInstanceCard({
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
</RAGFlowFormItem>
@@ -639,10 +555,7 @@ export function SoMarkInstanceCard({
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
</RAGFlowFormItem>
@@ -652,10 +565,7 @@ export function SoMarkInstanceCard({
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
</RAGFlowFormItem>
@@ -665,10 +575,7 @@ export function SoMarkInstanceCard({
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
</RAGFlowFormItem>
@@ -678,10 +585,7 @@ export function SoMarkInstanceCard({
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
</RAGFlowFormItem>
@@ -691,10 +595,7 @@ export function SoMarkInstanceCard({
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
</RAGFlowFormItem>
</form>
@@ -718,7 +619,45 @@ export function SoMarkInstanceCard({
className="border-b border-border-button mb-5 pb-5"
data-testid={`instance-card-${instance.instance_name || 'draft'}`}
>
{nameSaved ? (
{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">
@@ -764,82 +703,19 @@ export function SoMarkInstanceCard({
forceMount
className="data-[state=closed]:hidden overflow-hidden"
>
<div
className="px-2 pb-4 flex flex-col gap-4"
onBlurCapture={handleFieldsBlur}
>
<div className="px-2 pb-4 flex flex-col gap-4">
{renderFields()}
</div>
</CollapsibleContent>
</Collapsible>
) : (
<div className="px-2 py-3 flex flex-col gap-4">
<div
className="flex flex-col gap-1.5"
data-testid="instance-name-section"
>
<label
htmlFor="instance-name-input"
className="text-sm font-medium text-text-primary"
>
<span className="text-destructive mr-0.5">*</span>
{tSetting('instanceName')}
</label>
<div className="flex items-center">
<Input
id="instance-name-input"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={tSetting('instanceNamePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveName();
}
}}
className="flex-1 rounded-r-none"
data-testid="instance-name-input"
/>
<Button
onClick={handleSaveName}
disabled={!draftName.trim()}
data-testid="instance-name-save"
variant="outline"
className="rounded-l-none bg-bg-input shrink-0"
>
{tSetting('save')}
</Button>
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
variant="delete"
size="icon-sm"
className="ml-2 shrink-0"
aria-label={tSetting('deleteInstance')}
data-testid="draft-delete"
>
<Trash2 className="size-4" />
</Button>
</ConfirmDeleteDialog>
</div>
<p
className="text-xs text-text-secondary"
data-testid="instance-name-helper"
>
{tSetting('instanceNameSaveTip')}
</p>
</div>
<fieldset
disabled={!nameSaved}
className="contents disabled:[&_*]:pointer-events-none disabled:opacity-60"
data-testid="instance-locked-fields"
>
{renderFields()}
</fieldset>
</div>
)}
</div>
);
}
});
export default SoMarkInstanceCard;
// 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

@@ -15,22 +15,48 @@
*/
import { LlmIcon } from '@/components/svg-icon';
import { Button } from '@/components/ui/button';
import { APIMapUrl } from '@/constants/llm';
import { useTranslate } from '@/hooks/common-hooks';
import { ArrowUpRight } from 'lucide-react';
import { ArrowUpRight, Loader2, Save } from 'lucide-react';
import { getProviderConfig } from '../provider-schema/field-config';
interface ProviderHeaderBarProps {
providerName: string;
/** Called when the user clicks the batch Save button. */
onSave?: () => void;
/** True while the batch save is in flight - disables the button. */
saving?: boolean;
/**
* True when there is at least one dirty card on the page. When false
* the Save button is disabled (nothing to persist).
*/
canSave?: boolean;
}
/**
* Sticky top bar for the right pane that displays the selected provider's
* icon, name and a doc-link arrow. Stays visible while the user scrolls
* the instance list below.
* icon, name, an API-link arrow, an optional integration-doc link, and a
* batch Save button. Stays visible while the user scrolls the instance
* list below.
*/
export function ProviderHeaderBar({ providerName }: ProviderHeaderBarProps) {
export function ProviderHeaderBar({
providerName,
onSave,
saving = false,
canSave = false,
}: ProviderHeaderBarProps) {
const { t: tSetting } = useTranslate('setting');
const docLink = APIMapUrl[providerName as keyof typeof APIMapUrl];
const apiLink = APIMapUrl[providerName as keyof typeof APIMapUrl];
const providerConfig = getProviderConfig(providerName);
const docLink = providerConfig.docLink;
// Resolve doc-link text: explicit `docLinkText` wins, otherwise translate
// `docLinkI18nKey` with the provider name as the `{{name}}` interpolation.
const docLinkText =
providerConfig.docLinkText ??
(providerConfig.docLinkI18nKey
? tSetting(providerConfig.docLinkI18nKey, { name: providerName })
: null);
return (
<div
@@ -44,9 +70,9 @@ export function ProviderHeaderBar({ providerName }: ProviderHeaderBarProps) {
imgClass="size-6 text-text-primary"
/>
<span className="font-medium text-text-primary">{providerName}</span>
{docLink && (
{apiLink && (
<a
href={docLink}
href={apiLink}
target="_blank"
rel="noopener noreferrer"
className="text-text-secondary hover:text-text-primary"
@@ -55,6 +81,33 @@ export function ProviderHeaderBar({ providerName }: ProviderHeaderBarProps) {
<ArrowUpRight className="size-4" />
</a>
)}
<div className="w-5" />
{docLink && docLinkText && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-end self-end gap-1 text-xs text-text-secondary hover:text-text-primary"
>
<span>{docLinkText}</span>
</a>
)}
<div className="flex-1" />
<Button
type="button"
size="sm"
onClick={onSave}
disabled={saving || !canSave}
data-testid="provider-save-all"
className="gap-1.5"
>
{saving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
{tSetting('saveAll')}
</Button>
</div>
);
}