mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-18 21:57:27 +08:00
fix(model-provider): replace per-card auto-save with batch Save-all (#17025)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user