diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index b08843487c..7c1cfd0932 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index a04f365137..bf041ba12f 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -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', diff --git a/web/src/pages/user-setting/setting-model/index.tsx b/web/src/pages/user-setting/setting-model/index.tsx index dcf3d203a2..eed39f74b1 100644 --- a/web/src/pages/user-setting/setting-model/index.tsx +++ b/web/src/pages/user-setting/setting-model/index.tsx @@ -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('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([]); + 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>( + 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) => { - 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 = () => { ) : ( <> - {/* Sticky top: provider name + doc-link arrow */} - + {/* Sticky top: provider name + doc-link arrow + batch Save */} + {/* Scrollable middle: instance cards + optional draft cards */}
@@ -213,6 +297,7 @@ const SettingModelV2: FC = () => { {instances.map((instance, index) => ( { {draftIds.map((id) => ( handleDraftCancel(id)} - onNameSaved={(instanceName) => - handleNameSaved(id, instanceName) - } - onSaved={(values) => handleDraftSave(id, values)} /> ))} -
+
+ +
+
+ + {renderFields()} + +
+ form.getValues()} + /> +
+
+ ) : (
@@ -677,10 +643,7 @@ export function BedrockInstanceCard({ forceMount className="data-[state=closed]:hidden overflow-hidden" > -
+
{renderFields()}
@@ -691,93 +654,19 @@ export function BedrockInstanceCard({ hideActions={false} hideIfEmpty={false} getFormValues={() => form.getValues()} - onBlurSuppressChange={(s) => { - blurSuppressRef.current = s; - }} />
- ) : ( -
-
- -
- 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" - /> - - - - -
-

- {tSetting('instanceNameSaveTip')} -

-
- -
- {renderFields()} - -
- form.getValues()} - /> -
-
-
)}
); -} +}); 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 }; diff --git a/web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx index 4ee6757d37..97671d3c59 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx @@ -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 - * `
` 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({ -
- } - fields={formFields} - onSubmit={() => undefined} - defaultValues={formDefaultValues} - labelClassName="font-normal" + } + fields={formFields} + onSubmit={() => undefined} + defaultValues={formDefaultValues} + labelClassName="font-normal" + /> + +
+ +
-
- -
- -
- formRef.current?.getValues?.() ?? {}} - onInstanceModelsChange={(info) => { - modelInfoRef.current = info; - }} - onInstanceModelsEdited={handleInstanceModelsEdited} - /> -
-
+
+ formRef.current?.getValues?.() ?? {}} + onInstanceModelsChange={(info) => { + modelInfoRef.current = info; + }} + onInstanceModelsEdited={handleInstanceModelsEdited} + /> +
); } diff --git a/web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx b/web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx index 4c399c5d49..38c0574a2d 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx @@ -23,20 +23,19 @@ import { Trash2 } from 'lucide-react'; export interface InstanceNameSectionProps { draftName: string; setDraftName: (name: string) => void; - handleSaveName: () => Promise; handleDelete: () => Promise; } /** * 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" /> - -
- {draftName || instanceName} -
+ {renaming ? ( + 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" + /> + ) : ( +
{ + e.stopPropagation(); + startRename(); + }} + title={tSetting('editInstanceName')} + data-testid="instance-name-static" + > + {draftName || displayName} +
+ )} + + + + + {renderFields()} + + ) : (
@@ -764,82 +703,19 @@ export function SoMarkInstanceCard({ forceMount className="data-[state=closed]:hidden overflow-hidden" > -
+
{renderFields()}
- ) : ( -
-
- -
- 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" - /> - - - - -
-

- {tSetting('instanceNameSaveTip')} -

-
- -
- {renderFields()} -
-
)}
); -} +}); 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 }; diff --git a/web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx b/web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx index a4d7f2448a..8969132486 100644 --- a/web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx +++ b/web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx @@ -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 (
{providerName} - {docLink && ( + {apiLink && ( )} +
+ {docLink && docLinkText && ( + + {docLinkText} + + )} +
+
); }