Files
ragflow/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx
2026-07-08 09:47:29 +08:00

817 lines
30 KiB
TypeScript

/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynamicFormRef, FormFieldType } from '@/components/dynamic-form';
import {
useAddProviderInstance,
useDeleteProviderInstance,
useFetchAvailableProviders,
useFetchProviderInstance,
useUpdateProviderInstance,
useVerifyProviderConnection,
} from '@/hooks/use-llm-request';
import { IProviderInstance } from '@/interfaces/database/llm';
import {
IModelInfo,
IUpdateProviderInstanceRequestBody,
} from '@/interfaces/request/llm';
import { RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { useProviderFields } from '../provider-schema/hooks';
import { SelectOption } from '../provider-schema/types';
import { API_KEY_NESTED_FIELDS, ApiKeyNestedField } from './interface';
// ---------------------------------------------------------------------------
// Pure helpers — api_key shape normalization
// ---------------------------------------------------------------------------
/**
* Build the `api_key` payload value from the flat form values. If any of
* the nested credential fields (see `API_KEY_NESTED_FIELDS`) carry a
* value, returns `{ api_key, ...nested }`; otherwise returns the plain
* api_key string. Used by both the auto-save payload and its change
* signature baseline so the two stay byte-identical.
*/
export function buildApiKeyValue(
values: Record<string, any>,
): string | Record<string, any> | undefined {
const nested: Record<string, any> = {};
for (const field of API_KEY_NESTED_FIELDS) {
const v = values[field];
if (v !== undefined && v !== '') nested[field] = v;
}
if (Object.keys(nested).length === 0) return values.api_key;
return { api_key: values.api_key ?? '', ...nested };
}
/**
* Inverse of `buildApiKeyValue`, used on the echo/prefill path. The
* backend persists the credential bundle as a JSON string (see
* `rag/llm/chat_model.py`, which does `json.loads(key)`), so
* `showProviderInstance` may return `api_key` as:
* 1. a raw JSON string `'{"api_key":"sk-x","group_id":"123"}'`
* 2. an already-parsed object `{ api_key, group_id }`
* 3. a plain bare key string `'sk-x'`
* Normalise all three into the bare key plus any nested credential
* fields so the form pre-fills group_id / api_version / provider_order.
*/
export function unwrapApiKey(raw: unknown): {
apiKey: string;
nested: Record<string, any>;
} {
let obj: any = raw;
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
obj = JSON.parse(trimmed);
} catch {
obj = raw;
}
}
}
if (obj && typeof obj === 'object') {
const nested: Record<string, any> = {};
for (const field of API_KEY_NESTED_FIELDS) {
const v = obj[field];
if (v !== undefined && v !== '') nested[field] = v;
}
return { apiKey: obj.api_key ?? '', nested };
}
return { apiKey: typeof raw === 'string' ? raw : '', nested: {} };
}
/** Pick the value associated with the `default` region, if present. */
function pickDefaultUrl(
options?: Array<{ value: string; regionKey?: string }>,
): string | undefined {
return options?.find((o) => o.regionKey === 'default')?.value;
}
// ---------------------------------------------------------------------------
// useProviderBaseUrlOptions — fetch provider catalog and build URL options
// ---------------------------------------------------------------------------
/**
* Fetch the catalog of available providers and derive the
* `base_url` / `api_base` dropdown options for the current provider.
* Used to pre-fill the URL field with the provider's default URL when
* creating a new instance.
*/
export function useProviderBaseUrlOptions(providerName: string) {
const { data: availableProviders } = useFetchAvailableProviders();
const currentProvider = useMemo(
() =>
providerName
? availableProviders.find((p) => p.name === providerName)
: undefined,
[availableProviders, providerName],
);
const baseUrlOptions = useMemo(() => {
const urlObj = currentProvider?.url as
| Record<string, string | undefined>
| undefined;
if (!urlObj) return undefined;
const options = Object.keys(urlObj)
.filter((k) => !!urlObj[k])
.map((k) => {
const v = urlObj[k] as string;
return {
value: v,
regionKey: k,
label: (
<div className="flex justify-between items-center gap-2">
<span className="truncate">{v}</span>
<span className="text-xs text-text-secondary bg-bg-card px-2 py-0.5 rounded-sm shrink-0">
{k}
</span>
</div>
),
};
});
return options.length > 0 ? options : undefined;
}, [currentProvider]);
return { baseUrlOptions, availableProviders };
}
// ---------------------------------------------------------------------------
// useProviderInitialValues — form initial values for draft vs saved
// ---------------------------------------------------------------------------
/**
* Build the form's initial values from the persisted instance, the
* lazy-loaded `showProviderInstance` details, and the provider's default
* base URL.
*
* - Draft: empty form, with `base_url` / `api_base` pre-filled from the
* provider's `default` URL when available.
* - Saved: prefer `instanceDetails` (which carries api_key / base_url);
* normalise api_key into the bare key plus any nested credential
* fields (see {@link unwrapApiKey}).
*/
export function useProviderInitialValues(
instance: IProviderInstance,
instanceDetails: IProviderInstance | undefined,
isDraft: boolean,
baseUrlOptions: SelectOption[] | undefined,
) {
return useMemo(() => {
const defaultBaseUrl = pickDefaultUrl(baseUrlOptions);
if (isDraft) {
const values: Record<string, any> = { instance_name: '' };
if (defaultBaseUrl) {
values.base_url = defaultBaseUrl;
values.api_base = defaultBaseUrl;
}
return values;
}
const merged: IProviderInstance = {
...instance,
...(instanceDetails ?? ({} as IProviderInstance)),
};
const values: Record<string, any> = {
instance_name: merged.instance_name,
};
// api_key may come back as a JSON string, an already-parsed object,
// or a plain bare key (see `unwrapApiKey`). Normalise it so the
// api_key text field shows the bare key and the nested credential
// fields (MiniMax group_id, Azure api_version, OpenRouter
// provider_order) pre-fill their own form inputs.
if (merged.api_key) {
const { apiKey, nested } = unwrapApiKey(merged.api_key);
values.api_key = apiKey;
Object.assign(values, nested);
}
if (merged.base_url) {
values.base_url = merged.base_url;
values.api_base = merged.base_url;
} else if (defaultBaseUrl) {
values.base_url = defaultBaseUrl;
values.api_base = defaultBaseUrl;
}
// The /providers/<p>/instances/<i> endpoint also returns `region`
// for providers where it applies; surface it so the form / region
// submit logic can echo it back.
if ((merged as any).region) values.region = (merged as any).region;
// Fallback: some backends may still surface these credential fields
// at the top level rather than nested in api_key — echo any that
// weren't already lifted from the api_key object above.
for (const field of API_KEY_NESTED_FIELDS) {
if (values[field] === undefined && (merged as any)[field] !== undefined) {
values[field] = (merged as any)[field];
}
}
return values;
}, [instance, instanceDetails, isDraft, baseUrlOptions]);
}
// ---------------------------------------------------------------------------
// useLazyInstanceDetails — fetch full instance details when card opens
// ---------------------------------------------------------------------------
/**
* The list endpoint (`useFetchProviderInstances`) does not return
* sensitive/heavy fields like `api_key` or `base_url`. Pull the full
* instance via `showProviderInstance` so the form can be pre-filled when
* the user clicks an existing provider on the left. The hook is
* `enabled: false` by default — we trigger it manually here so we
* don't change behavior of other call sites.
*/
export function useLazyInstanceDetails(
providerName: string,
instanceName: string,
isDraft: boolean,
open: boolean,
) {
const { data: instanceDetails, refetch: refetchInstanceDetails } =
useFetchProviderInstance(
isDraft ? '' : providerName,
isDraft ? '' : instanceName,
);
useEffect(() => {
if (!isDraft && open && providerName && instanceName) {
refetchInstanceDetails();
}
}, [isDraft, open, providerName, instanceName, refetchInstanceDetails]);
return { instanceDetails, refetchInstanceDetails };
}
// ---------------------------------------------------------------------------
// useFormResetOnDetailsLoad — re-fill form when instanceDetails resolves
// ---------------------------------------------------------------------------
/**
* React-Hook-Form only consumes `defaultValues` on first mount, so we
* explicitly reset the form here to make the freshly-fetched values
* visible. We use `keepDirtyValues` so the user's in-progress edits
* (if any) are not clobbered by a background refetch.
*/
export function useFormResetOnDetailsLoad(
formRef: RefObject<DynamicFormRef>,
formDefaultValues: Record<string, any>,
instanceDetails: IProviderInstance | undefined,
isDraft: boolean,
) {
useEffect(() => {
if (isDraft) return;
if (!instanceDetails) return;
const form = (formRef.current as any)?.form;
if (form?.reset) {
form.reset(formDefaultValues, { keepDirtyValues: true });
} else {
formRef.current?.reset?.(formDefaultValues);
}
}, [isDraft, instanceDetails, formDefaultValues, formRef]);
}
// ---------------------------------------------------------------------------
// useVerifyProvider — wraps useVerifyProviderConnection for the card
// ---------------------------------------------------------------------------
/**
* Adapter that reads the current form values and proxies them into
* `verifyProviderConnection`, then shapes the response into the
* `VerifyResult` consumed by {@link VerifyButton}.
*/
export function useVerifyProvider(
providerName: string,
formRef: RefObject<DynamicFormRef>,
) {
const { verifyProviderConnection } = useVerifyProviderConnection();
return useCallback(
async (params: any) => {
const values = { ...(formRef.current?.getValues?.() ?? {}), ...params };
const ret = await verifyProviderConnection({
provider_name: providerName,
api_key: values.api_key ?? '',
base_url: values.base_url ?? values.api_base,
model_info: values.model_info,
});
if (ret.code === 0) {
return { isValid: true, logs: ret.message } as {
isValid: boolean;
logs: string;
};
}
return { isValid: false, logs: ret.message } as {
isValid: boolean;
logs: string;
};
},
[providerName, formRef, verifyProviderConnection],
);
}
// ---------------------------------------------------------------------------
// useSaveInstanceName — dedicated hook for the draft name Save button
// ---------------------------------------------------------------------------
/**
* Save the instance name on its own. Calls `addProviderInstance` with
* only the instance name (backend now supports creating an instance with
* just a name). On success notifies the parent via `onNameSaved` so it
* can remove this draft — the invalidated `providerInstances` query
* will surface the persisted card automatically.
*/
export function useSaveInstanceName(
providerName: string,
draftName: string,
onNameSaved?: () => void,
) {
const { addProviderInstance } = useAddProviderInstance();
return useCallback(async () => {
const trimmed = draftName.trim();
if (!trimmed) return;
const ret = await addProviderInstance({
llm_factory: providerName,
instance_name: trimmed,
} as any);
if (ret?.code === 0) {
onNameSaved?.();
}
}, [draftName, addProviderInstance, providerName, onNameSaved]);
}
// ---------------------------------------------------------------------------
// useDeleteInstance — wires the card's delete button
// ---------------------------------------------------------------------------
/**
* Delete handler: for saved instances calls `useDeleteProviderInstance`;
* for drafts calls `onDelete` (which maps to onCancel in the parent).
*/
export function useDeleteInstance(
providerName: string,
instanceName: string,
isDraft: boolean,
onDelete?: () => void,
) {
const { deleteProviderInstance } = useDeleteProviderInstance();
return useCallback(async () => {
if (isDraft) {
onDelete?.();
} else {
await deleteProviderInstance({
provider_name: providerName,
instances: [instanceName],
});
}
}, [isDraft, providerName, instanceName, deleteProviderInstance, onDelete]);
}
// ---------------------------------------------------------------------------
// useDraftAutoSave — 200ms-debounced watch-based save for draft mode
// ---------------------------------------------------------------------------
/**
* Auto-save: whenever the form's other fields change (in draft mode),
* watch the form values and, after a 200ms debounce (acting as a blur
* proxy — fires shortly after the user stops typing / blurs out of a
* field), trigger validation. If all required fields are valid AND
* the instance name has been entered and saved, call `onSaved` with
* the merged values.
*/
export function useDraftAutoSave(
formRef: RefObject<DynamicFormRef>,
isDraft: boolean,
nameSaved: boolean,
draftName: string,
onSaved: ((values: Record<string, any>) => void | Promise<void>) | undefined,
modelInfoRef: { current: IModelInfo[] },
) {
// Keep the latest `onSaved` and `draftName` in refs so the auto-save
// effect below can read them without re-subscribing on every render
// (the parent passes a fresh `onSaved` arrow each render).
const onSavedRef = useRef(onSaved);
useEffect(() => {
onSavedRef.current = onSaved;
});
const draftNameRef = useRef(draftName);
useEffect(() => {
draftNameRef.current = draftName;
});
useEffect(() => {
if (!isDraft) return;
const formInstance = (formRef.current as any)?.form;
if (!formInstance || typeof formInstance.watch !== 'function') return;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const savingRef = { current: false };
const subscription = formInstance.watch(() => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (cancelled || savingRef.current) return;
const isValid = await formRef.current?.trigger();
if (cancelled || savingRef.current) return;
if (!isValid) return;
// Name gate: refuse to actually save if the name is empty or
// has not been "saved" (locked). The red border on the name
// section is the visible signal — it stays on while
// `!nameSaved` regardless of whether the user has touched
// other fields.
if (!draftNameRef.current.trim() || !nameSaved) return;
if (!onSavedRef.current) return;
savingRef.current = true;
try {
const values = formRef.current?.getValues?.() ?? {};
const payload: Record<string, any> = {
...values,
instance_name: draftNameRef.current.trim(),
};
// Forward the latest per-instance model list as `model_info`
// when the user has attached models to the draft. The list
// is normally empty for drafts (the backend has nothing yet),
// but it can be populated once ModelsSection has fetched /
// received data — we only set the key when non-empty so an
// absent value is preserved as `undefined` rather than
// forcing an empty-array clear on the server.
if (modelInfoRef.current.length > 0) {
payload.model_info = modelInfoRef.current;
}
await onSavedRef.current(payload);
} finally {
savingRef.current = false;
}
}, 200);
});
return () => {
cancelled = true;
if (saveTimeout) clearTimeout(saveTimeout);
try {
subscription?.unsubscribe?.();
} catch {
// ignore cleanup errors
}
};
}, [isDraft, nameSaved, formRef, modelInfoRef]);
}
// ---------------------------------------------------------------------------
// useSavedAutoSave — blur + dropdown-driven auto-save for saved cards
// ---------------------------------------------------------------------------
/** Field types whose value is committed via click/select (not blur). The
* card's `onBlurCapture` auto-save fires before the dropdown click
* handler commits the new value, and the popover content is rendered in
* a Radix portal outside the card's blur container, so blur-based saves
* are unreliable for these. We watch the form values directly and
* trigger the same auto-save on value change. */
const DROPDOWN_FIELD_TYPES = new Set<FormFieldType>([
FormFieldType.Select,
FormFieldType.MultiSelect,
FormFieldType.Segmented,
// `Custom` is the form-field type used by `inputSelect` in this
// codebase (see use-provider-fields). Every `Custom` field rendered
// inside the provider instance card is an `InputSelect` dropdown.
FormFieldType.Custom,
]);
interface UseSavedAutoSaveArgs {
formRef: RefObject<DynamicFormRef>;
formFields: Array<{ name: string; type: FormFieldType; [k: string]: any }>;
providerName: string;
instanceName: string;
instanceId: string | undefined;
isDraft: boolean;
instanceDetails: IProviderInstance | undefined;
initialValues: Record<string, any>;
modelInfoRef: { current: IModelInfo[] };
}
/**
* Wires the blur-driven + dropdown-driven auto-save flow used by saved
* (non-draft) cards. Returns a `handleFieldsBlur` for the card body to
* attach to `onBlurCapture`, plus a `markModelsEdited` callback for
* `onInstanceModelsEdited` so the next auto-save short-circuits after
* a PATCH-driven model change.
*/
export function useSavedAutoSave({
formRef,
formFields,
providerName,
instanceName,
instanceId,
isDraft,
instanceDetails,
initialValues,
modelInfoRef,
}: UseSavedAutoSaveArgs) {
const { updateProviderInstance } = useUpdateProviderInstance();
const blurSavingRef = useRef(false);
const blurSuppressRef = useRef(false);
const lastSavedPayloadRef = useRef<string>('');
const hasSyncedInstanceRef = useRef(false);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const AUTO_SAVE_DEBOUNCE_MS = 500;
// Shared auto-save routine. Triggered by:
// - `handleFieldsBlur` (focus leaves a non-dropdown field), and
// - the dropdown value-change watcher (a dropdown field's value
// commits via click, not blur — and the popover is rendered in a
// Radix portal outside the card's blur container, so blur-based
// saves are unreliable for dropdowns).
const performAutoSave = useCallback(async () => {
if (isDraft) return;
if (blurSavingRef.current) return;
if (blurSuppressRef.current) return;
const isValid = await formRef.current?.trigger();
if (!isValid) return;
const values = formRef.current?.getValues?.() ?? {};
const resolvedId = instanceDetails?.id || instanceId;
// Providers like MiniMax / Azure-OpenAI / OpenRouter carry extra
// credential fields (group_id / api_version / provider_order) that
// the backend expects bundled *inside* api_key as an object rather
// than as top-level keys. Nesting them here also folds their values
// into the change signature below, so editing one actually triggers
// a blur-save.
const apiKeyValue = buildApiKeyValue(values as Record<string, any>);
const payload: IUpdateProviderInstanceRequestBody = {
provider_name: providerName,
instance_name: instanceName,
id: resolvedId,
api_key: apiKeyValue ?? '',
base_url: values.base_url ?? values.api_base,
region: values.region || 'default',
model_info: [],
verify: false,
};
// Pull the latest model list from ModelsSection (via the ref it
// updates). Only attach when non-empty so we don't accidentally
// wipe the persisted model set with an empty array.
if (modelInfoRef.current.length > 0) {
payload.model_info = modelInfoRef.current;
}
// Skip if nothing actually changed since the last save (or initial
// mount): prevents a no-op PUT on every focus shift.
const signature = JSON.stringify(payload);
if (signature === lastSavedPayloadRef.current) return;
blurSavingRef.current = true;
try {
const ret = await updateProviderInstance(payload);
if (ret?.code === 0) {
lastSavedPayloadRef.current = signature;
hasSyncedInstanceRef.current = true;
}
} finally {
blurSavingRef.current = false;
}
}, [
isDraft,
providerName,
instanceName,
instanceId,
instanceDetails?.id,
updateProviderInstance,
formRef,
modelInfoRef,
]);
// Debounced auto-save: coalesces rapid edits (blur cascade,
// successive dropdown changes, typing in filterable dropdowns) into
// a single delayed `performAutoSave` call.
const scheduleAutoSave = useCallback(() => {
if (isDraft) return;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
void performAutoSave();
}, AUTO_SAVE_DEBOUNCE_MS);
}, [isDraft, performAutoSave]);
const handleFieldsBlur = useCallback(
async (e: React.FocusEvent<HTMLDivElement>) => {
if (isDraft) return;
// Ignore focus moves that stay inside the same container.
if (
e.currentTarget.contains(e.relatedTarget as Node | null) &&
e.relatedTarget !== null
) {
return;
}
scheduleAutoSave();
},
[isDraft, scheduleAutoSave],
);
// Refs so the dropdown watcher effect can invoke the latest callbacks
// without re-subscribing on every render (the parent passes a fresh
// `onBlurCapture` arrow each render, and `performAutoSave` changes
// whenever its deps change — e.g. when `instanceDetails` loads).
const performAutoSaveRef = useRef(performAutoSave);
useEffect(() => {
performAutoSaveRef.current = performAutoSave;
});
const scheduleAutoSaveRef = useRef<() => void>(() => {});
useEffect(() => {
scheduleAutoSaveRef.current = scheduleAutoSave;
});
// Clear any pending debounced save when the card unmounts so we don't
// fire a stale request after teardown.
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, []);
// Seed the "last saved" signature once initial values are loaded so
// the first blur after mount doesn't trigger an unnecessary save.
useEffect(() => {
if (isDraft) return;
const resolvedId = instanceDetails?.id || instanceId;
if (!resolvedId) return;
// Match the api_key shape performAutoSave produces (extra credential
// fields nested inside api_key) so the first blur after mount
// doesn't see a signature diff and fire a redundant save. model_info
// is omitted for the same reason as in performAutoSave: model
// changes are owned by the per-model endpoints, not this auto-save.
const baseline = {
provider_name: providerName,
instance_name: instanceName,
id: resolvedId,
api_key: buildApiKeyValue(initialValues),
base_url: initialValues.base_url ?? initialValues.api_base,
region: initialValues.region,
model_info: [] as IModelInfo[],
};
lastSavedPayloadRef.current = JSON.stringify(baseline);
}, [
isDraft,
providerName,
instanceName,
instanceId,
instanceDetails?.id,
initialValues,
]);
// Dropdown value-change auto-save (saved mode only). A dropdown
// field's value commits via click, not blur — and the popover is
// rendered in a Radix portal outside the card's blur container, so
// blur-based saves are unreliable for dropdowns.
//
// We subscribe to the *raw* RHF form so we can read the change
// metadata `{ name, type }`. Only genuine user edits carry
// `type === 'change'`; programmatic updates (the `form.reset` that
// runs when `instanceDetails` loads, `setValue`, etc.) come through
// with an undefined type. Gating on `type === 'change'` is what stops
// merely opening the card / loading its details from firing a save —
// only an actual user selection in a dropdown schedules one. The
// shared debounce (`scheduleAutoSave`) coalesces it with any
// blur-triggered save. Skipped in draft mode (which has its own
// 200ms-debounced watch) and until `instanceDetails` loads.
const dropdownFieldNames = useMemo(
() =>
formFields
.filter((f) => DROPDOWN_FIELD_TYPES.has(f.type))
.map((f) => f.name),
[formFields],
);
useEffect(() => {
if (isDraft) return;
if (dropdownFieldNames.length === 0) return;
if (!instanceDetails) return;
const form = (formRef.current as any)?.form;
if (!form || typeof form.watch !== 'function') return;
let cancelled = false;
const dropdownFieldSet = new Set(dropdownFieldNames);
const subscription = form.watch(
(_values: any, meta: { name?: string; type?: string }) => {
if (cancelled) return;
// Ignore programmatic value changes (reset/setValue) — they have
// no `type`. Only react to user-driven dropdown selections.
if (meta?.type !== 'change') return;
if (!meta?.name || !dropdownFieldSet.has(meta.name)) return;
scheduleAutoSaveRef.current();
},
);
return () => {
cancelled = true;
try {
subscription?.unsubscribe?.();
} catch {
// ignore cleanup errors
}
};
}, [isDraft, dropdownFieldNames, instanceDetails, formRef]);
// Absorb a model patch into the host's last-saved baseline. When the
// user saves the edit modal, patchInstanceModel has already persisted
// the new max_tokens / model_type / features server-side, so the next
// blur auto-save should NOT re-PUT the same model_info. By parsing
// the previously-saved payload and overwriting ONLY model_info, the
// baseline now matches the current state and the signature check in
// performAutoSave short-circuits — while any in-flight edits to
// api_key / base_url / region remain in `lastSavedPayloadRef`
// unchanged and will still trigger a save on blur via signature
// mismatch.
//
// Skipped until the host has synced at least once. Before that the
// baseline still carries the initial `model_info: []`; rewriting it
// here would skip the very first PUT that syncs the user's first
// add/edit into the persisted model_info.
const markModelsEdited = useCallback(() => {
if (isDraft) return;
if (!hasSyncedInstanceRef.current) return;
const prev = lastSavedPayloadRef.current;
if (!prev) return;
const parsed = JSON.parse(prev) as IUpdateProviderInstanceRequestBody;
parsed.model_info =
modelInfoRef.current.length > 0 ? modelInfoRef.current : [];
// Mirror the `verify: false` field that performAutoSave always
// attaches, otherwise the next signature comparison would diff on
// this key alone and re-fire the save.
(parsed as any).verify = false;
lastSavedPayloadRef.current = JSON.stringify(parsed);
}, [isDraft, modelInfoRef]);
return {
handleFieldsBlur,
performAutoSave,
scheduleAutoSave,
blurSuppressRef,
markModelsEdited,
};
}
// ---------------------------------------------------------------------------
// useFormFields — wraps useProviderFields and strips instance_name
// ---------------------------------------------------------------------------
/**
* Wraps `useProviderFields` and removes the `instance_name` field from
* both the field list and the default values — the card header owns
* the instance name (editable on hover), so we keep a single source of
* truth and avoid showing it twice in the form.
*/
export function useFormFields(
providerName: string,
isDraft: boolean,
initialValues: Record<string, any>,
baseUrlOptions: SelectOption[] | undefined,
hideWhenInstanceExists: (values: any) => boolean,
) {
const { fields, defaultValues } = useProviderFields({
llmFactory: providerName,
editMode: !isDraft,
viewMode: isDraft,
initialValues,
baseUrlOptions,
hideWhenInstanceExists,
});
const formFields = useMemo(
() => fields.filter((f) => f.name !== 'instance_name'),
[fields],
);
const formDefaultValues = useMemo(() => {
const { instance_name: _ignored, ...rest } = (defaultValues ??
{}) as Record<string, any>;
void _ignored;
return rest;
}, [defaultValues]);
return { formFields, formDefaultValues };
}
// Re-export ApiKeyNestedField for components that need it.
export type { ApiKeyNestedField };