From 2ff329f6c0b0d15dc8c41c6a12f2ade79b47d27b Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 26 Jan 2026 14:09:13 +0800 Subject: [PATCH] refactor(hooks): redesign common config hooks for runtime merge architecture Refactor all three common config hooks (Claude, Codex, Gemini) to support the new runtime merge architecture where: - settingsConfig stores only custom (provider-specific) configuration - commonConfigSnippet stores shared template configuration in database - finalConfig is computed at runtime: merge(commonConfig, customConfig) - Toggling common config only changes enabled state, not settingsConfig Key changes across all hooks: - Add finalConfig/finalEnv return value for merge preview - Use configMerge utilities (computeFinalConfig, extractDifference) - Remove direct config manipulation on toggle (no more inject/remove) - Add proper TypeScript return type interfaces - Simplify state management by removing unnecessary refs - Improve code organization with clear section comments This refactor enables: - Clean separation between custom and common configuration - Real-time merge preview in the UI - Consistent behavior across Claude (JSON), Codex (TOML), and Gemini (env) --- src/components/providers/forms/hooks/index.ts | 6 +- .../forms/hooks/useCodexCommonConfig.ts | 472 ++++++++--------- .../forms/hooks/useCommonConfigSnippet.ts | 455 ++++++++--------- .../forms/hooks/useGeminiCommonConfig.ts | 482 +++++++----------- 4 files changed, 634 insertions(+), 781 deletions(-) diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 88e586418..2afa3b5d5 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -6,9 +6,11 @@ export { useCodexConfigState } from "./useCodexConfigState"; export { useApiKeyLink } from "./useApiKeyLink"; export { useCustomEndpoints } from "./useCustomEndpoints"; export { useTemplateValues } from "./useTemplateValues"; -export { useCommonConfigSnippet } from "./useCommonConfigSnippet"; -export { useCodexCommonConfig } from "./useCodexCommonConfig"; export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints"; export { useCodexTomlValidation } from "./useCodexTomlValidation"; export { useGeminiConfigState } from "./useGeminiConfigState"; + +// Common Config Hooks +export { useCommonConfigSnippet } from "./useCommonConfigSnippet"; +export { useCodexCommonConfig } from "./useCodexCommonConfig"; export { useGeminiCommonConfig } from "./useGeminiCommonConfig"; diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index a3763fe58..837bec5af 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -1,13 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } 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 { + computeFinalTomlConfig, + extractTomlDifference, +} from "@/utils/tomlConfigMerge"; import type { ProviderMeta } from "@/types"; const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; @@ -45,30 +43,74 @@ function validateTomlFormat(tomlText: string): TomlValidationErrorCode { } interface UseCodexCommonConfigProps { + /** + * 当前 Codex 配置(JSON 格式,包含 auth 和 config) + */ codexConfig: string; + /** + * 配置变化回调 + */ onConfigChange: (config: string) => void; + /** + * 初始数据(编辑模式) + */ initialData?: { settingsConfig?: Record; meta?: ProviderMeta; }; + /** + * 当前选中的预设 ID + */ selectedPresetId?: string; - /** 当前正在编辑的供应商 ID(用于同步时跳过) */ + /** + * 当前正在编辑的供应商 ID + */ currentProviderId?: string; } +export interface UseCodexCommonConfigReturn { + /** 是否启用通用配置 */ + useCommonConfig: boolean; + /** 通用配置片段 (TOML 格式) */ + commonConfigSnippet: string; + /** 通用配置错误信息 */ + commonConfigError: string; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在提取 */ + isExtracting: boolean; + /** 通用配置开关处理函数 */ + handleCommonConfigToggle: (checked: boolean) => void; + /** 通用配置片段变化处理函数 */ + handleCommonConfigSnippetChange: (snippet: string) => void; + /** 从当前配置提取通用配置 */ + handleExtract: () => Promise; + /** 最终配置(运行时合并结果,只读) */ + finalConfig: string; +} + /** - * 管理 Codex 通用配置片段 (TOML 格式) - * 从数据库读取和保存,支持从 localStorage 平滑迁移 + * 管理 Codex 通用配置片段(重构版) + * + * 新架构: + * - codexConfig:传入当前配置(JSON 格式,包含 auth 和 config) + * - commonConfigSnippet:存储在数据库中的通用 TOML 配置片段 + * - finalConfig:运行时计算 = merge(commonConfig, config) + * - 开启/关闭通用配置只改变 enabled 状态,不修改 codexConfig */ export function useCodexCommonConfig({ codexConfig, onConfigChange, initialData, selectedPresetId, - currentProviderId, -}: UseCodexCommonConfigProps) { + // currentProviderId is reserved for future use +}: UseCodexCommonConfigProps): UseCodexCommonConfigReturn { const { t } = useTranslation(); + + // 内部管理的通用配置启用状态 const [useCommonConfig, setUseCommonConfig] = useState(false); + + // 通用配置片段(从数据库加载) const [commonConfigSnippet, setCommonConfigSnippetState] = useState( DEFAULT_CODEX_COMMON_CONFIG_SNIPPET, ); @@ -76,40 +118,7 @@ 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) => { @@ -118,20 +127,61 @@ export function useCodexCommonConfig({ return next; }, []); - // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 + // 用于跟踪编辑模式是否已初始化 + const hasInitializedEditMode = useRef(false); + // 用于跟踪新建模式是否已初始化 + const hasInitializedNewMode = useRef(false); + + // 检查 TOML 片段是否有实质内容 + const hasSnippetContent = useCallback((snippet: string) => { + const lines = snippet.split("\n"); + return lines.some((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#"); + }); + }, []); + + // 从 codexConfig 提取 config 字段(TOML 格式) + // 注意:codexConfig 可能是以下两种格式之一: + // 1. 直接的 TOML 字符串(来自 useCodexConfigState) + // 2. JSON 字符串 { auth: {...}, config: "..." }(旧的 settingsConfig 格式) + const extractConfigToml = useCallback((configInput: string): string => { + if (!configInput || !configInput.trim()) { + return ""; + } + + // 尝试解析为 JSON(旧格式) + try { + const parsed = JSON.parse(configInput); + if (typeof parsed?.config === "string") { + return parsed.config; + } + // 如果是 JSON 对象但没有 config 字段,返回空 + if (typeof parsed === "object" && parsed !== null) { + return ""; + } + } catch { + // JSON 解析失败,说明是直接的 TOML 字符串 + } + + // 直接返回作为 TOML 字符串 + return configInput; + }, []); + + // 当预设变化时,重置初始化标记 useEffect(() => { hasInitializedNewMode.current = false; hasInitializedEditMode.current = false; - hasUserToggledCommonConfig.current = false; }, [selectedPresetId]); - // 初始化:从数据库加载,支持从 localStorage 迁移 + // ============================================================================ + // 加载通用配置片段(从数据库,支持 localStorage 迁移) + // ============================================================================ useEffect(() => { let mounted = true; const loadSnippet = async () => { try { - // 使用统一 API 加载 const snippet = await configApi.getCommonConfigSnippet("codex"); if (snippet && snippet.trim()) { @@ -139,22 +189,26 @@ export function useCodexCommonConfig({ setCommonConfigSnippetState(snippet); } } else { - // 如果数据库中没有,尝试从 localStorage 迁移 + // 尝试从 localStorage 迁移 if (typeof window !== "undefined") { try { const legacySnippet = window.localStorage.getItem(LEGACY_STORAGE_KEY); if (legacySnippet && legacySnippet.trim()) { - // 迁移到 config.json - await configApi.setCommonConfigSnippet("codex", legacySnippet); - if (mounted) { - setCommonConfigSnippetState(legacySnippet); + const tomlError = validateTomlFormat(legacySnippet); + if (!tomlError) { + await configApi.setCommonConfigSnippet( + "codex", + legacySnippet, + ); + if (mounted) { + setCommonConfigSnippetState(legacySnippet); + } + window.localStorage.removeItem(LEGACY_STORAGE_KEY); + console.log( + "[迁移] Codex 通用配置已从 localStorage 迁移到数据库", + ); } - // 清理 localStorage - window.localStorage.removeItem(LEGACY_STORAGE_KEY); - console.log( - "[迁移] Codex 通用配置已从 localStorage 迁移到数据库", - ); } } catch (e) { console.warn("[迁移] 从 localStorage 迁移失败:", e); @@ -177,13 +231,13 @@ export function useCodexCommonConfig({ }; }, []); - // 初始化时从 meta 读取启用状态(编辑模式) - // 优先使用 meta,若 meta 未定义则回退到内容检测 + // ============================================================================ + // 编辑模式初始化:从 meta 读取启用状态 + // ============================================================================ useEffect(() => { if (initialData && !isLoading && !hasInitializedEditMode.current) { hasInitializedEditMode.current = true; - // 使用 meta 中记录的按 app 启用状态 const metaByApp = initialData.meta?.commonConfigEnabledByApp; const resolvedMetaEnabled = metaByApp?.codex ?? initialData.meta?.commonConfigEnabled; @@ -193,260 +247,148 @@ export function useCodexCommonConfig({ setUseCommonConfig(false); return; } - const snippetError = getSnippetApplyError(commonConfigSnippet); - if (snippetError) { - setCommonConfigError(snippetError); + if (!hasSnippetContent(commonConfigSnippet)) { + setCommonConfigError(t("codexConfig.noCommonConfigToApply")); + setUseCommonConfig(false); + return; + } + const tomlError = validateTomlFormat(commonConfigSnippet); + if (tomlError) { + setCommonConfigError( + t("mcp.error.tomlInvalid", { defaultValue: "TOML 格式错误" }), + ); 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); + setUseCommonConfig(false); } } - }, [initialData, isLoading, commonConfigSnippet, getSnippetApplyError]); + }, [initialData, isLoading, commonConfigSnippet, hasSnippetContent, t]); - // 新建模式:如果通用配置片段存在且有效,默认启用 + // ============================================================================ + // 新建模式初始化:如果通用配置有效,默认启用 + // ============================================================================ useEffect(() => { - // 仅新建模式、加载完成、尚未初始化过 if (!initialData && !isLoading && !hasInitializedNewMode.current) { hasInitializedNewMode.current = true; - // 检查 TOML 片段是否有实质内容(不只是注释和空行) - const hasContent = hasSnippetContent(commonConfigSnippet); - - if (hasContent) { - setUseCommonConfig(true); - // 合并通用配置到当前配置 - const { updatedConfig, error } = updateTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - true, - ); - if (!error) { - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); + if (hasSnippetContent(commonConfigSnippet)) { + const tomlError = validateTomlFormat(commonConfigSnippet); + if (!tomlError) { + setUseCommonConfig(true); } } } - }, [ - initialData, - commonConfigSnippet, - isLoading, - codexConfig, - onConfigChange, - hasSnippetContent, - ]); + }, [initialData, commonConfigSnippet, isLoading, hasSnippetContent]); + // ============================================================================ + // 计算最终配置(运行时合并) + // ============================================================================ + const finalConfig = useMemo((): string => { + const customToml = extractConfigToml(codexConfig); + + if (!useCommonConfig) { + return customToml; + } + + const result = computeFinalTomlConfig( + customToml, + commonConfigSnippet, + true, + ); + + if (result.error) { + // 合并失败时返回原配置 + return customToml; + } + + return result.finalConfig; + }, [codexConfig, commonConfigSnippet, useCommonConfig, extractConfigToml]); + + // ============================================================================ // 处理通用配置开关 + // ============================================================================ const handleCommonConfigToggle = useCallback( (checked: boolean) => { - hasUserToggledCommonConfig.current = true; if (checked) { - const snippetError = getSnippetApplyError(commonConfigSnippet); - if (snippetError) { - setCommonConfigError(snippetError); + if (!hasSnippetContent(commonConfigSnippet)) { + setCommonConfigError(t("codexConfig.noCommonConfigToApply")); + setUseCommonConfig(false); + return; + } + const tomlError = validateTomlFormat(commonConfigSnippet); + if (tomlError) { + setCommonConfigError( + t("mcp.error.tomlInvalid", { defaultValue: "TOML 格式错误" }), + ); setUseCommonConfig(false); return; } } - const { updatedConfig, error: snippetError } = - updateTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - checked, - ); - - if (snippetError) { - setCommonConfigError(snippetError); - setUseCommonConfig(false); - return; - } - setCommonConfigError(""); setUseCommonConfig(checked); - // 标记正在通过通用配置更新 - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); + // 新架构:不修改 codexConfig,只改变 enabled 状态 }, - [codexConfig, commonConfigSnippet, onConfigChange, getSnippetApplyError], + [commonConfigSnippet, hasSnippetContent, t], ); + // ============================================================================ // 处理通用配置片段变化 + // ============================================================================ const handleCommonConfigSnippetChange = useCallback( (value: string) => { - const previousSnippet = commonConfigSnippet; setCommonConfigSnippetState(value); if (!value.trim()) { const saveId = ++saveSequenceRef.current; setCommonConfigError(""); - // 保存到数据库(清空) - 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) => { + enqueueSave(() => configApi.setCommonConfigSnippet("codex", "")).catch( + (error) => { if (saveSequenceRef.current !== saveId) return; console.error("保存 Codex 通用配置失败:", error); setCommonConfigError( t("codexConfig.saveFailed", { error: String(error) }), ); - }); - - if (useCommonConfig) { - const { updatedConfig } = updateTomlCommonConfigSnippet( - codexConfig, - previousSnippet, - false, - ); - onConfigChange(updatedConfig); - setUseCommonConfig(false); - } + }, + ); 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 - 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) => { + enqueueSave(() => configApi.setCommonConfigSnippet("codex", value)).catch( + (error) => { if (saveSequenceRef.current !== saveId) return; console.error("保存 Codex 通用配置失败:", error); setCommonConfigError( t("codexConfig.saveFailed", { error: String(error) }), ); - }); + }, + ); - // 若当前启用通用配置,需要替换为最新片段 - if (useCommonConfig) { - const removeResult = updateTomlCommonConfigSnippet( - codexConfig, - previousSnippet, - false, - ); - if (removeResult.error) { - setCommonConfigError(removeResult.error); - return; - } - const addResult = updateTomlCommonConfigSnippet( - removeResult.updatedConfig, - value, - true, - ); - - if (addResult.error) { - setCommonConfigError(addResult.error); - return; - } - - // 标记正在通过通用配置更新,避免触发状态检查 - isUpdatingFromCommonConfig.current = true; - onConfigChange(addResult.updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } + // 注意:新架构下不再需要同步到其他供应商的 settingsConfig + // 因为 finalConfig 是运行时计算的 }, - [ - commonConfigSnippet, - codexConfig, - useCommonConfig, - onConfigChange, - currentProviderId, - enqueueSave, - t, - ], + [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, initialData]); - - // 从编辑器当前内容提取通用配置片段 + // ============================================================================ + // 从当前最终配置提取通用配置片段 + // ============================================================================ const handleExtract = useCallback(async () => { setIsExtracting(true); setCommonConfigError(""); @@ -454,7 +396,7 @@ export function useCodexCommonConfig({ try { const extracted = await configApi.extractCommonConfigSnippet("codex", { settingsConfig: JSON.stringify({ - config: codexConfig ?? "", + config: finalConfig ?? "", }), }); @@ -463,11 +405,36 @@ export function useCodexCommonConfig({ return; } + // 验证 TOML 格式 + const tomlError = validateTomlFormat(extracted); + if (tomlError) { + setCommonConfigError( + t("mcp.error.tomlInvalid", { + defaultValue: "TOML 格式错误,请检查语法", + }), + ); + return; + } + // 更新片段状态 setCommonConfigSnippetState(extracted); // 保存到后端 await configApi.setCommonConfigSnippet("codex", extracted); + + // 提取成功后,从 config 中移除与 extracted 相同的部分 + const customToml = extractConfigToml(codexConfig); + const diffResult = extractTomlDifference(customToml, extracted); + if (!diffResult.error) { + // 更新 codexConfig 中的 config 字段 + try { + const parsed = JSON.parse(codexConfig); + parsed.config = diffResult.customToml; + onConfigChange(JSON.stringify(parsed, null, 2)); + } catch { + // 忽略解析错误 + } + } } catch (error) { console.error("提取 Codex 通用配置失败:", error); setCommonConfigError( @@ -476,7 +443,7 @@ export function useCodexCommonConfig({ } finally { setIsExtracting(false); } - }, [codexConfig, t]); + }, [finalConfig, codexConfig, onConfigChange, extractConfigToml, t]); return { useCommonConfig, @@ -487,5 +454,6 @@ export function useCodexCommonConfig({ handleCommonConfigToggle, handleCommonConfigSnippetChange, handleExtract, + finalConfig, }; } diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts index 0acf87ffa..c753fba09 100644 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -1,13 +1,12 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { - updateCommonConfigSnippet, - validateJsonConfig, - hasCommonConfigSnippet, - replaceCommonConfigSnippet, -} from "@/utils/providerConfigUtils"; +import { validateJsonConfig } from "@/utils/providerConfigUtils"; import { configApi } from "@/lib/api"; +import { + computeFinalConfig, + extractDifference, + isPlainObject, +} from "@/utils/configMerge"; import type { ProviderMeta } from "@/types"; const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet"; @@ -16,22 +15,65 @@ const DEFAULT_COMMON_CONFIG_SNIPPET = `{ }`; interface UseCommonConfigSnippetProps { + /** + * 当前配置(用于显示和运行时合并) + * 新架构:传入自定义配置,返回最终配置 + */ settingsConfig: string; + /** + * 配置变化回调 + */ onConfigChange: (config: string) => void; + /** + * 初始数据(编辑模式) + */ initialData?: { settingsConfig?: Record; meta?: ProviderMeta; }; + /** + * 当前选中的预设 ID + */ selectedPresetId?: string; - /** When false, the hook skips all logic and returns disabled state. Default: true */ + /** + * 当 false 时跳过所有逻辑,返回禁用状态。默认:true + */ enabled?: boolean; - /** 当前正在编辑的供应商 ID(用于同步时跳过) */ + /** + * 当前正在编辑的供应商 ID + */ currentProviderId?: string; } +export interface UseCommonConfigSnippetReturn { + /** 是否启用通用配置 */ + useCommonConfig: boolean; + /** 通用配置片段 (JSON 格式) */ + commonConfigSnippet: string; + /** 通用配置错误信息 */ + commonConfigError: string; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在提取 */ + isExtracting: boolean; + /** 通用配置开关处理函数 */ + handleCommonConfigToggle: (checked: boolean) => void; + /** 通用配置片段变化处理函数 */ + handleCommonConfigSnippetChange: (snippet: string) => void; + /** 从当前配置提取通用配置 */ + handleExtract: () => Promise; + /** 最终配置(运行时合并结果,只读) */ + finalConfig: string; +} + /** - * 管理 Claude 通用配置片段 - * 从数据库读取和保存,支持从 localStorage 平滑迁移 + * 管理 Claude 通用配置片段(重构版) + * + * 新架构: + * - settingsConfig:传入自定义配置(供应商独有部分) + * - commonConfigSnippet:存储在数据库中的通用配置片段 + * - finalConfig:运行时计算 = merge(commonConfig, settingsConfig) + * - 开启/关闭通用配置只改变 enabled 状态,不修改 settingsConfig */ export function useCommonConfigSnippet({ settingsConfig, @@ -39,10 +81,14 @@ export function useCommonConfigSnippet({ initialData, selectedPresetId, enabled = true, - currentProviderId, -}: UseCommonConfigSnippetProps) { + // currentProviderId is reserved for future use +}: UseCommonConfigSnippetProps): UseCommonConfigSnippetReturn { const { t } = useTranslation(); + + // 内部管理的通用配置启用状态 const [useCommonConfig, setUseCommonConfig] = useState(false); + + // 通用配置片段(从数据库加载) const [commonConfigSnippet, setCommonConfigSnippetState] = useState( DEFAULT_COMMON_CONFIG_SNIPPET, ); @@ -50,6 +96,51 @@ export function useCommonConfigSnippet({ const [isLoading, setIsLoading] = useState(true); const [isExtracting, setIsExtracting] = useState(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; + }, []); + + // 用于跟踪编辑模式是否已初始化 + const hasInitializedEditMode = useRef(false); + // 用于跟踪新建模式是否已初始化 + const hasInitializedNewMode = useRef(false); + + // 当预设变化时,重置初始化标记 + useEffect(() => { + if (!enabled) return; + hasInitializedNewMode.current = false; + hasInitializedEditMode.current = false; + }, [selectedPresetId, enabled]); + + // 解析 JSON 配置片段 + const parseSnippet = useCallback( + ( + snippetString: string, + ): { config: Record; error?: string } => { + const trimmed = snippetString.trim(); + if (!trimmed) { + return { config: {} }; + } + + try { + const parsed = JSON.parse(trimmed); + if (!isPlainObject(parsed)) { + return { config: {}, error: t("claudeConfig.invalidJsonFormat") }; + } + return { config: parsed }; + } catch { + return { config: {}, error: t("claudeConfig.invalidJsonFormat") }; + } + }, + [t], + ); + + // 获取片段应用错误 const getSnippetApplyError = useCallback( (snippet: string) => { if (!snippet.trim()) { @@ -72,32 +163,9 @@ export function useCommonConfigSnippet({ [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(() => { - if (!enabled) return; - hasInitializedNewMode.current = false; - hasInitializedEditMode.current = false; - hasUserToggledCommonConfig.current = false; - }, [selectedPresetId, enabled]); - - // 初始化:从数据库加载,支持从 localStorage 迁移 + // ============================================================================ + // 加载通用配置片段(从数据库,支持 localStorage 迁移) + // ============================================================================ useEffect(() => { if (!enabled) { setIsLoading(false); @@ -107,7 +175,6 @@ export function useCommonConfigSnippet({ const loadSnippet = async () => { try { - // 使用统一 API 加载 const snippet = await configApi.getCommonConfigSnippet("claude"); if (snippet && snippet.trim()) { @@ -115,22 +182,26 @@ export function useCommonConfigSnippet({ setCommonConfigSnippetState(snippet); } } else { - // 如果数据库中没有,尝试从 localStorage 迁移 + // 尝试从 localStorage 迁移 if (typeof window !== "undefined") { try { const legacySnippet = window.localStorage.getItem(LEGACY_STORAGE_KEY); if (legacySnippet && legacySnippet.trim()) { - // 迁移到 config.json - await configApi.setCommonConfigSnippet("claude", legacySnippet); - if (mounted) { - setCommonConfigSnippetState(legacySnippet); + const parsed = parseSnippet(legacySnippet); + if (!parsed.error) { + await configApi.setCommonConfigSnippet( + "claude", + legacySnippet, + ); + if (mounted) { + setCommonConfigSnippetState(legacySnippet); + } + window.localStorage.removeItem(LEGACY_STORAGE_KEY); + console.log( + "[迁移] Claude 通用配置已从 localStorage 迁移到数据库", + ); } - // 清理 localStorage - window.localStorage.removeItem(LEGACY_STORAGE_KEY); - console.log( - "[迁移] Claude 通用配置已从 localStorage 迁移到数据库", - ); } } catch (e) { console.warn("[迁移] 从 localStorage 迁移失败:", e); @@ -151,16 +222,16 @@ export function useCommonConfigSnippet({ return () => { mounted = false; }; - }, [enabled]); + }, [enabled, parseSnippet]); - // 初始化时从 meta 读取启用状态(编辑模式) - // 优先使用 meta,若 meta 未定义则回退到内容检测 + // ============================================================================ + // 编辑模式初始化:从 meta 读取启用状态 + // ============================================================================ useEffect(() => { if (!enabled) return; if (initialData && !isLoading && !hasInitializedEditMode.current) { hasInitializedEditMode.current = true; - // 使用 meta 中记录的按 app 启用状态 const metaByApp = initialData.meta?.commonConfigEnabledByApp; const resolvedMetaEnabled = metaByApp?.claude ?? initialData.meta?.commonConfigEnabled; @@ -178,18 +249,8 @@ export function useCommonConfigSnippet({ } setCommonConfigError(""); setUseCommonConfig(true); - return; } else { - // meta 未定义,回退到内容检测 - const settingsConfigStr = - typeof initialData.settingsConfig === "string" - ? initialData.settingsConfig - : JSON.stringify(initialData.settingsConfig ?? {}); - const detected = hasCommonConfigSnippet( - settingsConfigStr, - commonConfigSnippet, - ); - setUseCommonConfig(detected); + setUseCommonConfig(false); } } }, [ @@ -200,50 +261,69 @@ export function useCommonConfigSnippet({ getSnippetApplyError, ]); - // 新建模式:如果通用配置片段存在且有效,默认启用 + // ============================================================================ + // 新建模式初始化:如果通用配置有效,默认启用 + // ============================================================================ useEffect(() => { if (!enabled) return; - // 仅新建模式、加载完成、尚未初始化过 if (!initialData && !isLoading && !hasInitializedNewMode.current) { hasInitializedNewMode.current = true; - // 检查片段是否有实质内容 - try { - const snippetObj = JSON.parse(commonConfigSnippet); - const hasContent = Object.keys(snippetObj).length > 0; - if (hasContent) { - setUseCommonConfig(true); - // 合并通用配置到当前配置 - const { updatedConfig, error } = updateCommonConfigSnippet( - settingsConfig, - commonConfigSnippet, - true, - ); - if (!error) { - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } - } - } catch { - // ignore parse error + const parsed = parseSnippet(commonConfigSnippet); + if (!parsed.error && Object.keys(parsed.config).length > 0) { + setUseCommonConfig(true); } } + }, [enabled, initialData, commonConfigSnippet, isLoading, parseSnippet]); + + // ============================================================================ + // 计算最终配置(运行时合并) + // ============================================================================ + const finalConfig = useMemo((): string => { + if (!enabled) return settingsConfig; + + try { + const customParsed = settingsConfig ? JSON.parse(settingsConfig) : {}; + if (!isPlainObject(customParsed)) { + return settingsConfig; + } + + if (!useCommonConfig) { + return settingsConfig; + } + + const snippetParsed = parseSnippet(commonConfigSnippet); + if ( + snippetParsed.error || + Object.keys(snippetParsed.config).length === 0 + ) { + return settingsConfig; + } + + // 通用配置作为 base,自定义配置覆盖 + const merged = computeFinalConfig( + customParsed, + snippetParsed.config, + true, + ); + + return JSON.stringify(merged, null, 2); + } catch { + return settingsConfig; + } }, [ enabled, - initialData, - commonConfigSnippet, - isLoading, settingsConfig, - onConfigChange, + commonConfigSnippet, + useCommonConfig, + parseSnippet, ]); + // ============================================================================ // 处理通用配置开关 + // ============================================================================ const handleCommonConfigToggle = useCallback( (checked: boolean) => { - hasUserToggledCommonConfig.current = true; if (checked) { const snippetError = getSnippetApplyError(commonConfigSnippet); if (snippetError) { @@ -252,185 +332,70 @@ export function useCommonConfigSnippet({ return; } } - const { updatedConfig, error: snippetError } = updateCommonConfigSnippet( - settingsConfig, - commonConfigSnippet, - checked, - ); - - if (snippetError) { - setCommonConfigError(snippetError); - setUseCommonConfig(false); - return; - } - setCommonConfigError(""); setUseCommonConfig(checked); - // 标记正在通过通用配置更新 - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); + // 新架构:不修改 settingsConfig,只改变 enabled 状态 }, - [settingsConfig, commonConfigSnippet, onConfigChange, getSnippetApplyError], + [commonConfigSnippet, getSnippetApplyError], ); + // ============================================================================ // 处理通用配置片段变化 + // ============================================================================ const handleCommonConfigSnippetChange = useCallback( (value: string) => { - const previousSnippet = commonConfigSnippet; setCommonConfigSnippetState(value); if (!value.trim()) { const saveId = ++saveSequenceRef.current; setCommonConfigError(""); - // 保存到数据库(清空) - enqueueSave(() => configApi.setCommonConfigSnippet("claude", "")) - .then(() => { - if (saveSequenceRef.current !== saveId) return; - // 清空时也需要同步:移除所有供应商的通用配置片段 - configApi.syncCommonConfigToProviders( - "claude", - previousSnippet, - "", // newSnippet 为空表示移除 - replaceCommonConfigSnippet, - currentProviderId, - (result) => { - if (saveSequenceRef.current !== saveId) return; - if (result.error) { - toast.error(t("providerForm.commonConfigSyncFailed")); - } - }, - ); - }) - .catch((error) => { + enqueueSave(() => configApi.setCommonConfigSnippet("claude", "")).catch( + (error) => { if (saveSequenceRef.current !== saveId) return; console.error("保存通用配置失败:", error); setCommonConfigError( t("claudeConfig.saveFailed", { error: String(error) }), ); - }); - - if (useCommonConfig) { - const { updatedConfig } = updateCommonConfigSnippet( - settingsConfig, - previousSnippet, - false, - ); - onConfigChange(updatedConfig); - setUseCommonConfig(false); - } + }, + ); return; } - // 验证JSON格式 + // JSON 格式校验 const validationError = validateJsonConfig(value, "通用配置片段"); if (validationError) { setCommonConfigError(validationError); return; - } else { - const saveId = ++saveSequenceRef.current; - setCommonConfigError(""); - // 保存到数据库 - enqueueSave(() => configApi.setCommonConfigSnippet("claude", value)) - .then(() => { - if (saveSequenceRef.current !== saveId) return; - // 保存成功后,同步更新所有启用了通用配置的供应商 - configApi.syncCommonConfigToProviders( - "claude", - previousSnippet, - value, - replaceCommonConfigSnippet, - currentProviderId, - (result) => { - if (saveSequenceRef.current !== saveId) return; - if (result.error) { - toast.error(t("providerForm.commonConfigSyncFailed")); - } - }, - ); - }) - .catch((error) => { - if (saveSequenceRef.current !== saveId) return; - console.error("保存通用配置失败:", error); - setCommonConfigError( - t("claudeConfig.saveFailed", { error: String(error) }), - ); - }); } - // 若当前启用通用配置且格式正确,需要替换为最新片段 - if (useCommonConfig && !validationError) { - const removeResult = updateCommonConfigSnippet( - settingsConfig, - previousSnippet, - false, - ); - if (removeResult.error) { - setCommonConfigError(removeResult.error); - return; - } - const addResult = updateCommonConfigSnippet( - removeResult.updatedConfig, - value, - true, + const saveId = ++saveSequenceRef.current; + setCommonConfigError(""); + enqueueSave(() => + configApi.setCommonConfigSnippet("claude", value), + ).catch((error) => { + if (saveSequenceRef.current !== saveId) return; + console.error("保存通用配置失败:", error); + setCommonConfigError( + t("claudeConfig.saveFailed", { error: String(error) }), ); + }); - if (addResult.error) { - setCommonConfigError(addResult.error); - return; - } - - // 标记正在通过通用配置更新,避免触发状态检查 - isUpdatingFromCommonConfig.current = true; - onConfigChange(addResult.updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } + // 注意:新架构下不再需要同步到其他供应商的 settingsConfig + // 因为 finalConfig 是运行时计算的 }, - [ - commonConfigSnippet, - settingsConfig, - useCommonConfig, - onConfigChange, - currentProviderId, - enqueueSave, - t, - ], + [enqueueSave, t], ); - // 当配置变化时检查是否包含通用配置(避免通过通用配置更新时反复覆盖) - useEffect(() => { - if (!enabled) return; - if (isUpdatingFromCommonConfig.current || isLoading) { - return; - } - const metaByApp = initialData?.meta?.commonConfigEnabledByApp; - const hasExplicitMeta = - metaByApp?.claude !== undefined || - initialData?.meta?.commonConfigEnabled !== undefined; - if (hasExplicitMeta || hasUserToggledCommonConfig.current) { - return; - } - const hasCommon = hasCommonConfigSnippet( - settingsConfig, - commonConfigSnippet, - ); - setUseCommonConfig(hasCommon); - }, [enabled, settingsConfig, commonConfigSnippet, isLoading, initialData]); - - // 从编辑器当前内容提取通用配置片段 + // ============================================================================ + // 从当前最终配置提取通用配置片段 + // ============================================================================ const handleExtract = useCallback(async () => { setIsExtracting(true); setCommonConfigError(""); try { const extracted = await configApi.extractCommonConfigSnippet("claude", { - settingsConfig, + settingsConfig: finalConfig, }); if (!extracted || extracted === "{}") { @@ -441,7 +406,7 @@ export function useCommonConfigSnippet({ // 验证 JSON 格式 const validationError = validateJsonConfig(extracted, "提取的配置"); if (validationError) { - setCommonConfigError(validationError); + setCommonConfigError(t("claudeConfig.extractedConfigInvalid")); return; } @@ -450,6 +415,19 @@ export function useCommonConfigSnippet({ // 保存到后端 await configApi.setCommonConfigSnippet("claude", extracted); + + // 提取成功后,从 settingsConfig 中移除与 extracted 相同的部分 + try { + const customParsed = settingsConfig ? JSON.parse(settingsConfig) : {}; + const extractedParsed = JSON.parse(extracted); + + if (isPlainObject(customParsed) && isPlainObject(extractedParsed)) { + const diffResult = extractDifference(customParsed, extractedParsed); + onConfigChange(JSON.stringify(diffResult.customConfig, null, 2)); + } + } catch { + // 忽略解析错误 + } } catch (error) { console.error("提取通用配置失败:", error); setCommonConfigError( @@ -458,7 +436,7 @@ export function useCommonConfigSnippet({ } finally { setIsExtracting(false); } - }, [settingsConfig, t]); + }, [finalConfig, settingsConfig, onConfigChange, t]); return { useCommonConfig, @@ -469,5 +447,6 @@ export function useCommonConfigSnippet({ handleCommonConfigToggle, handleCommonConfigSnippetChange, handleExtract, + finalConfig, }; } diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts index 35d8a7733..22a00a8df 100644 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts @@ -1,45 +1,83 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; import { configApi } from "@/lib/api"; import { - replaceGeminiCommonConfigSnippet, GEMINI_COMMON_ENV_FORBIDDEN_KEYS, type GeminiForbiddenEnvKey, } from "@/utils/providerConfigUtils"; +import { + computeFinalConfig, + extractDifference, + isPlainObject, +} from "@/utils/configMerge"; import type { ProviderMeta } from "@/types"; const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet"; const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}"; interface UseGeminiCommonConfigProps { + /** + * 当前 env 值(字符串格式,如 "KEY=VALUE\nKEY2=VALUE2") + */ envValue: string; + /** + * env 变化回调 + */ onEnvChange: (env: string) => void; + /** + * 字符串转对象 + */ envStringToObj: (envString: string) => Record; + /** + * 对象转字符串 + */ envObjToString: (envObj: Record) => string; + /** + * 初始数据(编辑模式) + */ initialData?: { settingsConfig?: Record; meta?: ProviderMeta; }; + /** + * 当前选中的预设 ID + */ selectedPresetId?: string; - /** 当前正在编辑的供应商 ID(用于同步时跳过) */ + /** + * 当前正在编辑的供应商 ID + */ currentProviderId?: string; } -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); +export interface UseGeminiCommonConfigReturn { + /** 是否启用通用配置 */ + useCommonConfig: boolean; + /** 通用配置片段 (JSON 格式) */ + commonConfigSnippet: string; + /** 通用配置错误信息 */ + commonConfigError: string; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在提取 */ + isExtracting: boolean; + /** 通用配置开关处理函数 */ + handleCommonConfigToggle: (checked: boolean) => void; + /** 通用配置片段变化处理函数 */ + handleCommonConfigSnippetChange: (snippet: string) => void; + /** 从当前配置提取通用配置 */ + handleExtract: () => Promise; + /** 最终 env 对象(运行时合并结果,只读) */ + finalEnv: Record; } /** - * 管理 Gemini 通用配置片段 (JSON 格式) - * 写入 Gemini 的 .env,但会排除以下敏感字段: - * - GOOGLE_GEMINI_BASE_URL - * - GEMINI_API_KEY + * 管理 Gemini 通用配置片段(重构版) + * + * 新架构: + * - envValue:传入当前 env 字符串 + * - commonConfigSnippet:存储在数据库中的通用配置片段 + * - finalEnv:运行时计算 = merge(commonConfig, customEnv) + * - 开启/关闭通用配置只改变 enabled 状态,不修改 envValue */ export function useGeminiCommonConfig({ envValue, @@ -48,10 +86,14 @@ export function useGeminiCommonConfig({ envObjToString, initialData, selectedPresetId, - currentProviderId, -}: UseGeminiCommonConfigProps) { + // currentProviderId is reserved for future use +}: UseGeminiCommonConfigProps): UseGeminiCommonConfigReturn { const { t } = useTranslation(); + + // 内部管理的通用配置启用状态 const [useCommonConfig, setUseCommonConfig] = useState(false); + + // 通用配置片段(从数据库加载) const [commonConfigSnippet, setCommonConfigSnippetState] = useState( DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET, ); @@ -59,15 +101,7 @@ export function useGeminiCommonConfig({ const [isLoading, setIsLoading] = useState(true); const [isExtracting, setIsExtracting] = useState(false); - // 用于跟踪是否正在通过通用配置更新 - 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) => { @@ -76,13 +110,24 @@ export function useGeminiCommonConfig({ return next; }, []); - // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 + // 用于跟踪编辑模式是否已初始化 + const hasInitializedEditMode = useRef(false); + // 用于跟踪新建模式是否已初始化 + const hasInitializedNewMode = useRef(false); + + // 将 envValue 字符串转换为对象 + const customEnv = useMemo( + () => envStringToObj(envValue), + [envValue, envStringToObj], + ); + + // 当预设变化时,重置初始化标记 useEffect(() => { hasInitializedNewMode.current = false; hasInitializedEditMode.current = false; - hasUserToggledCommonConfig.current = false; }, [selectedPresetId]); + // 解析通用配置片段 const parseSnippetEnv = useCallback( ( snippetString: string, @@ -134,39 +179,29 @@ export function useGeminiCommonConfig({ [t], ); - const applySnippetToEnv = useCallback( - (envObj: Record, snippetEnv: Record) => { - const updated = { ...envObj }; - for (const [key, value] of Object.entries(snippetEnv)) { - if (typeof value === "string") { - updated[key] = value; - } + // 获取片段应用错误 + const getSnippetApplyError = useCallback( + (snippet: string) => { + const parsed = parseSnippetEnv(snippet); + if (parsed.error) { + return parsed.error; } - return updated; + if (Object.keys(parsed.env).length === 0) { + return t("geminiConfig.noCommonConfigToApply"); + } + return ""; }, - [], + [parseSnippetEnv, t], ); - const removeSnippetFromEnv = useCallback( - (envObj: Record, snippetEnv: Record) => { - const updated = { ...envObj }; - for (const [key, value] of Object.entries(snippetEnv)) { - if (typeof value === "string" && updated[key] === value) { - delete updated[key]; - } - } - return updated; - }, - [], - ); - - // 初始化:从数据库加载,支持从 localStorage 迁移 + // ============================================================================ + // 加载通用配置片段(从数据库,支持 localStorage 迁移) + // ============================================================================ useEffect(() => { let mounted = true; const loadSnippet = async () => { try { - // 使用统一 API 加载 const snippet = await configApi.getCommonConfigSnippet("gemini"); if (snippet && snippet.trim()) { @@ -174,29 +209,26 @@ export function useGeminiCommonConfig({ setCommonConfigSnippetState(snippet); } } else { - // 如果数据库中没有,尝试从 localStorage 迁移 + // 尝试从 localStorage 迁移 if (typeof window !== "undefined") { try { const legacySnippet = window.localStorage.getItem(LEGACY_STORAGE_KEY); if (legacySnippet && legacySnippet.trim()) { const parsed = parseSnippetEnv(legacySnippet); - if (parsed.error) { - console.warn( - "[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移", + if (!parsed.error) { + await configApi.setCommonConfigSnippet( + "gemini", + legacySnippet, + ); + if (mounted) { + setCommonConfigSnippetState(legacySnippet); + } + window.localStorage.removeItem(LEGACY_STORAGE_KEY); + console.log( + "[迁移] Gemini 通用配置已从 localStorage 迁移到数据库", ); - return; } - // 迁移到 config.json - await configApi.setCommonConfigSnippet("gemini", legacySnippet); - if (mounted) { - setCommonConfigSnippetState(legacySnippet); - } - // 清理 localStorage - window.localStorage.removeItem(LEGACY_STORAGE_KEY); - console.log( - "[迁移] Gemini 通用配置已从 localStorage 迁移到数据库", - ); } } catch (e) { console.warn("[迁移] 从 localStorage 迁移失败:", e); @@ -219,13 +251,13 @@ export function useGeminiCommonConfig({ }; }, [parseSnippetEnv]); - // 初始化时从 meta 读取启用状态(编辑模式) - // 优先使用 meta,若 meta 未定义则回退到内容检测 + // ============================================================================ + // 编辑模式初始化:从 meta 读取启用状态 + // ============================================================================ useEffect(() => { if (initialData && !isLoading && !hasInitializedEditMode.current) { hasInitializedEditMode.current = true; - // 使用 meta 中记录的按 app 启用状态 const metaByApp = initialData.meta?.commonConfigEnabledByApp; const resolvedMetaEnabled = metaByApp?.gemini ?? initialData.meta?.commonConfigEnabled; @@ -235,171 +267,107 @@ export function useGeminiCommonConfig({ setUseCommonConfig(false); return; } - - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error || Object.keys(parsed.env).length === 0) { - setCommonConfigError( - parsed.error ?? t("geminiConfig.noCommonConfigToApply"), - ); + const snippetError = getSnippetApplyError(commonConfigSnippet); + if (snippetError) { + setCommonConfigError(snippetError); setUseCommonConfig(false); return; } - setCommonConfigError(""); setUseCommonConfig(true); - return; - } - - // meta 未定义,回退到内容检测 - // Gemini 使用 JSON 格式的 env,检测通用配置片段是否已应用 - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error || Object.keys(parsed.env).length === 0) { - setUseCommonConfig(false); } else { - // 检查当前 env 是否包含通用配置中的所有键值对 - const currentEnv = envStringToObj(envValue); - const allKeysMatch = Object.entries(parsed.env).every( - ([key, value]) => currentEnv[key] === value, - ); - setUseCommonConfig(allKeysMatch); + setUseCommonConfig(false); } } - }, [ - initialData, - isLoading, - commonConfigSnippet, - parseSnippetEnv, - envStringToObj, - envValue, - t, - ]); + }, [initialData, isLoading, commonConfigSnippet, getSnippetApplyError]); - // 新建模式:如果通用配置片段存在且有效,默认启用 + // ============================================================================ + // 新建模式初始化:如果通用配置有效,默认启用 + // ============================================================================ useEffect(() => { - // 仅新建模式、加载完成、尚未初始化过 if (!initialData && !isLoading && !hasInitializedNewMode.current) { hasInitializedNewMode.current = true; const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error) return; - const hasContent = Object.keys(parsed.env).length > 0; - if (!hasContent) return; - - setUseCommonConfig(true); - const currentEnv = envStringToObj(envValue); - const merged = applySnippetToEnv(currentEnv, parsed.env); - const nextEnvString = envObjToString(merged); - - isUpdatingFromCommonConfig.current = true; - onEnvChange(nextEnvString); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); + if (!parsed.error && Object.keys(parsed.env).length > 0) { + setUseCommonConfig(true); + } } - }, [ - initialData, - isLoading, - commonConfigSnippet, - envValue, - envStringToObj, - envObjToString, - applySnippetToEnv, - onEnvChange, - parseSnippetEnv, - ]); + }, [initialData, commonConfigSnippet, isLoading, parseSnippetEnv]); + // ============================================================================ + // 计算最终 env(运行时合并) + // ============================================================================ + const finalEnv = useMemo((): Record => { + if (!useCommonConfig) { + return customEnv; + } + + const parsed = parseSnippetEnv(commonConfigSnippet); + if (parsed.error || Object.keys(parsed.env).length === 0) { + return customEnv; + } + + // 通用配置作为 base,自定义 env 覆盖 + const merged = computeFinalConfig( + customEnv as Record, + parsed.env as Record, + true, + ); + + // 转换回 Record + const result: Record = {}; + for (const [key, value] of Object.entries(merged)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; + }, [customEnv, commonConfigSnippet, useCommonConfig, parseSnippetEnv]); + + // ============================================================================ // 处理通用配置开关 + // ============================================================================ const handleCommonConfigToggle = useCallback( (checked: boolean) => { - hasUserToggledCommonConfig.current = true; - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error) { - setCommonConfigError(parsed.error); - setUseCommonConfig(false); - return; + if (checked) { + const snippetError = getSnippetApplyError(commonConfigSnippet); + if (snippetError) { + setCommonConfigError(snippetError); + setUseCommonConfig(false); + return; + } } - if (Object.keys(parsed.env).length === 0) { - setCommonConfigError(t("geminiConfig.noCommonConfigToApply")); - setUseCommonConfig(false); - return; - } - - const currentEnv = envStringToObj(envValue); - const updatedEnvObj = checked - ? applySnippetToEnv(currentEnv, parsed.env) - : removeSnippetFromEnv(currentEnv, parsed.env); - setCommonConfigError(""); setUseCommonConfig(checked); - - isUpdatingFromCommonConfig.current = true; - onEnvChange(envObjToString(updatedEnvObj)); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); + // 新架构:不修改 envValue,只改变 enabled 状态 }, - [ - applySnippetToEnv, - commonConfigSnippet, - envObjToString, - envStringToObj, - envValue, - onEnvChange, - parseSnippetEnv, - removeSnippetFromEnv, - t, - ], + [commonConfigSnippet, getSnippetApplyError], ); + // ============================================================================ // 处理通用配置片段变化 + // ============================================================================ const handleCommonConfigSnippetChange = useCallback( (value: string) => { - const previousSnippet = commonConfigSnippet; setCommonConfigSnippetState(value); if (!value.trim()) { const saveId = ++saveSequenceRef.current; setCommonConfigError(""); - // 保存到 config.json(清空) - enqueueSave(() => configApi.setCommonConfigSnippet("gemini", "")) - .then(() => { - if (saveSequenceRef.current !== saveId) return; - // 清空时也需要同步:移除所有供应商的通用配置片段 - configApi.syncCommonConfigToProviders( - "gemini", - previousSnippet, - "", // newSnippet 为空表示移除 - replaceGeminiCommonConfigSnippet, - currentProviderId, - (result) => { - if (saveSequenceRef.current !== saveId) return; - if (result.error) { - toast.error(t("providerForm.commonConfigSyncFailed")); - } - }, - ); - }) - .catch((error) => { + enqueueSave(() => configApi.setCommonConfigSnippet("gemini", "")).catch( + (error) => { if (saveSequenceRef.current !== saveId) return; console.error("保存 Gemini 通用配置失败:", error); setCommonConfigError( t("geminiConfig.saveFailed", { error: String(error) }), ); - }); - - if (useCommonConfig) { - const parsed = parseSnippetEnv(previousSnippet); - if (!parsed.error && Object.keys(parsed.env).length > 0) { - const currentEnv = envStringToObj(envValue); - const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env); - onEnvChange(envObjToString(updatedEnv)); - } - setUseCommonConfig(false); - } + }, + ); return; } - // 校验 JSON 格式 + // JSON 格式校验 const parsed = parseSnippetEnv(value); if (parsed.error) { setCommonConfigError(parsed.error); @@ -408,103 +376,25 @@ export function useGeminiCommonConfig({ const saveId = ++saveSequenceRef.current; setCommonConfigError(""); - enqueueSave(() => configApi.setCommonConfigSnippet("gemini", value)) - .then(() => { - if (saveSequenceRef.current !== saveId) return; - // 保存成功后,同步更新所有启用了通用配置的供应商 - configApi.syncCommonConfigToProviders( - "gemini", - previousSnippet, - value, - replaceGeminiCommonConfigSnippet, - currentProviderId, - (result) => { - if (saveSequenceRef.current !== saveId) return; - if (result.error) { - toast.error(t("providerForm.commonConfigSyncFailed")); - } - }, - ); - }) - .catch((error) => { - if (saveSequenceRef.current !== saveId) return; - console.error("保存 Gemini 通用配置失败:", error); - setCommonConfigError( - t("geminiConfig.saveFailed", { error: String(error) }), - ); - }); + enqueueSave(() => + configApi.setCommonConfigSnippet("gemini", value), + ).catch((error) => { + if (saveSequenceRef.current !== saveId) return; + console.error("保存 Gemini 通用配置失败:", error); + setCommonConfigError( + t("geminiConfig.saveFailed", { error: String(error) }), + ); + }); - // 若当前启用通用配置,需要替换为最新片段 - if (useCommonConfig) { - const prevParsed = parseSnippetEnv(previousSnippet); - const prevEnv = prevParsed.error ? {} : prevParsed.env; - const nextEnv = parsed.env; - const currentEnv = envStringToObj(envValue); - - const withoutOld = - Object.keys(prevEnv).length > 0 - ? removeSnippetFromEnv(currentEnv, prevEnv) - : currentEnv; - const withNew = - Object.keys(nextEnv).length > 0 - ? applySnippetToEnv(withoutOld, nextEnv) - : withoutOld; - - isUpdatingFromCommonConfig.current = true; - onEnvChange(envObjToString(withNew)); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } + // 注意:新架构下不再需要同步到其他供应商的 settingsConfig + // 因为 finalEnv 是运行时计算的 }, - [ - applySnippetToEnv, - commonConfigSnippet, - currentProviderId, - envObjToString, - envStringToObj, - envValue, - onEnvChange, - enqueueSave, - parseSnippetEnv, - removeSnippetFromEnv, - t, - useCommonConfig, - ], + [enqueueSave, parseSnippetEnv, t], ); - // 当 env 变化时检查是否包含通用配置(避免通过通用配置更新时反复覆盖) - useEffect(() => { - if (isUpdatingFromCommonConfig.current || isLoading) { - return; - } - const metaByApp = initialData?.meta?.commonConfigEnabledByApp; - const hasExplicitMeta = - metaByApp?.gemini !== undefined || - initialData?.meta?.commonConfigEnabled !== undefined; - if (hasExplicitMeta || hasUserToggledCommonConfig.current) { - return; - } - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error || Object.keys(parsed.env).length === 0) { - setUseCommonConfig(false); - return; - } - const envObj = envStringToObj(envValue); - const hasCommon = Object.entries(parsed.env).every( - ([key, value]) => envObj[key] === value, - ); - setUseCommonConfig(hasCommon); - }, [ - envValue, - commonConfigSnippet, - envStringToObj, - isLoading, - parseSnippetEnv, - initialData, - ]); - - // 从编辑器当前内容提取通用配置片段 + // ============================================================================ + // 从当前最终 env 提取通用配置片段 + // ============================================================================ const handleExtract = useCallback(async () => { setIsExtracting(true); setCommonConfigError(""); @@ -512,7 +402,7 @@ export function useGeminiCommonConfig({ try { const extracted = await configApi.extractCommonConfigSnippet("gemini", { settingsConfig: JSON.stringify({ - env: envStringToObj(envValue), + env: finalEnv, }), }); @@ -533,6 +423,19 @@ export function useGeminiCommonConfig({ // 保存到后端 await configApi.setCommonConfigSnippet("gemini", extracted); + + // 提取成功后,从 customEnv 中移除与 extracted 相同的部分 + const diffResult = extractDifference( + customEnv as Record, + parsed.env as Record, + ); + const newCustomEnv: Record = {}; + for (const [key, value] of Object.entries(diffResult.customConfig)) { + if (typeof value === "string") { + newCustomEnv[key] = value; + } + } + onEnvChange(envObjToString(newCustomEnv)); } catch (error) { console.error("提取 Gemini 通用配置失败:", error); setCommonConfigError( @@ -541,7 +444,7 @@ export function useGeminiCommonConfig({ } finally { setIsExtracting(false); } - }, [envStringToObj, envValue, parseSnippetEnv, t]); + }, [finalEnv, customEnv, onEnvChange, envObjToString, parseSnippetEnv, t]); return { useCommonConfig, @@ -552,5 +455,6 @@ export function useGeminiCommonConfig({ handleCommonConfigToggle, handleCommonConfigSnippetChange, handleExtract, + finalEnv, }; }