diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index b209003ed..a3763fe58 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -1,33 +1,71 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import TOML from "smol-toml"; import { updateTomlCommonConfigSnippet, hasTomlCommonConfigSnippet, + replaceTomlCommonConfigSnippet, } from "@/utils/providerConfigUtils"; import { configApi } from "@/lib/api"; +import type { ProviderMeta } from "@/types"; const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config # Add your common TOML configuration here`; +/** TOML 校验错误码 */ +export type TomlValidationErrorCode = + | "TOML_SYNTAX_ERROR" + | "TOML_PARSE_FAILED" + | ""; + +/** + * 校验 TOML 格式 + * @param tomlText - 待校验的 TOML 文本 + * @returns 错误码,如果校验通过则返回空字符串 + */ +function validateTomlFormat(tomlText: string): TomlValidationErrorCode { + // 空字符串或仅包含注释/空行视为合法 + const lines = tomlText.split("\n"); + const hasContent = lines.some((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#"); + }); + if (!hasContent) { + return ""; + } + + try { + TOML.parse(tomlText); + return ""; + } catch { + return "TOML_SYNTAX_ERROR"; + } +} + interface UseCodexCommonConfigProps { codexConfig: string; onConfigChange: (config: string) => void; initialData?: { settingsConfig?: Record; + meta?: ProviderMeta; }; selectedPresetId?: string; + /** 当前正在编辑的供应商 ID(用于同步时跳过) */ + currentProviderId?: string; } /** * 管理 Codex 通用配置片段 (TOML 格式) - * 从 config.json 读取和保存,支持从 localStorage 平滑迁移 + * 从数据库读取和保存,支持从 localStorage 平滑迁移 */ export function useCodexCommonConfig({ codexConfig, onConfigChange, initialData, selectedPresetId, + currentProviderId, }: UseCodexCommonConfigProps) { const { t } = useTranslation(); const [useCommonConfig, setUseCommonConfig] = useState(false); @@ -38,17 +76,56 @@ export function useCodexCommonConfig({ const [isLoading, setIsLoading] = useState(true); const [isExtracting, setIsExtracting] = useState(false); + const hasSnippetContent = useCallback((snippet: string) => { + const lines = snippet.split("\n"); + return lines.some((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#"); + }); + }, []); + + const getSnippetApplyError = useCallback( + (snippet: string) => { + if (!hasSnippetContent(snippet)) { + return t("codexConfig.noCommonConfigToApply"); + } + // 校验 TOML 语法 + const tomlError = validateTomlFormat(snippet); + if (tomlError) { + return t("mcp.error.tomlInvalid", { + defaultValue: "TOML 格式错误,请检查语法", + }); + } + return ""; + }, + [hasSnippetContent, t], + ); + // 用于跟踪是否正在通过通用配置更新 const isUpdatingFromCommonConfig = useRef(false); + // 用于跟踪用户是否手动切换,避免自动检测覆盖用户意图 + const hasUserToggledCommonConfig = useRef(false); // 用于跟踪新建模式是否已初始化默认勾选 const hasInitializedNewMode = useRef(false); + // 用于跟踪编辑模式是否已初始化(避免反复覆盖用户切换) + const hasInitializedEditMode = useRef(false); + // 用于避免异步保存乱序导致的过期同步 + const saveSequenceRef = useRef(0); + const saveQueueRef = useRef>(Promise.resolve()); + const enqueueSave = useCallback((saveFn: () => Promise) => { + const next = saveQueueRef.current.then(saveFn); + saveQueueRef.current = next.catch(() => {}); + return next; + }, []); // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 useEffect(() => { hasInitializedNewMode.current = false; + hasInitializedEditMode.current = false; + hasUserToggledCommonConfig.current = false; }, [selectedPresetId]); - // 初始化:从 config.json 加载,支持从 localStorage 迁移 + // 初始化:从数据库加载,支持从 localStorage 迁移 useEffect(() => { let mounted = true; @@ -62,7 +139,7 @@ export function useCodexCommonConfig({ setCommonConfigSnippetState(snippet); } } else { - // 如果 config.json 中没有,尝试从 localStorage 迁移 + // 如果数据库中没有,尝试从 localStorage 迁移 if (typeof window !== "undefined") { try { const legacySnippet = @@ -76,7 +153,7 @@ export function useCodexCommonConfig({ // 清理 localStorage window.localStorage.removeItem(LEGACY_STORAGE_KEY); console.log( - "[迁移] Codex 通用配置已从 localStorage 迁移到 config.json", + "[迁移] Codex 通用配置已从 localStorage 迁移到数据库", ); } } catch (e) { @@ -100,17 +177,50 @@ export function useCodexCommonConfig({ }; }, []); - // 初始化时检查通用配置片段(编辑模式) + // 初始化时从 meta 读取启用状态(编辑模式) + // 优先使用 meta,若 meta 未定义则回退到内容检测 useEffect(() => { - if (initialData?.settingsConfig && !isLoading) { - const config = - typeof initialData.settingsConfig.config === "string" - ? initialData.settingsConfig.config - : ""; - const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet); - setUseCommonConfig(hasCommon); + if (initialData && !isLoading && !hasInitializedEditMode.current) { + hasInitializedEditMode.current = true; + + // 使用 meta 中记录的按 app 启用状态 + const metaByApp = initialData.meta?.commonConfigEnabledByApp; + const resolvedMetaEnabled = + metaByApp?.codex ?? initialData.meta?.commonConfigEnabled; + + if (resolvedMetaEnabled !== undefined) { + if (!resolvedMetaEnabled) { + setUseCommonConfig(false); + return; + } + const snippetError = getSnippetApplyError(commonConfigSnippet); + if (snippetError) { + setCommonConfigError(snippetError); + setUseCommonConfig(false); + return; + } + setCommonConfigError(""); + setUseCommonConfig(true); + return; + } else { + // meta 未定义,回退到内容检测 + // Codex 使用 TOML 格式,从 settingsConfig.config 获取 + const codexConfigStr = + typeof initialData.settingsConfig === "object" && + initialData.settingsConfig !== null + ? String( + (initialData.settingsConfig as Record) + .config ?? "", + ) + : ""; + const detected = hasTomlCommonConfigSnippet( + codexConfigStr, + commonConfigSnippet, + ); + setUseCommonConfig(detected); + } } - }, [initialData, commonConfigSnippet, isLoading]); + }, [initialData, isLoading, commonConfigSnippet, getSnippetApplyError]); // 新建模式:如果通用配置片段存在且有效,默认启用 useEffect(() => { @@ -119,11 +229,7 @@ export function useCodexCommonConfig({ hasInitializedNewMode.current = true; // 检查 TOML 片段是否有实质内容(不只是注释和空行) - const lines = commonConfigSnippet.split("\n"); - const hasContent = lines.some((line) => { - const trimmed = line.trim(); - return trimmed && !trimmed.startsWith("#"); - }); + const hasContent = hasSnippetContent(commonConfigSnippet); if (hasContent) { setUseCommonConfig(true); @@ -148,11 +254,21 @@ export function useCodexCommonConfig({ isLoading, codexConfig, onConfigChange, + hasSnippetContent, ]); // 处理通用配置开关 const handleCommonConfigToggle = useCallback( (checked: boolean) => { + hasUserToggledCommonConfig.current = true; + if (checked) { + const snippetError = getSnippetApplyError(commonConfigSnippet); + if (snippetError) { + setCommonConfigError(snippetError); + setUseCommonConfig(false); + return; + } + } const { updatedConfig, error: snippetError } = updateTomlCommonConfigSnippet( codexConfig, @@ -176,7 +292,7 @@ export function useCodexCommonConfig({ isUpdatingFromCommonConfig.current = false; }, 0); }, - [codexConfig, commonConfigSnippet, onConfigChange], + [codexConfig, commonConfigSnippet, onConfigChange, getSnippetApplyError], ); // 处理通用配置片段变化 @@ -186,14 +302,34 @@ export function useCodexCommonConfig({ setCommonConfigSnippetState(value); if (!value.trim()) { + const saveId = ++saveSequenceRef.current; setCommonConfigError(""); - // 保存到 config.json(清空) - configApi.setCommonConfigSnippet("codex", "").catch((error) => { - console.error("保存 Codex 通用配置失败:", error); - setCommonConfigError( - t("codexConfig.saveFailed", { error: String(error) }), - ); - }); + // 保存到数据库(清空) + enqueueSave(() => configApi.setCommonConfigSnippet("codex", "")) + .then(() => { + if (saveSequenceRef.current !== saveId) return; + // 清空时也需要同步:移除所有供应商的通用配置片段 + configApi.syncCommonConfigToProviders( + "codex", + previousSnippet, + "", // newSnippet 为空表示移除 + replaceTomlCommonConfigSnippet, + currentProviderId, + (result) => { + if (saveSequenceRef.current !== saveId) return; + if (result.error) { + toast.error(t("providerForm.commonConfigSyncFailed")); + } + }, + ); + }) + .catch((error) => { + if (saveSequenceRef.current !== saveId) return; + console.error("保存 Codex 通用配置失败:", error); + setCommonConfigError( + t("codexConfig.saveFailed", { error: String(error) }), + ); + }); if (useCommonConfig) { const { updatedConfig } = updateTomlCommonConfigSnippet( @@ -207,15 +343,47 @@ export function useCodexCommonConfig({ return; } - // TOML 格式校验较为复杂,暂时不做校验,直接清空错误 + // TOML 格式校验 - 在保存和同步前校验,避免传播无效配置 + const tomlError = validateTomlFormat(value); + if (tomlError) { + console.warn("Codex 通用配置 TOML 校验失败:", tomlError); + setCommonConfigError( + t("mcp.error.tomlInvalid", { + defaultValue: "TOML 格式错误,请检查语法", + }), + ); + // 不保存、不同步,仅显示错误 + return; + } + + const saveId = ++saveSequenceRef.current; setCommonConfigError(""); // 保存到 config.json - configApi.setCommonConfigSnippet("codex", value).catch((error) => { - console.error("保存 Codex 通用配置失败:", error); - setCommonConfigError( - t("codexConfig.saveFailed", { error: String(error) }), - ); - }); + enqueueSave(() => configApi.setCommonConfigSnippet("codex", value)) + .then(() => { + if (saveSequenceRef.current !== saveId) return; + // 保存成功后,同步更新所有启用了通用配置的供应商 + configApi.syncCommonConfigToProviders( + "codex", + previousSnippet, + value, + replaceTomlCommonConfigSnippet, + currentProviderId, + (result) => { + if (saveSequenceRef.current !== saveId) return; + if (result.error) { + toast.error(t("providerForm.commonConfigSyncFailed")); + } + }, + ); + }) + .catch((error) => { + if (saveSequenceRef.current !== saveId) return; + console.error("保存 Codex 通用配置失败:", error); + setCommonConfigError( + t("codexConfig.saveFailed", { error: String(error) }), + ); + }); // 若当前启用通用配置,需要替换为最新片段 if (useCommonConfig) { @@ -248,20 +416,35 @@ export function useCodexCommonConfig({ }, 0); } }, - [commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange], + [ + commonConfigSnippet, + codexConfig, + useCommonConfig, + onConfigChange, + currentProviderId, + enqueueSave, + t, + ], ); - // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) + // 当配置变化时检查是否包含通用配置(避免通过通用配置更新时反复覆盖) useEffect(() => { if (isUpdatingFromCommonConfig.current || isLoading) { return; } + const metaByApp = initialData?.meta?.commonConfigEnabledByApp; + const hasExplicitMeta = + metaByApp?.codex !== undefined || + initialData?.meta?.commonConfigEnabled !== undefined; + if (hasExplicitMeta || hasUserToggledCommonConfig.current) { + return; + } const hasCommon = hasTomlCommonConfigSnippet( codexConfig, commonConfigSnippet, ); setUseCommonConfig(hasCommon); - }, [codexConfig, commonConfigSnippet, isLoading]); + }, [codexConfig, commonConfigSnippet, isLoading, initialData]); // 从编辑器当前内容提取通用配置片段 const handleExtract = useCallback(async () => {