From 0778347f84188ac8cfa3025e2d97b9c18f6d7965 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 4 Nov 2025 15:30:54 +0800 Subject: [PATCH] refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful --- src-tauri/src/services/provider.rs | 17 ++-- .../providers/EditProviderDialog.tsx | 1 + .../providers/forms/ClaudeFormFields.tsx | 3 + .../providers/forms/CodexFormFields.tsx | 3 + .../providers/forms/EndpointSpeedTest.tsx | 96 +++++-------------- .../providers/forms/ProviderForm.tsx | 64 ++++++++++--- src/utils/providerMetaUtils.ts | 30 +++++- 7 files changed, 115 insertions(+), 99 deletions(-) diff --git a/src-tauri/src/services/provider.rs b/src-tauri/src/services/provider.rs index 492da7b..e1afb35 100644 --- a/src-tauri/src/services/provider.rs +++ b/src-tauri/src/services/provider.rs @@ -440,21 +440,16 @@ impl ProviderService { let merged = if let Some(existing) = manager.providers.get(&provider_id) { let mut updated = provider_clone.clone(); match (existing.meta.as_ref(), updated.meta.take()) { + // 前端未提供 meta,表示不修改,沿用旧值 (Some(old_meta), None) => { updated.meta = Some(old_meta.clone()); } - (Some(old_meta), Some(mut new_meta)) => { - let mut merged_map = old_meta.custom_endpoints.clone(); - for (url, ep) in new_meta.custom_endpoints.drain() { - merged_map.entry(url).or_insert(ep); - } - updated.meta = Some(ProviderMeta { - custom_endpoints: merged_map, - usage_script: new_meta.usage_script.clone(), - }); + (None, None) => { + updated.meta = None; } - (None, maybe_new) => { - updated.meta = maybe_new; + // 前端提供的 meta 视为权威,直接覆盖(其中 custom_endpoints 允许是空,表示删除所有自定义端点) + (_old, Some(new_meta)) => { + updated.meta = Some(new_meta); } } updated diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index a446d04..46a79d9 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -123,6 +123,7 @@ export function EditProviderDialog({
onOpenChange(false)} diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 1506ea3..252bace 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -11,6 +11,7 @@ interface EndpointCandidate { } interface ClaudeFormFieldsProps { + providerId?: string; // API Key shouldShowApiKey: boolean; apiKey: string; @@ -53,6 +54,7 @@ interface ClaudeFormFieldsProps { } export function ClaudeFormFields({ + providerId, shouldShowApiKey, apiKey, onApiKeyChange, @@ -144,6 +146,7 @@ export function ClaudeFormFields({ {shouldShowSpeedTest && isEndpointModalOpen && ( void; @@ -28,6 +29,7 @@ interface CodexFormFieldsProps { } export function CodexFormFields({ + providerId, codexApiKey, onApiKeyChange, category, @@ -81,6 +83,7 @@ export function CodexFormFields({ {shouldShowSpeedTest && isEndpointModalOpen && ( = ({ setAddError(null); - // 保存到后端 - try { - if (providerId) { - await vscodeApi.addCustomEndpoint(appId, providerId, sanitized); - } + // 更新本地状态(延迟提交,不立即保存到后端) + setEntries((prev) => { + if (prev.some((e) => e.url === sanitized)) return prev; + return [ + ...prev, + { + id: randomId(), + url: sanitized, + isCustom: true, + latency: null, + status: undefined, + error: null, + }, + ]; + }); - // 更新本地状态 - setEntries((prev) => { - if (prev.some((e) => e.url === sanitized)) return prev; - return [ - ...prev, - { - id: randomId(), - url: sanitized, - isCustom: true, - latency: null, - status: undefined, - error: null, - }, - ]; - }); - - if (!normalizedSelected) { - onChange(sanitized); - } - - setCustomUrl(""); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAddError(message || t("endpointTest.saveFailed")); - console.error(t("endpointTest.addEndpointFailed"), error); + if (!normalizedSelected) { + onChange(sanitized); } - }, [customUrl, entries, normalizedSelected, onChange, appId, providerId, t]); + + setCustomUrl(""); + }, [customUrl, entries, normalizedSelected, onChange]); const handleRemoveEndpoint = useCallback( - async (entry: EndpointEntry) => { + (entry: EndpointEntry) => { // 清空之前的错误提示 setLastError(null); - // 如果有 providerId,尝试从后端删除 - if (entry.isCustom && providerId) { - try { - await vscodeApi.removeCustomEndpoint(appId, providerId, entry.url); - } catch (error) { - const errorMsg = - error instanceof Error ? error.message : String(error); - - // 只有"端点不存在"时才允许删除本地条目 - if ( - errorMsg.includes("not found") || - errorMsg.includes("does not exist") || - errorMsg.includes("不存在") - ) { - console.warn(t("endpointTest.removeEndpointFailed"), errorMsg); - // 继续删除本地条目 - } else { - // 其他错误:显示错误提示,阻止删除 - setLastError(t("endpointTest.removeFailed", { error: errorMsg })); - return; - } - } - } - - // 更新本地状态(删除成功) + // 更新本地状态(延迟提交,不立即从后端删除) setEntries((prev) => { const next = prev.filter((item) => item.id !== entry.id); if (entry.url === normalizedSelected) { @@ -354,7 +319,7 @@ const EndpointSpeedTest: React.FC = ({ return next; }); }, - [normalizedSelected, onChange, appId, providerId, t], + [normalizedSelected, onChange], ); const runSpeedTest = useCallback(async () => { @@ -432,22 +397,11 @@ const EndpointSpeedTest: React.FC = ({ }, [entries, autoSelect, appId, normalizedSelected, onChange, t]); const handleSelect = useCallback( - async (url: string) => { + (url: string) => { if (!url || url === normalizedSelected) return; - - // 更新最后使用时间(对自定义端点) - const entry = entries.find((e) => e.url === url); - if (entry?.isCustom && providerId) { - try { - await vscodeApi.updateEndpointLastUsed(appId, providerId, url); - } catch (error) { - console.error(t("endpointTest.updateLastUsedFailed"), error); - } - } - onChange(url); }, - [normalizedSelected, onChange, appId, entries, providerId, t], + [normalizedSelected, onChange], ); return ( diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 431c528..7c320d5 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -30,7 +30,6 @@ import { useModelState, useCodexConfigState, useApiKeyLink, - useCustomEndpoints, useTemplateValues, useCommonConfigSnippet, useCodexCommonConfig, @@ -48,6 +47,7 @@ type PresetEntry = { interface ProviderFormProps { appId: AppId; + providerId?: string; submitLabel: string; onSubmit: (values: ProviderFormValues) => void; onCancel: () => void; @@ -63,6 +63,7 @@ interface ProviderFormProps { export function ProviderForm({ appId, + providerId, submitLabel, onSubmit, onCancel, @@ -82,8 +83,15 @@ export function ProviderForm({ const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false); // 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints + // 编辑供应商:从 initialData.meta.custom_endpoints 恢复端点列表 const [draftCustomEndpoints, setDraftCustomEndpoints] = useState( - [], + () => { + if (!initialData?.meta?.custom_endpoints) { + return []; + } + // 从 Record 中提取 URL 列表 + return Object.keys(initialData.meta.custom_endpoints); + }, ); // 使用 category hook @@ -97,6 +105,13 @@ export function ProviderForm({ useEffect(() => { setSelectedPresetId(initialData ? null : "custom"); setActivePreset(null); + + // 重新初始化 draftCustomEndpoints(编辑模式时从 meta 恢复) + if (initialData?.meta?.custom_endpoints) { + setDraftCustomEndpoints(Object.keys(initialData.meta.custom_endpoints)); + } else { + setDraftCustomEndpoints([]); + } }, [appId, initialData]); const defaultValues: ProviderFormData = useMemo( @@ -272,7 +287,7 @@ export function ProviderForm({ type: "manual", message: t("providerForm.fillParameter", { label: validation.missingField.label, - defaultValue: `请填写 ${validation.missingField.label}`, + defaultValue: `���填写 ${validation.missingField.label}`, }), }); return; @@ -313,8 +328,35 @@ export function ProviderForm({ } } - // 处理 meta 字段(新建与编辑使用不同策略) - const mergedMeta = mergeProviderMeta(initialData?.meta, customEndpointsMap); + // 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints + // 注意:不使用 customEndpointsMap,因为它包含了候选端点(预设、Base URL 等) + // 而我们只需要保存用户真正添加的自定义端点 + const customEndpointsToSave: Record | null = + draftCustomEndpoints.length > 0 + ? draftCustomEndpoints.reduce((acc, url) => { + // 尝试从 initialData.meta 中获取原有的端点元数据(保留 addedAt 和 lastUsed) + const existing = initialData?.meta?.custom_endpoints?.[url]; + if (existing) { + acc[url] = existing; + } else { + // 新端点:使用当前时间戳 + const now = Date.now(); + acc[url] = { url, addedAt: now, lastUsed: undefined }; + } + return acc; + }, {} as Record) + : null; + + // 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点") + const hadEndpoints = initialData?.meta?.custom_endpoints && + Object.keys(initialData.meta.custom_endpoints).length > 0; + const needsClearEndpoints = hadEndpoints && draftCustomEndpoints.length === 0; + + // 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除 + const mergedMeta = needsClearEndpoints + ? mergeProviderMeta(initialData?.meta, {}) + : mergeProviderMeta(initialData?.meta, customEndpointsToSave); + if (mergedMeta) { payload.meta = mergedMeta; } @@ -369,16 +411,6 @@ export function ProviderForm({ formWebsiteUrl: form.watch("websiteUrl") || "", }); - // 使用自定义端点 hook - const customEndpointsMap = useCustomEndpoints({ - appId, - selectedPresetId, - presetEntries, - draftCustomEndpoints, - baseUrl, - codexBaseUrl, - }); - // 使用端点测速候选 hook const speedTestEndpoints = useSpeedTestEndpoints({ appId, @@ -473,6 +505,7 @@ export function ProviderForm({ {/* Claude 专属字段 */} {appId === "claude" && ( 0; + // 明确清空:传入空对象(非 null/undefined)表示用户想要删除所有端点 + const isExplicitClear = + customEndpoints !== null && + customEndpoints !== undefined && + Object.keys(customEndpoints).length === 0; + if (hasCustomEndpoints) { return { ...(initialMeta ? { ...initialMeta } : {}), @@ -20,6 +27,25 @@ export function mergeProviderMeta( }; } + // 明确清空端点 + if (isExplicitClear) { + if (!initialMeta) { + // 新供应商且用户没有添加端点(理论上不会到这里) + return undefined; + } + + if ("custom_endpoints" in initialMeta) { + const { custom_endpoints, ...rest } = initialMeta; + // 保留其他字段(如 usage_script) + // 即使 rest 为空,也要返回空对象(让后端知道要清空 meta) + return Object.keys(rest).length > 0 ? rest : {}; + } + + // initialMeta 中本来就没有 custom_endpoints + return { ...initialMeta }; + } + + // null/undefined:用户没有修改端点,保持不变 if (!initialMeta) { return undefined; }