From 326893811a6ffb4d785f118c40b832d9e6aa9079 Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Tue, 28 Jul 2026 09:49:57 +0800 Subject: [PATCH] fix(web): identify provider instances by id and skip clean cards on save (#17424) --- web/src/hooks/use-llm-request.tsx | 23 +++++-------------- web/src/interfaces/request/llm.ts | 4 ++-- .../user-setting/setting-model/index.tsx | 16 ++++++------- .../instance-card/bedrock-instance-card.tsx | 8 ++++++- .../setting-model/instance-card/hooks.tsx | 19 +++++++++------ .../instance-card/models-section/hooks.ts | 1 + .../instance-card/provider-instance-card.tsx | 2 +- .../instance-card/somark-instance-card.tsx | 3 ++- web/src/utils/api.ts | 12 +++++----- 9 files changed, 44 insertions(+), 44 deletions(-) diff --git a/web/src/hooks/use-llm-request.tsx b/web/src/hooks/use-llm-request.tsx index d1181f2cb8..651e5401d8 100644 --- a/web/src/hooks/use-llm-request.tsx +++ b/web/src/hooks/use-llm-request.tsx @@ -57,13 +57,8 @@ export const LlmKeys = { [LLMApiAction.AllModels, modelType] as const, providerInstances: (providerName: string) => [LLMApiAction.AddedProviders, providerName, 'instances'] as const, - providerInstance: (providerName: string, instanceName: string) => - [ - LLMApiAction.AddedProviders, - providerName, - instanceName, - 'instance', - ] as const, + providerInstance: (providerName: string, id: string) => + [LLMApiAction.AddedProviders, providerName, id, 'instance'] as const, instanceModels: (providerName: string, instanceName: string) => [ LLMApiAction.AddedProviders, @@ -165,18 +160,15 @@ export const useFetchProviderInstances = (providerName: string) => { return { data, loading }; }; -export const useFetchProviderInstance = ( - providerName: string, - instanceName: string, -) => { +export const useFetchProviderInstance = (providerName: string, id: string) => { return useQuery({ - queryKey: LlmKeys.providerInstance(providerName, instanceName), + queryKey: LlmKeys.providerInstance(providerName, id), initialData: undefined as unknown as IProviderInstance, gcTime: 0, enabled: false, queryFn: async () => { const { data } = await llmService.showProviderInstance( - { provider_name: providerName, instance_name: instanceName }, + { provider_name: providerName, id }, true, ); return (data?.data ?? {}) as IProviderInstance; @@ -584,10 +576,7 @@ export const useUpdateProviderInstance = () => { queryKey: LlmKeys.providerInstances(params.provider_name), }); queryClient.invalidateQueries({ - queryKey: LlmKeys.providerInstance( - params.provider_name, - params.instance_name, - ), + queryKey: LlmKeys.providerInstance(params.provider_name, params.id), }); queryClient.invalidateQueries({ queryKey: LlmKeys.instanceModels( diff --git a/web/src/interfaces/request/llm.ts b/web/src/interfaces/request/llm.ts index 02e8318b10..d0d30b5634 100644 --- a/web/src/interfaces/request/llm.ts +++ b/web/src/interfaces/request/llm.ts @@ -54,7 +54,7 @@ export interface IDeleteProviderInstanceRequestBody { export interface IShowProviderInstanceRequestParams { provider_name: string; - instance_name: string; + id: string; } export interface IAddInstanceModelRequestBody { @@ -104,7 +104,7 @@ export interface IDeleteInstanceModelsRequestBody { export interface IUpdateProviderInstanceRequestBody { provider_name: string; instance_name: string; - id?: string; + id: string; /** * Either a plain API-key string, or — for providers that need an * extra credential such as MiniMax's `group_id` — an object bundling diff --git a/web/src/pages/user-setting/setting-model/index.tsx b/web/src/pages/user-setting/setting-model/index.tsx index eed39f74b1..4408ea05da 100644 --- a/web/src/pages/user-setting/setting-model/index.tsx +++ b/web/src/pages/user-setting/setting-model/index.tsx @@ -176,20 +176,18 @@ const SettingModelV2: FC = () => { ); if (refs.length === 0) return; - // 1. Validate every card up front. Block all saves if any is invalid. + const dirty = refs + .map((r) => ({ ref: r, payload: r.getSavePayload() })) + .filter((e) => e.payload !== null); + if (dirty.length === 0) return; + const validations = await Promise.all( - refs.map(async (r) => ({ ref: r, valid: await r.validate() })), + dirty.map(async (e) => ({ ...e, valid: await e.ref.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 @@ -200,7 +198,7 @@ const SettingModelV2: FC = () => { // 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) { + for (const { ref, payload } of validations) { if (!payload) continue; if (payload.apiKind === 'add') { const ret = await addProviderInstance(payload.payload as any); diff --git a/web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx index 907a226715..bd39d82afa 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx @@ -167,7 +167,7 @@ export const BedrockInstanceCard = forwardRef< const { data: instanceDetails, refetch: refetchInstanceDetails } = useFetchProviderInstance( isDraft ? '' : providerName, - isDraft ? '' : instance.instance_name, + isDraft ? '' : instance.id, ); // Lazily fetch full instance details only when the card is open. @@ -365,6 +365,12 @@ export const BedrockInstanceCard = forwardRef< apiKind: 'add' as const, }; } + // Skip cards the user hasn't actually edited. Collapsed cards + // never fetch `instanceDetails`, so their form is still on empty + // defaults - the signature check below would false-positive on + // defaulting differences. `form.formState.isDirty` tracks real + // user interaction. + if (!form.formState.isDirty) return null; const values = form.getValues(); const payload = buildPayload(values, instance.instance_name); const finalPayload = { diff --git a/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx b/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx index 341452474e..0c9f49a3fb 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx @@ -232,21 +232,21 @@ export function useProviderInitialValues( */ export function useLazyInstanceDetails( providerName: string, - instanceName: string, + instanceId: string, isDraft: boolean, open: boolean, ) { const { data: instanceDetails, refetch: refetchInstanceDetails } = useFetchProviderInstance( isDraft ? '' : providerName, - isDraft ? '' : instanceName, + isDraft ? '' : instanceId, ); useEffect(() => { - if (!isDraft && open && providerName && instanceName) { + if (!isDraft && open && providerName && instanceId) { refetchInstanceDetails(); } - }, [isDraft, open, providerName, instanceName, refetchInstanceDetails]); + }, [isDraft, open, providerName, instanceId, refetchInstanceDetails]); return { instanceDetails, refetchInstanceDetails }; } @@ -590,9 +590,14 @@ export function useInstanceSaveState({ // `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. + // cards it returns `null` when the card hasn't been touched by the + // user, so the parent skips both validation and the no-op PUT. const getSavePayload = useCallback((): InstanceSavePayload | null => { + if (!isDraft) { + const formDirty = formRef.current?.isDirty() ?? false; + const renamed = editedNameRef.current !== instanceName; + if (!formDirty && !renamed) return null; + } const payload = buildPayload(); if (!payload) return null; if (!isDraft) { @@ -610,7 +615,7 @@ export function useInstanceSaveState({ // `IUpdateProviderInstanceRequestBody`). apiKind: isDraft ? 'add' : 'update', }; - }, [buildPayload, isDraft, instanceName]); + }, [buildPayload, isDraft, instanceName, formRef]); // After a successful save the parent calls `markSaved()` so the // baseline catches up to the just-persisted values. Without this, diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts index 31eba5f379..d30629491e 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts @@ -592,6 +592,7 @@ export function useModelMutations({ const { apiKey, baseUrl } = resolveCreds(); await updateProviderInstance({ provider_name: providerName, + id: instance!.id, instance_name: instanceName, api_key: apiKey, base_url: baseUrl, diff --git a/web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx index d49194890f..8df788c869 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx @@ -127,7 +127,7 @@ const GenericProviderInstanceCard = forwardRef< const { baseUrlOptions } = useProviderBaseUrlOptions(providerName); const { instanceDetails } = useLazyInstanceDetails( providerName, - instance.instance_name, + instance.id, isDraft, open, ); diff --git a/web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx index 15ed640368..11e85fe93b 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx @@ -169,7 +169,7 @@ export const SoMarkInstanceCard = forwardRef< const { data: instanceDetails, refetch: refetchInstanceDetails } = useFetchProviderInstance( isDraft ? '' : providerName, - isDraft ? '' : instance.instance_name, + isDraft ? '' : instance.id, ); // Lazily fetch full instance details only when the card is open. @@ -398,6 +398,7 @@ export const SoMarkInstanceCard = forwardRef< }; } const values = form.getValues(); + if (!form.formState.isDirty) return null; const payload = buildPayload(values, instance.instance_name); const finalPayload = { ...payload, diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index f70ff1a0c8..93bccfbc99 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -49,11 +49,11 @@ export default { `${restAPIv1}/providers/${provider_name}/instances/${instance_name}/models`, showProviderInstance: ({ provider_name, - instance_name, + id, }: { provider_name: string; - instance_name: string; - }) => `${restAPIv1}/providers/${provider_name}/instances/${instance_name}`, + id: string; + }) => `${restAPIv1}/providers/${provider_name}/instances/${id}`, addInstanceModel: ({ provider_name, instance_name, @@ -74,11 +74,11 @@ export default { `${restAPIv1}/providers/${provider_name}/instances`, updateProviderInstance: ({ provider_name, - instance_name, + id, }: { provider_name: string; - instance_name: string; - }) => `${restAPIv1}/providers/${provider_name}/instances/${instance_name}`, + id: string; + }) => `${restAPIv1}/providers/${provider_name}/instances/${id}`, updateModelStatus: ({ provider_name, instance_name,