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
This commit is contained in:
Jason
2025-11-04 15:30:54 +08:00
parent 49c2855b10
commit 0778347f84
7 changed files with 115 additions and 99 deletions

View File

@@ -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 && (
<EndpointSpeedTest
appId="claude"
providerId={providerId}
value={baseUrl}
onChange={onBaseUrlChange}
initialEndpoints={speedTestEndpoints}

View File

@@ -8,6 +8,7 @@ interface EndpointCandidate {
}
interface CodexFormFieldsProps {
providerId?: string;
// API Key
codexApiKey: string;
onApiKeyChange: (key: string) => void;
@@ -28,6 +29,7 @@ interface CodexFormFieldsProps {
}
export function CodexFormFields({
providerId,
codexApiKey,
onApiKeyChange,
category,
@@ -81,6 +83,7 @@ export function CodexFormFields({
{shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appId="codex"
providerId={providerId}
value={codexBaseUrl}
onChange={onBaseUrlChange}
initialEndpoints={speedTestEndpoints}

View File

@@ -281,70 +281,35 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
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<EndpointSpeedTestProps> = ({
return next;
});
},
[normalizedSelected, onChange, appId, providerId, t],
[normalizedSelected, onChange],
);
const runSpeedTest = useCallback(async () => {
@@ -432,22 +397,11 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
}, [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 (

View File

@@ -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<string[]>(
[],
() => {
if (!initialData?.meta?.custom_endpoints) {
return [];
}
// 从 Record<string, CustomEndpoint> 中提取 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: `<EFBFBD><EFBFBD><EFBFBD>填写 ${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<string, import("@/types").CustomEndpoint> | 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<string, import("@/types").CustomEndpoint>)
: 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" && (
<ClaudeFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.watch("settingsConfig"),
isEditMode,
@@ -505,6 +538,7 @@ export function ProviderForm({
{/* Codex 专属字段 */}
{appId === "codex" && (
<CodexFormFields
providerId={providerId}
codexApiKey={codexApiKey}
onApiKeyChange={handleCodexApiKeyChange}
category={category}