From 3b61fab4b5888627fe8911b9c6707d3c6f2b6719 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Thu, 29 Jan 2026 22:35:52 +0800 Subject: [PATCH] refactor: unify common config hooks with generic base hook and adapters - Create useCommonConfigBase generic hook (~300 lines) - Create commonConfigAdapters for Claude (JSON), Codex (TOML), Gemini (ENV/JSON) - Refactor three hooks from ~1370 lines to ~430 lines (-940 lines) - Extract useDarkMode hook from three ConfigSections components - Remove dead code: backend _str functions, frontend JSON/TOML unused exports - Deduplicate deepClone/deepMerge utilities - Fix duplicate mod tests in provider.rs --- src-tauri/src/config_merge.rs | 98 ---- src-tauri/src/provider.rs | 2 +- .../providers/forms/CodexConfigSections.tsx | 35 +- .../providers/forms/CommonConfigEditor.tsx | 18 +- .../providers/forms/GeminiConfigSections.tsx | 35 +- .../forms/hooks/useCodexCommonConfig.ts | 448 ++++------------- .../forms/hooks/useCommonConfigSnippet.ts | 403 +-------------- .../forms/hooks/useGeminiCommonConfig.ts | 391 ++------------- src/hooks/commonConfigAdapters.ts | 388 ++++++++++++++ src/hooks/useCommonConfigBase.ts | 473 ++++++++++++++++++ src/hooks/useDarkMode.ts | 27 + src/utils/configMerge.ts | 83 --- src/utils/providerConfigUtils.ts | 37 +- src/utils/tomlConfigMerge.ts | 112 ----- 14 files changed, 1045 insertions(+), 1505 deletions(-) create mode 100644 src/hooks/commonConfigAdapters.ts create mode 100644 src/hooks/useCommonConfigBase.ts create mode 100644 src/hooks/useDarkMode.ts diff --git a/src-tauri/src/config_merge.rs b/src-tauri/src/config_merge.rs index 2e1f37728..bb46dc82e 100644 --- a/src-tauri/src/config_merge.rs +++ b/src-tauri/src/config_merge.rs @@ -6,9 +6,6 @@ //! //! Supports JSON (Claude, Gemini) and TOML (Codex) formats. -// Allow dead code as this is a utility module with functions available for future use -#![allow(dead_code)] - use serde_json::{Map, Value as JsonValue}; use toml::Value as TomlValue; @@ -94,55 +91,6 @@ pub fn compute_final_json_config( result } -/// Compute final JSON config from strings. -/// -/// # Arguments -/// * `custom_config_json` - Custom configuration as JSON string -/// * `common_config_json` - Common configuration as JSON string -/// * `enabled` - Whether common config is enabled -/// -/// # Returns -/// Tuple of (final_config_json, error_message) -pub fn compute_final_json_config_str( - custom_config_json: &str, - common_config_json: &str, - enabled: bool, -) -> (String, Option) { - let custom_config: JsonValue = match serde_json::from_str(custom_config_json) { - Ok(v) => v, - Err(_) => { - return ( - custom_config_json.to_string(), - Some("Failed to parse custom config JSON".to_string()), - ) - } - }; - - let common_config: JsonValue = if common_config_json.trim().is_empty() { - JsonValue::Object(Map::new()) - } else { - match serde_json::from_str(common_config_json) { - Ok(v) => v, - Err(_) => { - return ( - custom_config_json.to_string(), - Some("Failed to parse common config JSON".to_string()), - ) - } - } - }; - - let final_config = compute_final_json_config(&custom_config, &common_config, enabled); - - match serde_json::to_string_pretty(&final_config) { - Ok(s) => (s, None), - Err(e) => ( - custom_config_json.to_string(), - Some(format!("Failed to serialize: {e}")), - ), - } -} - /// Check if two JSON values are deeply equal. fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool { match (a, b) { @@ -247,52 +195,6 @@ pub fn extract_json_difference( (JsonValue::Object(custom_config), has_common_keys) } -/// Extract difference from JSON strings. -/// -/// # Returns -/// Tuple of (custom_config_json, has_common_keys, error_message) -pub fn extract_json_difference_str( - live_config_json: &str, - common_config_json: &str, -) -> (String, bool, Option) { - let live_config: JsonValue = match serde_json::from_str(live_config_json) { - Ok(v) => v, - Err(_) => { - return ( - live_config_json.to_string(), - false, - Some("Failed to parse live config JSON".to_string()), - ) - } - }; - - let common_config: JsonValue = if common_config_json.trim().is_empty() { - JsonValue::Object(Map::new()) - } else { - match serde_json::from_str(common_config_json) { - Ok(v) => v, - Err(_) => { - return ( - live_config_json.to_string(), - false, - Some("Failed to parse common config JSON".to_string()), - ) - } - } - }; - - let (custom_config, has_common_keys) = extract_json_difference(&live_config, &common_config); - - match serde_json::to_string_pretty(&custom_config) { - Ok(s) => (s, has_common_keys, None), - Err(e) => ( - live_config_json.to_string(), - false, - Some(format!("Failed to serialize: {e}")), - ), - } -} - // ============================================================================ // TOML Configuration Merge Functions // ============================================================================ diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 8a7aa61db..e38dab040 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -697,7 +697,7 @@ pub struct OpenCodeModelLimit { } #[cfg(test)] -mod tests { +mod provider_tests { use super::{ ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider, ProviderManager, ProviderMeta, UniversalProvider, diff --git a/src/components/providers/forms/CodexConfigSections.tsx b/src/components/providers/forms/CodexConfigSections.tsx index 7d6c1b720..fd85b1dda 100644 --- a/src/components/providers/forms/CodexConfigSections.tsx +++ b/src/components/providers/forms/CodexConfigSections.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Eye, EyeOff } from "lucide-react"; import JsonEditor from "@/components/JsonEditor"; import { Label } from "@/components/ui/label"; +import { useDarkMode } from "@/hooks/useDarkMode"; interface CodexAuthSectionProps { value: string; @@ -21,22 +22,7 @@ export const CodexAuthSection: React.FC = ({ error, }) => { const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); + const isDarkMode = useDarkMode(); const handleChange = (newValue: string) => { onChange(newValue); @@ -104,24 +90,9 @@ export const CodexConfigSection: React.FC = ({ finalConfig, }) => { const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); + const isDarkMode = useDarkMode(); const [showPreview, setShowPreview] = useState(false); - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - // 当启用通用配置时,自动显示预览 useEffect(() => { if (useCommonConfig && finalConfig) { diff --git a/src/components/providers/forms/CommonConfigEditor.tsx b/src/components/providers/forms/CommonConfigEditor.tsx index ea046d960..ea879326f 100644 --- a/src/components/providers/forms/CommonConfigEditor.tsx +++ b/src/components/providers/forms/CommonConfigEditor.tsx @@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Save, Download, Loader2, Eye, EyeOff } from "lucide-react"; import JsonEditor from "@/components/JsonEditor"; +import { useDarkMode } from "@/hooks/useDarkMode"; interface CommonConfigEditorProps { value: string; @@ -39,24 +40,9 @@ export function CommonConfigEditor({ finalConfig, }: CommonConfigEditorProps) { const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); + const isDarkMode = useDarkMode(); const [showPreview, setShowPreview] = useState(false); - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - // 当启用通用配置时,自动显示预览 useEffect(() => { if (useCommonConfig && finalConfig) { diff --git a/src/components/providers/forms/GeminiConfigSections.tsx b/src/components/providers/forms/GeminiConfigSections.tsx index 4488e2607..ac31533b1 100644 --- a/src/components/providers/forms/GeminiConfigSections.tsx +++ b/src/components/providers/forms/GeminiConfigSections.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Eye, EyeOff } from "lucide-react"; import JsonEditor from "@/components/JsonEditor"; import { Label } from "@/components/ui/label"; +import { useDarkMode } from "@/hooks/useDarkMode"; interface GeminiEnvSectionProps { value: string; @@ -32,24 +33,9 @@ export const GeminiEnvSection: React.FC = ({ finalEnv, }) => { const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); + const isDarkMode = useDarkMode(); const [showPreview, setShowPreview] = useState(false); - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - // 当启用通用配置时,自动显示预览 useEffect(() => { if (useCommonConfig && finalEnv) { @@ -215,22 +201,7 @@ export const GeminiConfigSection: React.FC = ({ configError, }) => { const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); + const isDarkMode = useDarkMode(); return (
diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index e91a9c8ad..b3c3e11e3 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -1,51 +1,24 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import TOML from "smol-toml"; import { configApi } from "@/lib/api"; import { - computeFinalTomlConfig, - extractTomlDifference, -} from "@/utils/tomlConfigMerge"; + useCommonConfigBase, + type UseCommonConfigBaseReturn, +} from "@/hooks/useCommonConfigBase"; +import { codexAdapter } from "@/hooks/commonConfigAdapters"; +import { extractTomlDifference } from "@/utils/tomlConfigMerge"; 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 { /** - * 当前 Codex 配置(JSON 格式,包含 auth 和 config) + * 当前 Codex 配置(可能是纯 TOML 或 JSON wrapper 格式) */ codexConfig: string; /** @@ -97,300 +70,74 @@ export interface UseCodexCommonConfigReturn { } /** - * 管理 Codex 通用配置片段(重构版) + * 检测 codexConfig 是否是 JSON wrapper 格式 + * @returns 如果是 JSON wrapper 返回解析后的对象,否则返回 null + */ +function detectJsonWrapperFormat( + codexConfig: string, +): { auth?: unknown; config?: string } | null { + try { + const parsed = JSON.parse(codexConfig); + if (typeof parsed?.config === "string") { + return parsed; + } + if (typeof parsed === "object" && parsed !== null) { + return parsed; // JSON 对象但没有 config 字段 + } + } catch { + // 不是 JSON + } + return null; +} + +/** + * 管理 Codex 通用配置片段 * - * 新架构: - * - codexConfig:传入当前配置(JSON 格式,包含 auth 和 config) - * - commonConfigSnippet:存储在数据库中的通用 TOML 配置片段 - * - finalConfig:运行时计算 = merge(commonConfig, config) - * - 开启/关闭通用配置只改变 enabled 状态,不修改 codexConfig + * 基于 useCommonConfigBase 泛型 Hook + Codex TOML 适配器实现。 + * 额外处理 Codex 双格式(纯 TOML / JSON wrapper)的写回逻辑。 */ export function useCodexCommonConfig({ codexConfig, onConfigChange, initialData, selectedPresetId, - // currentProviderId is reserved for future use }: UseCodexCommonConfigProps): UseCodexCommonConfigReturn { const { t } = useTranslation(); + const adapter = useMemo(() => codexAdapter, []); - // 内部管理的通用配置启用状态 - const [useCommonConfig, setUseCommonConfig] = useState(false); + // 额外的 isExtracting 状态(base hook 的 handleExtract 不适用于 Codex) + const [localIsExtracting, setLocalIsExtracting] = useState(false); + const [localExtractError, setLocalExtractError] = useState(""); - // 通用配置片段(从数据库加载) - const [commonConfigSnippet, setCommonConfigSnippetState] = useState( - DEFAULT_CODEX_COMMON_CONFIG_SNIPPET, - ); - const [commonConfigError, setCommonConfigError] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [isExtracting, setIsExtracting] = useState(false); - // 是否有待保存的通用配置变更 - const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false); + const base: UseCommonConfigBaseReturn = useCommonConfigBase({ + adapter, + inputValue: codexConfig, + onInputChange: onConfigChange, + initialData, + selectedPresetId, + }); - // 用于跟踪编辑模式是否已初始化 - 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; - }, [selectedPresetId]); - - // ============================================================================ - // 加载通用配置片段(从数据库,支持 localStorage 迁移) - // ============================================================================ - useEffect(() => { - let mounted = true; - - const loadSnippet = async () => { - try { - const snippet = await configApi.getCommonConfigSnippet("codex"); - - if (snippet && snippet.trim()) { - if (mounted) { - setCommonConfigSnippetState(snippet); - } - } else { - // 尝试从 localStorage 迁移 - if (typeof window !== "undefined") { - try { - const legacySnippet = - window.localStorage.getItem(LEGACY_STORAGE_KEY); - if (legacySnippet && legacySnippet.trim()) { - 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 迁移到数据库", - ); - } - } - } catch (e) { - console.warn("[迁移] 从 localStorage 迁移失败:", e); - } - } - } - } catch (error) { - console.error("加载 Codex 通用配置失败:", error); - } finally { - if (mounted) { - setIsLoading(false); - } - } - }; - - loadSnippet(); - - return () => { - mounted = false; - }; - }, []); - - // ============================================================================ - // 编辑模式初始化:从 meta 读取启用状态 - // ============================================================================ - useEffect(() => { - if (initialData && !isLoading && !hasInitializedEditMode.current) { - hasInitializedEditMode.current = true; - - const metaByApp = initialData.meta?.commonConfigEnabledByApp; - const resolvedMetaEnabled = - metaByApp?.codex ?? initialData.meta?.commonConfigEnabled; - - if (resolvedMetaEnabled !== undefined) { - if (!resolvedMetaEnabled) { - setUseCommonConfig(false); - return; - } - if (!hasSnippetContent(commonConfigSnippet)) { - setCommonConfigError(t("codexConfig.noCommonConfigToApply")); - setUseCommonConfig(false); - return; - } - const tomlError = validateTomlFormat(commonConfigSnippet); - if (tomlError) { - setCommonConfigError( - t("codexConfig.tomlFormatError", { defaultValue: "TOML 格式错误" }), - ); - setUseCommonConfig(false); - return; - } - setCommonConfigError(""); - setUseCommonConfig(true); - } else { - setUseCommonConfig(false); - } - } - }, [initialData, isLoading, commonConfigSnippet, hasSnippetContent, t]); - - // ============================================================================ - // 新建模式初始化:如果通用配置有效,默认启用 - // ============================================================================ - useEffect(() => { - if (!initialData && !isLoading && !hasInitializedNewMode.current) { - hasInitializedNewMode.current = true; - - if (hasSnippetContent(commonConfigSnippet)) { - const tomlError = validateTomlFormat(commonConfigSnippet); - if (!tomlError) { - setUseCommonConfig(true); - } - } - } - }, [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) => { - if (checked) { - if (!hasSnippetContent(commonConfigSnippet)) { - setCommonConfigError(t("codexConfig.noCommonConfigToApply")); - setUseCommonConfig(false); - return; - } - const tomlError = validateTomlFormat(commonConfigSnippet); - if (tomlError) { - setCommonConfigError( - t("codexConfig.tomlFormatError", { defaultValue: "TOML 格式错误" }), - ); - setUseCommonConfig(false); - return; - } - } - setCommonConfigError(""); - setUseCommonConfig(checked); - // 新架构:不修改 codexConfig,只改变 enabled 状态 - }, - [commonConfigSnippet, hasSnippetContent, t], - ); - - // ============================================================================ - // 处理通用配置片段变化(延迟保存模式:只更新本地状态,实际保存在表单提交时) - // ============================================================================ - const handleCommonConfigSnippetChange = useCallback( - (value: string) => { - setCommonConfigSnippetState(value); - - if (!value.trim()) { - setCommonConfigError(""); - setHasUnsavedCommonConfig(true); - return; - } - - // TOML 格式校验 - const tomlError = validateTomlFormat(value); - if (tomlError) { - setCommonConfigError( - t("codexConfig.tomlSyntaxError", { - defaultValue: "TOML 格式错误,请检查语法", - }), - ); - return; - } - - setCommonConfigError(""); - setHasUnsavedCommonConfig(true); - - // 注意:新架构下不再需要同步到其他供应商的 settingsConfig - // 因为 finalConfig 是运行时计算的 - }, - [t], - ); - - // ============================================================================ - // 从当前最终配置提取通用配置片段(延迟保存模式:只更新本地状态,实际保存在表单提交时) - // ============================================================================ + // Codex 自定义 handleExtract:处理双格式写回 + 状态管理 const handleExtract = useCallback(async () => { - setIsExtracting(true); - setCommonConfigError(""); + setLocalIsExtracting(true); + setLocalExtractError(""); try { - const extracted = await configApi.extractCommonConfigSnippet("codex", { - settingsConfig: JSON.stringify({ - config: finalConfig ?? "", - }), - }); + const request = adapter.buildExtractRequest(base.finalValue); + const extracted = await configApi.extractCommonConfigSnippet( + "codex", + request, + ); if (!extracted || !extracted.trim()) { - setCommonConfigError(t("codexConfig.extractNoCommonConfig")); + setLocalExtractError(t("codexConfig.extractNoCommonConfig")); return; } // 验证 TOML 格式 - const tomlError = validateTomlFormat(extracted); - if (tomlError) { - setCommonConfigError( + const parseResult = adapter.parseSnippet(extracted); + if (parseResult.error || parseResult.config === null) { + setLocalExtractError( t("codexConfig.extractedTomlInvalid", { defaultValue: "提取的配置 TOML 格式错误", }), @@ -398,37 +145,26 @@ export function useCodexCommonConfig({ return; } - // 更新片段状态(延迟保存:不立即调用后端 API) - setCommonConfigSnippetState(extracted); - setHasUnsavedCommonConfig(true); + // 更新 snippet 状态(通过 base 的 handler) + base.handleCommonConfigSnippetChange(extracted); - // 提取成功后,从 config 中移除与 extracted 相同的部分 - const customToml = extractConfigToml(codexConfig); + // 从 config 中移除与 extracted 相同的部分 + const customToml = adapter.parseInput(codexConfig); const diffResult = extractTomlDifference(customToml, extracted); - if (!diffResult.error) { - // 更新 codexConfig - // codexConfig 可能是两种格式: - // 1. 纯 TOML 字符串(来自 useCodexConfigState) - // 2. JSON 字符串 { auth: {...}, config: "..." }(旧格式) - let updated = false; - try { - const parsed = JSON.parse(codexConfig); - if (typeof parsed?.config === "string") { - // JSON 格式,更新 config 字段 - parsed.config = diffResult.customToml; - onConfigChange(JSON.stringify(parsed, null, 2)); - updated = true; - } - } catch { - // JSON 解析失败,说明是纯 TOML 字符串 - } - // 如果是纯 TOML 字符串,直接更新 - if (!updated) { + if (!diffResult.error) { + // Codex 双格式写回:检测原始格式并保持一致 + const jsonWrapper = detectJsonWrapperFormat(codexConfig); + + if (jsonWrapper && typeof jsonWrapper.config === "string") { + // JSON wrapper 格式,更新 config 字段 + jsonWrapper.config = diffResult.customToml; + onConfigChange(JSON.stringify(jsonWrapper, null, 2)); + } else { + // 纯 TOML 格式 onConfigChange(diffResult.customToml); } - // Notify user that config was modified (提示用户需要保存) toast.success( t("codexConfig.extractSuccessNeedSave", { defaultValue: "已提取通用配置,点击保存按钮完成保存", @@ -437,40 +173,32 @@ export function useCodexCommonConfig({ } } catch (error) { console.error("提取 Codex 通用配置失败:", error); - setCommonConfigError( - t("codexConfig.extractFailed", { error: String(error) }), + setLocalExtractError( + t("codexConfig.extractFailed", { + error: String(error), + defaultValue: "提取失败", + }), ); } finally { - setIsExtracting(false); + setLocalIsExtracting(false); } - }, [finalConfig, codexConfig, onConfigChange, extractConfigToml, t]); + }, [adapter, base, codexConfig, onConfigChange, t]); - // ============================================================================ - // 获取待保存的通用配置片段(用于 handleSubmit 中统一保存) - // ============================================================================ - const getPendingCommonConfigSnippet = useCallback(() => { - return hasUnsavedCommonConfig ? commonConfigSnippet : null; - }, [hasUnsavedCommonConfig, commonConfigSnippet]); - - // ============================================================================ - // 标记通用配置已保存 - // ============================================================================ - const markCommonConfigSaved = useCallback(() => { - setHasUnsavedCommonConfig(false); - }, []); + // 合并 error:优先显示 extract 错误,其次是 base 的错误 + const combinedError = localExtractError || base.commonConfigError; return { - useCommonConfig, - commonConfigSnippet, - commonConfigError, - isLoading, - isExtracting, - handleCommonConfigToggle, - handleCommonConfigSnippetChange, + useCommonConfig: base.useCommonConfig, + commonConfigSnippet: base.commonConfigSnippet, + commonConfigError: combinedError, + isLoading: base.isLoading, + isExtracting: localIsExtracting, + handleCommonConfigToggle: base.handleCommonConfigToggle, + handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange, handleExtract, - finalConfig, - hasUnsavedCommonConfig, - getPendingCommonConfigSnippet, - markCommonConfigSaved, + finalConfig: base.finalValue, + hasUnsavedCommonConfig: base.hasUnsavedCommonConfig, + getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet, + markCommonConfigSaved: base.markCommonConfigSaved, }; } diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts index afb571231..d7e2bd375 100644 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -1,20 +1,11 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { validateJsonConfig } from "@/utils/providerConfigUtils"; -import { configApi } from "@/lib/api"; +import { useMemo } from "react"; import { - computeFinalConfig, - extractDifference, - isPlainObject, -} from "@/utils/configMerge"; + useCommonConfigBase, + type UseCommonConfigBaseReturn, +} from "@/hooks/useCommonConfigBase"; +import { claudeAdapter } from "@/hooks/commonConfigAdapters"; import type { ProviderMeta } from "@/types"; -const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet"; -const DEFAULT_COMMON_CONFIG_SNIPPET = `{ - "includeCoAuthoredBy": false -}`; - interface UseCommonConfigSnippetProps { /** * 当前配置(用于显示和运行时合并) @@ -74,13 +65,9 @@ export interface UseCommonConfigSnippetReturn { } /** - * 管理 Claude 通用配置片段(重构版) + * 管理 Claude 通用配置片段 * - * 新架构: - * - settingsConfig:传入自定义配置(供应商独有部分) - * - commonConfigSnippet:存储在数据库中的通用配置片段 - * - finalConfig:运行时计算 = merge(commonConfig, settingsConfig) - * - 开启/关闭通用配置只改变 enabled 状态,不修改 settingsConfig + * 基于 useCommonConfigBase 泛型 Hook + Claude JSON 适配器实现。 */ export function useCommonConfigSnippet({ settingsConfig, @@ -88,368 +75,30 @@ export function useCommonConfigSnippet({ initialData, selectedPresetId, enabled = true, - // currentProviderId is reserved for future use }: UseCommonConfigSnippetProps): UseCommonConfigSnippetReturn { - const { t } = useTranslation(); + const adapter = useMemo(() => claudeAdapter, []); - // 内部管理的通用配置启用状态 - const [useCommonConfig, setUseCommonConfig] = useState(false); - - // 通用配置片段(从数据库加载) - const [commonConfigSnippet, setCommonConfigSnippetState] = useState( - DEFAULT_COMMON_CONFIG_SNIPPET, - ); - const [commonConfigError, setCommonConfigError] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [isExtracting, setIsExtracting] = useState(false); - // 是否有待保存的通用配置变更 - const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false); - - // 用于跟踪编辑模式是否已初始化 - 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()) { - return t("claudeConfig.noCommonConfigToApply"); - } - const validationError = validateJsonConfig(snippet, "通用配置片段"); - if (validationError) { - return validationError; - } - try { - const parsed = JSON.parse(snippet) as Record; - if (Object.keys(parsed).length === 0) { - return t("claudeConfig.noCommonConfigToApply"); - } - } catch { - return t("claudeConfig.noCommonConfigToApply"); - } - return ""; - }, - [t], - ); - - // ============================================================================ - // 加载通用配置片段(从数据库,支持 localStorage 迁移) - // ============================================================================ - useEffect(() => { - if (!enabled) { - setIsLoading(false); - return; - } - let mounted = true; - - const loadSnippet = async () => { - try { - const snippet = await configApi.getCommonConfigSnippet("claude"); - - if (snippet && snippet.trim()) { - if (mounted) { - setCommonConfigSnippetState(snippet); - } - } else { - // 尝试从 localStorage 迁移 - if (typeof window !== "undefined") { - try { - const legacySnippet = - window.localStorage.getItem(LEGACY_STORAGE_KEY); - if (legacySnippet && legacySnippet.trim()) { - 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 迁移到数据库", - ); - } - } - } catch (e) { - console.warn("[迁移] 从 localStorage 迁移失败:", e); - } - } - } - } catch (error) { - console.error("加载通用配置失败:", error); - } finally { - if (mounted) { - setIsLoading(false); - } - } - }; - - loadSnippet(); - - return () => { - mounted = false; - }; - }, [enabled, parseSnippet]); - - // ============================================================================ - // 编辑模式初始化:从 meta 读取启用状态 - // ============================================================================ - useEffect(() => { - if (!enabled) return; - if (initialData && !isLoading && !hasInitializedEditMode.current) { - hasInitializedEditMode.current = true; - - const metaByApp = initialData.meta?.commonConfigEnabledByApp; - const resolvedMetaEnabled = - metaByApp?.claude ?? 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); - } else { - setUseCommonConfig(false); - } - } - }, [ - enabled, + const base: UseCommonConfigBaseReturn = useCommonConfigBase({ + adapter, + inputValue: settingsConfig, + onInputChange: onConfigChange, initialData, - isLoading, - commonConfigSnippet, - getSnippetApplyError, - ]); - - // ============================================================================ - // 新建模式初始化:如果通用配置有效,默认启用 - // ============================================================================ - useEffect(() => { - if (!enabled) return; - if (!initialData && !isLoading && !hasInitializedNewMode.current) { - hasInitializedNewMode.current = true; - - 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; - } - }, [ + selectedPresetId, enabled, - settingsConfig, - commonConfigSnippet, - useCommonConfig, - parseSnippet, - ]); - - // ============================================================================ - // 处理通用配置开关 - // ============================================================================ - const handleCommonConfigToggle = useCallback( - (checked: boolean) => { - if (checked) { - const snippetError = getSnippetApplyError(commonConfigSnippet); - if (snippetError) { - setCommonConfigError(snippetError); - setUseCommonConfig(false); - return; - } - } - setCommonConfigError(""); - setUseCommonConfig(checked); - // 新架构:不修改 settingsConfig,只改变 enabled 状态 - }, - [commonConfigSnippet, getSnippetApplyError], - ); - - // ============================================================================ - // 处理通用配置片段变化(延迟保存模式:只更新本地状态,实际保存在表单提交时) - // ============================================================================ - const handleCommonConfigSnippetChange = useCallback((value: string) => { - setCommonConfigSnippetState(value); - - if (!value.trim()) { - setCommonConfigError(""); - setHasUnsavedCommonConfig(true); - return; - } - - // JSON 格式校验 - const validationError = validateJsonConfig(value, "通用配置片段"); - if (validationError) { - setCommonConfigError(validationError); - return; - } - - setCommonConfigError(""); - setHasUnsavedCommonConfig(true); - - // 注意:新架构下不再需要同步到其他供应商的 settingsConfig - // 因为 finalConfig 是运行时计算的 - }, []); - - // ============================================================================ - // 从当前最终配置提取通用配置片段(延迟保存模式:只更新本地状态,实际保存在表单提交时) - // ============================================================================ - const handleExtract = useCallback(async () => { - setIsExtracting(true); - setCommonConfigError(""); - - try { - const extracted = await configApi.extractCommonConfigSnippet("claude", { - settingsConfig: finalConfig, - }); - - if (!extracted || extracted === "{}") { - setCommonConfigError(t("claudeConfig.extractNoCommonConfig")); - return; - } - - // 验证 JSON 格式 - const validationError = validateJsonConfig(extracted, "提取的配置"); - if (validationError) { - setCommonConfigError(t("claudeConfig.extractedConfigInvalid")); - return; - } - - // 更新片段状态(延迟保存:不立即调用后端 API) - setCommonConfigSnippetState(extracted); - setHasUnsavedCommonConfig(true); - - // 提取成功后,从 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)); - // Notify user that config was modified (提示用户需要保存) - toast.success( - t("claudeConfig.extractSuccessNeedSave", { - defaultValue: "已提取通用配置,点击保存按钮完成保存", - }), - ); - } - } catch (parseError) { - console.warn( - "[Extract] Failed to update settingsConfig after extract:", - parseError, - ); - } - } catch (error) { - console.error("提取通用配置失败:", error); - setCommonConfigError( - t("claudeConfig.extractFailed", { error: String(error) }), - ); - } finally { - setIsExtracting(false); - } - }, [finalConfig, settingsConfig, onConfigChange, t]); - - // ============================================================================ - // 获取待保存的通用配置片段(用于 handleSubmit 中统一保存) - // ============================================================================ - const getPendingCommonConfigSnippet = useCallback(() => { - return hasUnsavedCommonConfig ? commonConfigSnippet : null; - }, [hasUnsavedCommonConfig, commonConfigSnippet]); - - // ============================================================================ - // 标记通用配置已保存 - // ============================================================================ - const markCommonConfigSaved = useCallback(() => { - setHasUnsavedCommonConfig(false); - }, []); + }); return { - useCommonConfig, - commonConfigSnippet, - commonConfigError, - isLoading, - isExtracting, - handleCommonConfigToggle, - handleCommonConfigSnippetChange, - handleExtract, - finalConfig, - hasUnsavedCommonConfig, - getPendingCommonConfigSnippet, - markCommonConfigSaved, + useCommonConfig: base.useCommonConfig, + commonConfigSnippet: base.commonConfigSnippet, + commonConfigError: base.commonConfigError, + isLoading: base.isLoading, + isExtracting: base.isExtracting, + handleCommonConfigToggle: base.handleCommonConfigToggle, + handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange, + handleExtract: base.handleExtract, + finalConfig: base.finalValue, + hasUnsavedCommonConfig: base.hasUnsavedCommonConfig, + getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet, + markCommonConfigSaved: base.markCommonConfigSaved, }; } diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts index 0680a0c5d..e04ed7696 100644 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts @@ -1,17 +1,11 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { configApi } from "@/lib/api"; +import { useMemo } from "react"; import { - parseGeminiCommonConfigSnippet, - GEMINI_CONFIG_ERROR_CODES, -} from "@/utils/providerConfigUtils"; -import { computeFinalConfig, extractDifference } from "@/utils/configMerge"; + useCommonConfigBase, + type UseCommonConfigBaseReturn, +} from "@/hooks/useCommonConfigBase"; +import { createGeminiAdapter } from "@/hooks/commonConfigAdapters"; 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") @@ -74,9 +68,11 @@ export interface UseGeminiCommonConfigReturn { } /** - * 管理 Gemini 通用配置片段(重构版) + * 管理 Gemini 通用配置片段 * - * 新架构: + * 基于 useCommonConfigBase 泛型 Hook + Gemini ENV/JSON 适配器实现。 + * + * 架构: * - envValue:传入当前 env 字符串 * - commonConfigSnippet:存储在数据库中的通用配置片段 * - finalEnv:运行时计算 = merge(commonConfig, customEnv) @@ -89,355 +85,34 @@ export function useGeminiCommonConfig({ envObjToString, initialData, selectedPresetId, - // 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, - ); - const [commonConfigError, setCommonConfigError] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [isExtracting, setIsExtracting] = useState(false); - // 是否有待保存的通用配置变更 - const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false); - - // 用于跟踪编辑模式是否已初始化 - const hasInitializedEditMode = useRef(false); - // 用于跟踪新建模式是否已初始化 - const hasInitializedNewMode = useRef(false); - - // 将 envValue 字符串转换为对象 - const customEnv = useMemo( - () => envStringToObj(envValue), - [envValue, envStringToObj], + // 创建适配器(需要转换函数) + const adapter = useMemo( + () => createGeminiAdapter({ envStringToObj, envObjToString }), + [envStringToObj, envObjToString], ); - // 当预设变化时,重置初始化标记 - useEffect(() => { - hasInitializedNewMode.current = false; - hasInitializedEditMode.current = false; - }, [selectedPresetId]); - - // 解析通用配置片段 - 使用共享解析器 - // 支持三种格式: ENV (KEY=VALUE), 扁平 JSON, 包裹 JSON {"env":{...}} - const parseSnippetEnv = useCallback( - ( - snippetString: string, - ): { env: Record; error?: string } => { - const result = parseGeminiCommonConfigSnippet(snippetString, { - strictForbiddenKeys: true, - }); - - if (result.error) { - // Map error codes to i18n keys - if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) { - const keys = result.error.split(": ")[1] ?? result.error; - return { - env: {}, - error: t("geminiConfig.commonConfigInvalidKeys", { keys }), - }; - } - if ( - result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING) - ) { - return { - env: {}, - error: t("geminiConfig.commonConfigInvalidValues"), - }; - } - // Generic format error (NOT_OBJECT, ENV_NOT_OBJECT, or parse failure) - return { env: {}, error: t("geminiConfig.invalidJsonFormat") }; - } - - return { env: result.env }; - }, - [t], - ); - - // 获取片段应用错误 - const getSnippetApplyError = useCallback( - (snippet: string) => { - const parsed = parseSnippetEnv(snippet); - if (parsed.error) { - return parsed.error; - } - if (Object.keys(parsed.env).length === 0) { - return t("geminiConfig.noCommonConfigToApply"); - } - return ""; - }, - [parseSnippetEnv, t], - ); - - // ============================================================================ - // 加载通用配置片段(从数据库,支持 localStorage 迁移) - // ============================================================================ - useEffect(() => { - let mounted = true; - - const loadSnippet = async () => { - try { - const snippet = await configApi.getCommonConfigSnippet("gemini"); - - if (snippet && snippet.trim()) { - if (mounted) { - setCommonConfigSnippetState(snippet); - } - } else { - // 尝试从 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) { - await configApi.setCommonConfigSnippet( - "gemini", - legacySnippet, - ); - if (mounted) { - setCommonConfigSnippetState(legacySnippet); - } - window.localStorage.removeItem(LEGACY_STORAGE_KEY); - console.log( - "[迁移] Gemini 通用配置已从 localStorage 迁移到数据库", - ); - } - } - } catch (e) { - console.warn("[迁移] 从 localStorage 迁移失败:", e); - } - } - } - } catch (error) { - console.error("加载 Gemini 通用配置失败:", error); - } finally { - if (mounted) { - setIsLoading(false); - } - } - }; - - loadSnippet(); - - return () => { - mounted = false; - }; - }, [parseSnippetEnv]); - - // ============================================================================ - // 编辑模式初始化:从 meta 读取启用状态 - // ============================================================================ - useEffect(() => { - if (initialData && !isLoading && !hasInitializedEditMode.current) { - hasInitializedEditMode.current = true; - - const metaByApp = initialData.meta?.commonConfigEnabledByApp; - const resolvedMetaEnabled = - metaByApp?.gemini ?? 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); - } else { - setUseCommonConfig(false); - } - } - }, [initialData, isLoading, commonConfigSnippet, getSnippetApplyError]); - - // ============================================================================ - // 新建模式初始化:如果通用配置有效,默认启用 - // ============================================================================ - useEffect(() => { - if (!initialData && !isLoading && !hasInitializedNewMode.current) { - hasInitializedNewMode.current = true; - - const parsed = parseSnippetEnv(commonConfigSnippet); - if (!parsed.error && Object.keys(parsed.env).length > 0) { - setUseCommonConfig(true); - } - } - }, [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) => { - if (checked) { - const snippetError = getSnippetApplyError(commonConfigSnippet); - if (snippetError) { - setCommonConfigError(snippetError); - setUseCommonConfig(false); - return; - } - } - setCommonConfigError(""); - setUseCommonConfig(checked); - // 新架构:不修改 envValue,只改变 enabled 状态 - }, - [commonConfigSnippet, getSnippetApplyError], - ); - - // ============================================================================ - // 处理通用配置片段变化(延迟保存模式:只更新本地状态,实际保存在表单提交时) - // ============================================================================ - const handleCommonConfigSnippetChange = useCallback( - (value: string) => { - setCommonConfigSnippetState(value); - - if (!value.trim()) { - setCommonConfigError(""); - setHasUnsavedCommonConfig(true); - return; - } - - // JSON 格式校验 - const parsed = parseSnippetEnv(value); - if (parsed.error) { - setCommonConfigError(parsed.error); - return; - } - - setCommonConfigError(""); - setHasUnsavedCommonConfig(true); - - // 注意:新架构下不再需要同步到其他供应商的 settingsConfig - // 因为 finalEnv 是运行时计算的 - }, - [parseSnippetEnv], - ); - - // ============================================================================ - // 从当前最终 env 提取通用配置片段(延迟保存模式:只更新本地状态,实际保存在表单提交时) - // ============================================================================ - const handleExtract = useCallback(async () => { - setIsExtracting(true); - setCommonConfigError(""); - - try { - const extracted = await configApi.extractCommonConfigSnippet("gemini", { - settingsConfig: JSON.stringify({ - env: finalEnv, - }), - }); - - if (!extracted || extracted === "{}") { - setCommonConfigError(t("geminiConfig.extractNoCommonConfig")); - return; - } - - // 验证 JSON 格式 - const parsed = parseSnippetEnv(extracted); - if (parsed.error) { - setCommonConfigError(t("geminiConfig.extractedConfigInvalid")); - return; - } - - // 更新片段状态(延迟保存:不立即调用后端 API) - setCommonConfigSnippetState(extracted); - setHasUnsavedCommonConfig(true); - - // 提取成功后,从 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)); - // Notify user that config was modified (提示用户需要保存) - toast.success( - t("geminiConfig.extractSuccessNeedSave", { - defaultValue: "已提取通用配置,点击保存按钮完成保存", - }), - ); - } catch (error) { - console.error("提取 Gemini 通用配置失败:", error); - setCommonConfigError( - t("geminiConfig.extractFailed", { error: String(error) }), - ); - } finally { - setIsExtracting(false); - } - }, [finalEnv, customEnv, onEnvChange, envObjToString, parseSnippetEnv, t]); - - // ============================================================================ - // 获取待保存的通用配置片段(用于 handleSubmit 中统一保存) - // ============================================================================ - const getPendingCommonConfigSnippet = useCallback(() => { - return hasUnsavedCommonConfig ? commonConfigSnippet : null; - }, [hasUnsavedCommonConfig, commonConfigSnippet]); - - // ============================================================================ - // 标记通用配置已保存 - // ============================================================================ - const markCommonConfigSaved = useCallback(() => { - setHasUnsavedCommonConfig(false); - }, []); + const base: UseCommonConfigBaseReturn> = + useCommonConfigBase({ + adapter, + inputValue: envValue, + onInputChange: onEnvChange, + initialData, + selectedPresetId, + }); return { - useCommonConfig, - commonConfigSnippet, - commonConfigError, - isLoading, - isExtracting, - handleCommonConfigToggle, - handleCommonConfigSnippetChange, - handleExtract, - finalEnv, - hasUnsavedCommonConfig, - getPendingCommonConfigSnippet, - markCommonConfigSaved, + useCommonConfig: base.useCommonConfig, + commonConfigSnippet: base.commonConfigSnippet, + commonConfigError: base.commonConfigError, + isLoading: base.isLoading, + isExtracting: base.isExtracting, + handleCommonConfigToggle: base.handleCommonConfigToggle, + handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange, + handleExtract: base.handleExtract, + finalEnv: base.finalValue, + hasUnsavedCommonConfig: base.hasUnsavedCommonConfig, + getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet, + markCommonConfigSaved: base.markCommonConfigSaved, }; } diff --git a/src/hooks/commonConfigAdapters.ts b/src/hooks/commonConfigAdapters.ts new file mode 100644 index 000000000..badb7d870 --- /dev/null +++ b/src/hooks/commonConfigAdapters.ts @@ -0,0 +1,388 @@ +/** + * 通用配置格式适配器 + * + * 提供 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式的适配器实现。 + */ + +import { validateJsonConfig } from "@/utils/providerConfigUtils"; +import { + computeFinalConfig, + extractDifference, + isPlainObject, +} from "@/utils/configMerge"; +import { + computeFinalTomlConfig, + extractTomlDifference, + safeParseToml, +} from "@/utils/tomlConfigMerge"; +import { + parseGeminiCommonConfigSnippet, + GEMINI_CONFIG_ERROR_CODES, +} from "@/utils/providerConfigUtils"; +import type { + CommonConfigAdapter, + ParseResult, + ExtractResult, +} from "./useCommonConfigBase"; + +// ============================================================================ +// Claude Adapter (JSON) +// ============================================================================ + +const CLAUDE_LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet"; +const CLAUDE_DEFAULT_SNIPPET = `{ + "includeCoAuthoredBy": false +}`; + +export const claudeAdapter: CommonConfigAdapter< + Record, + string +> = { + appKey: "claude", + defaultSnippet: CLAUDE_DEFAULT_SNIPPET, + legacyStorageKey: CLAUDE_LEGACY_STORAGE_KEY, + + parseSnippet: (snippet: string): ParseResult> => { + const trimmed = snippet.trim(); + if (!trimmed) { + return { config: {}, error: null }; + } + + try { + const parsed = JSON.parse(trimmed); + if (!isPlainObject(parsed)) { + return { config: null, error: "JSON 格式错误:不是对象" }; + } + return { config: parsed, error: null }; + } catch { + return { config: null, error: "JSON 格式错误" }; + } + }, + + hasValidContent: (snippet: string): boolean => { + try { + const parsed = JSON.parse(snippet.trim()); + return isPlainObject(parsed) && Object.keys(parsed).length > 0; + } catch { + return false; + } + }, + + getApplyError: (snippet: string, t): string => { + if (!snippet.trim()) { + return t("claudeConfig.noCommonConfigToApply"); + } + const validationError = validateJsonConfig(snippet, "通用配置片段"); + if (validationError) { + return validationError; + } + try { + const parsed = JSON.parse(snippet) as Record; + if (Object.keys(parsed).length === 0) { + return t("claudeConfig.noCommonConfigToApply"); + } + } catch { + return t("claudeConfig.noCommonConfigToApply"); + } + return ""; + }, + + parseInput: (input: string): Record => { + try { + const parsed = JSON.parse(input || "{}"); + return isPlainObject(parsed) ? parsed : {}; + } catch { + return {}; + } + }, + + computeFinal: ( + custom: Record, + common: Record, + enabled: boolean, + ): string => { + if (!enabled || Object.keys(common).length === 0) { + return JSON.stringify(custom, null, 2); + } + const merged = computeFinalConfig(custom, common, true); + return JSON.stringify(merged, null, 2); + }, + + extractDiff: ( + custom: Record, + common: Record, + ): ExtractResult> => { + const result = extractDifference(custom, common); + return { + custom: result.customConfig, + hasCommonKeys: result.hasCommonKeys, + }; + }, + + serializeOutput: (config: Record): string => { + return JSON.stringify(config, null, 2); + }, + + buildExtractRequest: (finalValue: string): { settingsConfig: string } => { + return { settingsConfig: finalValue }; + }, +}; + +// ============================================================================ +// Codex Adapter (TOML) +// ============================================================================ + +const CODEX_LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; +const CODEX_DEFAULT_SNIPPET = `# Common Codex config +# Add your common TOML configuration here`; + +/** 检查 TOML 是否有实质内容(非空、非纯注释) */ +function hasTomlContent(toml: string): boolean { + const lines = toml.split("\n"); + return lines.some((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#"); + }); +} + +/** 校验 TOML 格式 */ +function validateTomlFormat(tomlText: string): string | null { + if (!hasTomlContent(tomlText)) { + return null; // 空或纯注释是合法的 + } + const result = safeParseToml(tomlText); + return result.error; +} + +/** + * 从 codexConfig 提取 config 字段(TOML 格式) + * 支持两种格式: + * 1. 直接的 TOML 字符串 + * 2. JSON 字符串 { auth: {...}, config: "TOML" } + */ +function extractConfigToml(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 字符串 + } + + return configInput; +} + +export const codexAdapter: CommonConfigAdapter = { + appKey: "codex", + defaultSnippet: CODEX_DEFAULT_SNIPPET, + legacyStorageKey: CODEX_LEGACY_STORAGE_KEY, + + parseSnippet: (snippet: string): ParseResult => { + const error = validateTomlFormat(snippet); + if (error) { + return { config: null, error }; + } + return { config: snippet, error: null }; + }, + + hasValidContent: (snippet: string): boolean => { + return hasTomlContent(snippet) && !validateTomlFormat(snippet); + }, + + getApplyError: (snippet: string, t): string => { + if (!hasTomlContent(snippet)) { + return t("codexConfig.noCommonConfigToApply"); + } + const error = validateTomlFormat(snippet); + if (error) { + return t("codexConfig.tomlFormatError", { defaultValue: "TOML 格式错误" }); + } + return ""; + }, + + parseInput: (input: string): string => { + return extractConfigToml(input); + }, + + computeFinal: ( + custom: string, + common: string, + enabled: boolean, + ): string => { + if (!enabled || !hasTomlContent(common)) { + return custom; + } + const result = computeFinalTomlConfig(custom, common, true); + return result.error ? custom : result.finalConfig; + }, + + extractDiff: (custom: string, common: string): ExtractResult => { + const result = extractTomlDifference(custom, common); + return { + custom: result.customToml, + hasCommonKeys: result.hasCommonKeys, + error: result.error, + }; + }, + + serializeOutput: (config: string): string => { + return config; + }, + + buildExtractRequest: (finalValue: string): { settingsConfig: string } => { + return { + settingsConfig: JSON.stringify({ config: finalValue ?? "" }), + }; + }, +}; + +// ============================================================================ +// Gemini Adapter (ENV/JSON) +// ============================================================================ + +const GEMINI_LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet"; +const GEMINI_DEFAULT_SNIPPET = "{}"; + +export interface GeminiAdapterOptions { + /** 字符串转对象 */ + envStringToObj: (envString: string) => Record; + /** 对象转字符串 */ + envObjToString: (envObj: Record) => string; +} + +/** + * 创建 Gemini 适配器 + * 需要传入 env 转换函数,因为这些函数依赖于外部实现 + */ +export function createGeminiAdapter( + options: GeminiAdapterOptions, +): CommonConfigAdapter, Record> { + const { envStringToObj, envObjToString } = options; + + return { + appKey: "gemini", + defaultSnippet: GEMINI_DEFAULT_SNIPPET, + legacyStorageKey: GEMINI_LEGACY_STORAGE_KEY, + + parseSnippet: ( + snippet: string, + ): ParseResult> => { + const result = parseGeminiCommonConfigSnippet(snippet, { + strictForbiddenKeys: true, + }); + + if (result.error) { + return { config: null, error: result.error }; + } + + return { config: result.env, error: null }; + }, + + hasValidContent: (snippet: string): boolean => { + const result = parseGeminiCommonConfigSnippet(snippet, { + strictForbiddenKeys: true, + }); + return !result.error && Object.keys(result.env).length > 0; + }, + + getApplyError: (snippet: string, t): string => { + const result = parseGeminiCommonConfigSnippet(snippet, { + strictForbiddenKeys: true, + }); + + if (result.error) { + if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) { + const keys = result.error.split(": ")[1] ?? result.error; + return t("geminiConfig.commonConfigInvalidKeys", { keys }); + } + if ( + result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING) + ) { + return t("geminiConfig.commonConfigInvalidValues"); + } + return t("geminiConfig.invalidJsonFormat"); + } + + if (Object.keys(result.env).length === 0) { + return t("geminiConfig.noCommonConfigToApply"); + } + + return ""; + }, + + parseInput: (input: string): Record => { + return envStringToObj(input); + }, + + computeFinal: ( + custom: Record, + common: Record, + enabled: boolean, + ): Record => { + if (!enabled || Object.keys(common).length === 0) { + return custom; + } + + // 通用配置作为 base,自定义 env 覆盖 + const merged = computeFinalConfig( + custom as Record, + common as Record, + true, + ); + + // 转换回 Record + const result: Record = {}; + for (const [key, value] of Object.entries(merged)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; + }, + + extractDiff: ( + custom: Record, + common: Record, + ): ExtractResult> => { + const result = extractDifference( + custom as Record, + common as Record, + ); + + // 转换回 Record + const customResult: Record = {}; + for (const [key, value] of Object.entries(result.customConfig)) { + if (typeof value === "string") { + customResult[key] = value; + } + } + + return { + custom: customResult, + hasCommonKeys: result.hasCommonKeys, + }; + }, + + serializeOutput: (config: Record): string => { + return envObjToString(config); + }, + + buildExtractRequest: ( + finalValue: Record, + ): { settingsConfig: string } => { + return { + settingsConfig: JSON.stringify({ env: finalValue }), + }; + }, + }; +} diff --git a/src/hooks/useCommonConfigBase.ts b/src/hooks/useCommonConfigBase.ts new file mode 100644 index 000000000..49c5caf1a --- /dev/null +++ b/src/hooks/useCommonConfigBase.ts @@ -0,0 +1,473 @@ +/** + * 通用配置管理基础 Hook + * + * 提供加载、切换、保存、提取等通用逻辑,通过 Adapter 注入格式特定处理。 + * 支持 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式。 + */ + +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { configApi } from "@/lib/api"; +import type { ProviderMeta } from "@/types"; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +/** 应用类型 */ +export type CommonConfigAppKey = "claude" | "codex" | "gemini"; + +/** 解析结果 */ +export interface ParseResult { + config: T | null; + error: string | null; +} + +/** 合并结果 */ +export interface MergeResult { + merged: T; + error?: string; +} + +/** 差异提取结果 */ +export interface ExtractResult { + custom: T; + hasCommonKeys: boolean; + error?: string; +} + +/** + * 格式适配器接口 + * + * 每种格式(JSON/TOML/ENV)需要实现此接口 + */ +export interface CommonConfigAdapter { + /** 应用标识 */ + appKey: CommonConfigAppKey; + + /** 默认片段内容 */ + defaultSnippet: string; + + /** localStorage 迁移 key (可选) */ + legacyStorageKey?: string; + + /** + * 解析片段字符串为配置对象 + * @param snippet - 原始片段字符串 + * @returns 解析结果 + */ + parseSnippet: (snippet: string) => ParseResult; + + /** + * 验证片段是否有有效内容(非空、非纯注释) + * @param snippet - 片段字符串 + * @returns 是否有有效内容 + */ + hasValidContent: (snippet: string) => boolean; + + /** + * 获取片段应用错误(用于 toggle 时验证) + * @param snippet - 片段字符串 + * @param t - i18n 翻译函数 + * @returns 错误信息,无错误返回空字符串 + */ + getApplyError: ( + snippet: string, + t: (key: string, options?: Record) => string, + ) => string; + + /** + * 从表单输入解析当前配置 + * @param input - 表单输入值 + * @returns 解析后的配置对象 + */ + parseInput: (input: string) => TConfig; + + /** + * 计算最终合并配置 + * @param custom - 自定义配置 + * @param common - 通用配置 + * @param enabled - 是否启用 + * @returns 合并后的最终配置 + */ + computeFinal: (custom: TConfig, common: TConfig, enabled: boolean) => TFinal; + + /** + * 提取差异(从自定义配置中移除与通用配置相同的部分) + * @param custom - 自定义配置 + * @param common - 通用配置 + * @returns 差异结果 + */ + extractDiff: (custom: TConfig, common: TConfig) => ExtractResult; + + /** + * 将配置对象序列化为表单输出 + * @param config - 配置对象 + * @returns 序列化后的字符串 + */ + serializeOutput: (config: TConfig) => string; + + /** + * 构建提取 API 的请求参数 + * @param finalValue - 最终合并后的值 + * @returns API 请求参数 + */ + buildExtractRequest: (finalValue: TFinal) => { settingsConfig: string }; +} + +// ============================================================================ +// Props 和 Return 类型 +// ============================================================================ + +export interface UseCommonConfigBaseProps { + /** 格式适配器 */ + adapter: CommonConfigAdapter; + /** 当前表单输入值 */ + inputValue: string; + /** 输入变化回调 */ + onInputChange: (value: string) => void; + /** 初始数据(编辑模式) */ + initialData?: { + settingsConfig?: Record; + meta?: ProviderMeta; + }; + /** 当前选中的预设 ID */ + selectedPresetId?: string; + /** 是否启用此 hook(默认 true) */ + enabled?: boolean; +} + +export interface UseCommonConfigBaseReturn { + /** 是否启用通用配置 */ + useCommonConfig: boolean; + /** 通用配置片段 */ + commonConfigSnippet: string; + /** 通用配置错误信息 */ + commonConfigError: string; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在提取 */ + isExtracting: boolean; + /** 通用配置开关处理函数 */ + handleCommonConfigToggle: (checked: boolean) => void; + /** 通用配置片段变化处理函数 */ + handleCommonConfigSnippetChange: (snippet: string) => void; + /** 从当前配置提取通用配置 */ + handleExtract: () => Promise; + /** 最终配置(运行时合并结果,只读) */ + finalValue: TFinal; + /** 是否有待保存的通用配置变更 */ + hasUnsavedCommonConfig: boolean; + /** 获取待保存的通用配置片段(用于 handleSubmit) */ + getPendingCommonConfigSnippet: () => string | null; + /** 标记通用配置已保存 */ + markCommonConfigSaved: () => void; +} + +// ============================================================================ +// 基础 Hook 实现 +// ============================================================================ + +export function useCommonConfigBase({ + adapter, + inputValue, + onInputChange, + initialData, + selectedPresetId, + enabled = true, +}: UseCommonConfigBaseProps): UseCommonConfigBaseReturn { + const { t } = useTranslation(); + + // ============================================================================ + // 状态 + // ============================================================================ + const [useCommonConfig, setUseCommonConfig] = useState(false); + const [commonConfigSnippet, setCommonConfigSnippetState] = useState( + adapter.defaultSnippet, + ); + const [commonConfigError, setCommonConfigError] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isExtracting, setIsExtracting] = useState(false); + const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false); + + // 初始化跟踪 + const hasInitializedEditMode = useRef(false); + const hasInitializedNewMode = useRef(false); + + // ============================================================================ + // 预设变化时重置初始化标记 + // ============================================================================ + useEffect(() => { + if (!enabled) return; + hasInitializedNewMode.current = false; + hasInitializedEditMode.current = false; + }, [selectedPresetId, enabled]); + + // ============================================================================ + // 加载通用配置片段(从数据库,支持 localStorage 迁移) + // ============================================================================ + useEffect(() => { + if (!enabled) { + setIsLoading(false); + return; + } + + let mounted = true; + + const loadSnippet = async () => { + try { + const snippet = await configApi.getCommonConfigSnippet(adapter.appKey); + + if (snippet && snippet.trim()) { + if (mounted) { + setCommonConfigSnippetState(snippet); + } + } else if (adapter.legacyStorageKey && typeof window !== "undefined") { + // 尝试从 localStorage 迁移 + try { + const legacySnippet = window.localStorage.getItem( + adapter.legacyStorageKey, + ); + if (legacySnippet && legacySnippet.trim()) { + const parsed = adapter.parseSnippet(legacySnippet); + if (!parsed.error) { + await configApi.setCommonConfigSnippet( + adapter.appKey, + legacySnippet, + ); + if (mounted) { + setCommonConfigSnippetState(legacySnippet); + } + window.localStorage.removeItem(adapter.legacyStorageKey); + console.log( + `[迁移] ${adapter.appKey} 通用配置已从 localStorage 迁移到数据库`, + ); + } + } + } catch (e) { + console.warn("[迁移] 从 localStorage 迁移失败:", e); + } + } + } catch (error) { + console.error(`加载 ${adapter.appKey} 通用配置失败:`, error); + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + loadSnippet(); + + return () => { + mounted = false; + }; + }, [enabled, adapter]); + + // ============================================================================ + // 编辑模式初始化:从 meta 读取启用状态 + // ============================================================================ + useEffect(() => { + if (!enabled) return; + if (initialData && !isLoading && !hasInitializedEditMode.current) { + hasInitializedEditMode.current = true; + + const metaByApp = initialData.meta?.commonConfigEnabledByApp; + const resolvedMetaEnabled = + metaByApp?.[adapter.appKey] ?? initialData.meta?.commonConfigEnabled; + + if (resolvedMetaEnabled !== undefined) { + if (!resolvedMetaEnabled) { + setUseCommonConfig(false); + return; + } + const applyError = adapter.getApplyError(commonConfigSnippet, t); + if (applyError) { + setCommonConfigError(applyError); + setUseCommonConfig(false); + return; + } + setCommonConfigError(""); + setUseCommonConfig(true); + } else { + setUseCommonConfig(false); + } + } + }, [enabled, initialData, isLoading, commonConfigSnippet, adapter, t]); + + // ============================================================================ + // 新建模式初始化:如果通用配置有效,默认启用 + // ============================================================================ + useEffect(() => { + if (!enabled) return; + if (!initialData && !isLoading && !hasInitializedNewMode.current) { + hasInitializedNewMode.current = true; + + if (adapter.hasValidContent(commonConfigSnippet)) { + const parsed = adapter.parseSnippet(commonConfigSnippet); + if (!parsed.error && parsed.config !== null) { + setUseCommonConfig(true); + } + } + } + }, [enabled, initialData, commonConfigSnippet, isLoading, adapter]); + + // ============================================================================ + // 计算最终配置(运行时合并) + // ============================================================================ + const finalValue = useMemo((): TFinal => { + const customConfig = adapter.parseInput(inputValue); + + if (!enabled || !useCommonConfig) { + return adapter.computeFinal(customConfig, customConfig, false); + } + + const snippetParsed = adapter.parseSnippet(commonConfigSnippet); + if (snippetParsed.error || snippetParsed.config === null) { + return adapter.computeFinal(customConfig, customConfig, false); + } + + return adapter.computeFinal(customConfig, snippetParsed.config, true); + }, [enabled, inputValue, commonConfigSnippet, useCommonConfig, adapter]); + + // ============================================================================ + // 处理通用配置开关 + // ============================================================================ + const handleCommonConfigToggle = useCallback( + (checked: boolean) => { + if (checked) { + const applyError = adapter.getApplyError(commonConfigSnippet, t); + if (applyError) { + setCommonConfigError(applyError); + setUseCommonConfig(false); + return; + } + } + setCommonConfigError(""); + setUseCommonConfig(checked); + }, + [commonConfigSnippet, adapter, t], + ); + + // ============================================================================ + // 处理通用配置片段变化(延迟保存模式) + // ============================================================================ + const handleCommonConfigSnippetChange = useCallback( + (value: string) => { + setCommonConfigSnippetState(value); + + if (!value.trim()) { + setCommonConfigError(""); + setHasUnsavedCommonConfig(true); + return; + } + + // 格式校验 + const parsed = adapter.parseSnippet(value); + if (parsed.error) { + setCommonConfigError(parsed.error); + return; + } + + setCommonConfigError(""); + setHasUnsavedCommonConfig(true); + }, + [adapter], + ); + + // ============================================================================ + // 从当前最终配置提取通用配置片段 + // ============================================================================ + const handleExtract = useCallback(async () => { + setIsExtracting(true); + setCommonConfigError(""); + + try { + const request = adapter.buildExtractRequest(finalValue); + const extracted = await configApi.extractCommonConfigSnippet( + adapter.appKey, + request, + ); + + if (!extracted || extracted === "{}" || !extracted.trim()) { + setCommonConfigError( + t(`${adapter.appKey}Config.extractNoCommonConfig`, { + defaultValue: "无法提取通用配置", + }), + ); + return; + } + + // 验证提取结果格式 + const extractedParsed = adapter.parseSnippet(extracted); + if (extractedParsed.error || extractedParsed.config === null) { + setCommonConfigError( + t(`${adapter.appKey}Config.extractedConfigInvalid`, { + defaultValue: "提取的配置格式错误", + }), + ); + return; + } + + // 更新片段状态 + setCommonConfigSnippetState(extracted); + setHasUnsavedCommonConfig(true); + + // 从自定义配置中移除与提取内容相同的部分 + const customConfig = adapter.parseInput(inputValue); + const diffResult = adapter.extractDiff( + customConfig, + extractedParsed.config, + ); + + if (!diffResult.error) { + onInputChange(adapter.serializeOutput(diffResult.custom)); + toast.success( + t(`${adapter.appKey}Config.extractSuccessNeedSave`, { + defaultValue: "已提取通用配置,点击保存按钮完成保存", + }), + ); + } + } catch (error) { + console.error(`提取 ${adapter.appKey} 通用配置失败:`, error); + setCommonConfigError( + t(`${adapter.appKey}Config.extractFailed`, { + error: String(error), + defaultValue: "提取失败", + }), + ); + } finally { + setIsExtracting(false); + } + }, [adapter, finalValue, inputValue, onInputChange, t]); + + // ============================================================================ + // 获取待保存的通用配置片段 + // ============================================================================ + const getPendingCommonConfigSnippet = useCallback(() => { + return hasUnsavedCommonConfig ? commonConfigSnippet : null; + }, [hasUnsavedCommonConfig, commonConfigSnippet]); + + // ============================================================================ + // 标记通用配置已保存 + // ============================================================================ + const markCommonConfigSaved = useCallback(() => { + setHasUnsavedCommonConfig(false); + }, []); + + return { + useCommonConfig, + commonConfigSnippet, + commonConfigError, + isLoading, + isExtracting, + handleCommonConfigToggle, + handleCommonConfigSnippetChange, + handleExtract, + finalValue, + hasUnsavedCommonConfig, + getPendingCommonConfigSnippet, + markCommonConfigSaved, + }; +} diff --git a/src/hooks/useDarkMode.ts b/src/hooks/useDarkMode.ts new file mode 100644 index 000000000..1ec26781d --- /dev/null +++ b/src/hooks/useDarkMode.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from "react"; + +/** + * Hook to track dark mode state by observing document.documentElement class changes. + * + * @returns boolean indicating if dark mode is currently active + */ +export function useDarkMode(): boolean { + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + + const observer = new MutationObserver(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + return isDarkMode; +} diff --git a/src/utils/configMerge.ts b/src/utils/configMerge.ts index a154a3e86..d4d9c41bf 100644 --- a/src/utils/configMerge.ts +++ b/src/utils/configMerge.ts @@ -208,86 +208,3 @@ export const extractDifference = ( return { customConfig, hasCommonKeys }; }; - -// ============================================================================ -// JSON 格式便捷函数 -// ============================================================================ - -/** - * 计算最终配置(JSON 字符串版本) - */ -export const computeFinalConfigJson = ( - customConfigJson: string, - commonConfigJson: string, - enabled: boolean, -): { finalConfig: string; error?: string } => { - try { - const customConfig = customConfigJson ? JSON.parse(customConfigJson) : {}; - const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {}; - - if (!isPlainObject(customConfig)) { - return { - finalConfig: customConfigJson, - error: "自定义配置必须是 JSON 对象", - }; - } - - if (!isPlainObject(commonConfig)) { - return { - finalConfig: customConfigJson, - error: "通用配置必须是 JSON 对象", - }; - } - - const final = computeFinalConfig(customConfig, commonConfig, enabled); - return { - finalConfig: JSON.stringify(final, null, 2), - }; - } catch (err) { - return { - finalConfig: customConfigJson, - error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`, - }; - } -}; - -/** - * 从 JSON 字符串中提取差异 - */ -export const extractDifferenceJson = ( - liveConfigJson: string, - commonConfigJson: string, -): { customConfig: string; hasCommonKeys: boolean; error?: string } => { - try { - const liveConfig = liveConfigJson ? JSON.parse(liveConfigJson) : {}; - const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {}; - - if (!isPlainObject(liveConfig)) { - return { - customConfig: liveConfigJson, - hasCommonKeys: false, - error: "Live 配置必须是 JSON 对象", - }; - } - - if (!isPlainObject(commonConfig)) { - return { - customConfig: liveConfigJson, - hasCommonKeys: false, - error: "通用配置必须是 JSON 对象", - }; - } - - const result = extractDifference(liveConfig, commonConfig); - return { - customConfig: JSON.stringify(result.customConfig, null, 2), - hasCommonKeys: result.hasCommonKeys, - }; - } catch (err) { - return { - customConfig: liveConfigJson, - hasCommonKeys: false, - error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`, - }; - } -}; diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 04d5dd133..8a643b521 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -2,7 +2,7 @@ import type { TemplateValueConfig } from "../config/claudeProviderPresets"; import { normalizeQuotes } from "@/utils/textNormalization"; -import { isPlainObject } from "@/utils/configMerge"; +import { isPlainObject, deepClone, deepMerge } from "@/utils/configMerge"; // Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用) export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [ @@ -12,24 +12,6 @@ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [ export type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number]; -const deepMerge = ( - target: Record, - source: Record, -): Record => { - Object.entries(source).forEach(([key, value]) => { - if (isPlainObject(value)) { - if (!isPlainObject(target[key])) { - target[key] = {}; - } - deepMerge(target[key], value); - } else { - // 直接覆盖非对象字段(数组/基础类型) - target[key] = value; - } - }); - return target; -}; - const deepRemove = ( target: Record, source: Record, @@ -66,23 +48,6 @@ const isSubset = (target: any, source: any): boolean => { return target === source; }; -// 深拷贝函数 -const deepClone = (obj: T): T => { - if (obj === null || typeof obj !== "object") return obj; - if (obj instanceof Date) return new Date(obj.getTime()) as T; - if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T; - if (obj instanceof Object) { - const clonedObj = {} as T; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - clonedObj[key] = deepClone(obj[key]); - } - } - return clonedObj; - } - return obj; -}; - export interface UpdateCommonConfigResult { updatedConfig: string; error?: string; diff --git a/src/utils/tomlConfigMerge.ts b/src/utils/tomlConfigMerge.ts index 92a14c10c..74eb746b2 100644 --- a/src/utils/tomlConfigMerge.ts +++ b/src/utils/tomlConfigMerge.ts @@ -244,118 +244,6 @@ export const extractTomlDifference = ( }; }; -// ============================================================================ -// TOML Section 级别处理(保留原有格式和注释的场景) -// ============================================================================ - -/** - * TOML 解析结构(用于保留格式的场景) - */ -export interface TomlParsedStructure { - topLevel: string[]; - sections: Map; - sectionOrder: string[]; -} - -/** - * 解析 TOML 文本为段落结构(保留原始行格式) - */ -export const parseTomlStructure = (tomlText: string): TomlParsedStructure => { - const lines = tomlText.split("\n"); - const topLevel: string[] = []; - const sections = new Map(); - const sectionOrder: string[] = []; - - let currentSection: string | null = null; - - for (const line of lines) { - const trimmed = line.trim(); - - // 检查是否是 section 头 - const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); - if (sectionMatch) { - currentSection = sectionMatch[1]; - if (!sections.has(currentSection)) { - sections.set(currentSection, []); - sectionOrder.push(currentSection); - } - continue; - } - - if (currentSection === null) { - topLevel.push(line); - } else { - const sectionLines = sections.get(currentSection)!; - sectionLines.push(line); - } - } - - return { topLevel, sections, sectionOrder }; -}; - -/** - * 从行中提取键名 - */ -export const extractKeyFromLine = (line: string): string | null => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) { - return null; - } - const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=/); - return match ? match[1] : null; -}; - -/** - * 重建 TOML 文本(从结构) - */ -export const rebuildTomlFromStructure = ( - structure: TomlParsedStructure, -): string => { - const resultLines: string[] = []; - - // 添加顶级内容 - let lastWasEmpty = true; - for (const line of structure.topLevel) { - const isEmpty = !line.trim(); - if (isEmpty && lastWasEmpty) { - continue; - } - resultLines.push(line); - lastWasEmpty = isEmpty; - } - - // 添加 sections - for (const sectionName of structure.sectionOrder) { - const sectionLines = structure.sections.get(sectionName)!; - - if (resultLines.length > 0 && resultLines[resultLines.length - 1].trim()) { - resultLines.push(""); - } - - resultLines.push(`[${sectionName}]`); - - lastWasEmpty = false; - for (const line of sectionLines) { - const isEmpty = !line.trim(); - if (isEmpty && lastWasEmpty) { - continue; - } - resultLines.push(line); - lastWasEmpty = isEmpty; - } - } - - // 清理末尾空行 - while ( - resultLines.length > 0 && - !resultLines[resultLines.length - 1].trim() - ) { - resultLines.pop(); - } - - return resultLines.length > 0 ? resultLines.join("\n") + "\n" : ""; -}; - /** * 检查 TOML 配置是否包含通用配置的内容 * 使用对象级别比较,而非字符串匹配