From 154ff4c81916d2640a3ce5b29ef76c1203ab9af2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 15 Nov 2025 19:52:49 +0800 Subject: [PATCH] feat(config): unify common config snippets persistence across all apps - Add unified `common_config_snippets` structure to MultiAppConfig - Implement `get_common_config_snippet` and `set_common_config_snippet` commands - Replace localStorage with config.json persistence for Codex and Gemini - Auto-migrate legacy `claude_common_config_snippet` to new unified structure - Deprecate individual API methods in favor of unified interface - Add automatic migration from localStorage on first load BREAKING CHANGE: Common config snippets now stored in unified `common_config_snippets` object instead of separate fields --- src-tauri/src/app_config.rs | 46 ++++++- src-tauri/src/commands/config.rs | 74 +++++++++++- src-tauri/src/lib.rs | 2 + .../forms/hooks/useCodexCommonConfig.ts | 111 +++++++++++------ .../forms/hooks/useCommonConfigSnippet.ts | 13 +- .../forms/hooks/useGeminiCommonConfig.ts | 112 ++++++++++++------ src/lib/api/config.ts | 32 ++++- 7 files changed, 300 insertions(+), 90 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 30ddbf2..2daace5 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -174,6 +174,39 @@ impl FromStr for AppType { } } +/// 通用配置片段(按应用分治) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CommonConfigSnippets { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gemini: Option, +} + +impl CommonConfigSnippets { + /// 获取指定应用的通用配置片段 + pub fn get(&self, app: &AppType) -> Option<&String> { + match app { + AppType::Claude => self.claude.as_ref(), + AppType::Codex => self.codex.as_ref(), + AppType::Gemini => self.gemini.as_ref(), + } + } + + /// 设置指定应用的通用配置片段 + pub fn set(&mut self, app: &AppType, snippet: Option) { + match app { + AppType::Claude => self.claude = snippet, + AppType::Codex => self.codex = snippet, + AppType::Gemini => self.gemini = snippet, + } + } +} + /// 多应用配置结构(向后兼容) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultiAppConfig { @@ -188,7 +221,10 @@ pub struct MultiAppConfig { /// Prompt 配置(按客户端分治) #[serde(default)] pub prompts: PromptRoot, - /// Claude 通用配置片段(JSON 字符串,用于跨供应商共享配置) + /// 通用配置片段(按应用分治) + #[serde(default)] + pub common_config_snippets: CommonConfigSnippets, + /// Claude 通用配置片段(旧字段,用于向后兼容迁移) #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_common_config_snippet: Option, } @@ -209,6 +245,7 @@ impl Default for MultiAppConfig { apps, mcp: McpRoot::default(), prompts: PromptRoot::default(), + common_config_snippets: CommonConfigSnippets::default(), claude_common_config_snippet: None, } } @@ -278,6 +315,13 @@ impl MultiAppConfig { updated = true; } + // 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude + if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() { + log::info!("迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"); + config.common_config_snippets.claude = Some(old_claude_snippet); + updated = true; + } + if updated { log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置..."); config.save()?; diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index fbbbbd8..4724430 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -136,7 +136,7 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { Ok(true) } -/// 获取 Claude 通用配置片段 +/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet) #[tauri::command] pub async fn get_claude_common_config_snippet( state: tauri::State<'_, crate::store::AppState>, @@ -145,10 +145,10 @@ pub async fn get_claude_common_config_snippet( .config .read() .map_err(|e| format!("读取配置锁失败: {e}"))?; - Ok(guard.claude_common_config_snippet.clone()) + Ok(guard.common_config_snippets.claude.clone()) } -/// 设置 Claude 通用配置片段 +/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet) #[tauri::command] pub async fn set_claude_common_config_snippet( snippet: String, @@ -165,7 +165,7 @@ pub async fn set_claude_common_config_snippet( .map_err(|e| format!("无效的 JSON 格式: {e}"))?; } - guard.claude_common_config_snippet = if snippet.trim().is_empty() { + guard.common_config_snippets.claude = if snippet.trim().is_empty() { None } else { Some(snippet) @@ -174,3 +174,69 @@ pub async fn set_claude_common_config_snippet( guard.save().map_err(|e| e.to_string())?; Ok(()) } + +/// 获取通用配置片段(统一接口) +#[tauri::command] +pub async fn get_common_config_snippet( + app_type: String, + state: tauri::State<'_, crate::store::AppState>, +) -> Result, String> { + use crate::app_config::AppType; + use std::str::FromStr; + + let app = AppType::from_str(&app_type) + .map_err(|e| format!("无效的应用类型: {}", e))?; + + let guard = state + .config + .read() + .map_err(|e| format!("读取配置锁失败: {}", e))?; + + Ok(guard.common_config_snippets.get(&app).cloned()) +} + +/// 设置通用配置片段(统一接口) +#[tauri::command] +pub async fn set_common_config_snippet( + app_type: String, + snippet: String, + state: tauri::State<'_, crate::store::AppState>, +) -> Result<(), String> { + use crate::app_config::AppType; + use std::str::FromStr; + + let app = AppType::from_str(&app_type) + .map_err(|e| format!("无效的应用类型: {}", e))?; + + let mut guard = state + .config + .write() + .map_err(|e| format!("写入配置锁失败: {}", e))?; + + // 验证格式(根据应用类型) + if !snippet.trim().is_empty() { + match app { + AppType::Claude | AppType::Gemini => { + // 验证 JSON 格式 + serde_json::from_str::(&snippet) + .map_err(|e| format!("无效的 JSON 格式: {}", e))?; + } + AppType::Codex => { + // TOML 格式暂不验证(或可使用 toml crate) + // 注意:TOML 验证较为复杂,暂时跳过 + } + } + } + + guard.common_config_snippets.set( + &app, + if snippet.trim().is_empty() { + None + } else { + Some(snippet) + }, + ); + + guard.save().map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 122cc89..9e8852e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -517,6 +517,8 @@ pub fn run() { commands::open_app_config_folder, commands::get_claude_common_config_snippet, commands::set_claude_common_config_snippet, + commands::get_common_config_snippet, + commands::set_common_config_snippet, commands::read_live_provider_settings, commands::get_settings, commands::save_settings, diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index a831bcb..761fe53 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -3,8 +3,9 @@ import { updateTomlCommonConfigSnippet, hasTomlCommonConfigSnippet, } from "@/utils/providerConfigUtils"; +import { configApi } from "@/lib/api"; -const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; +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`; @@ -18,6 +19,7 @@ interface UseCodexCommonConfigProps { /** * 管理 Codex 通用配置片段 (TOML 格式) + * 从 config.json 读取和保存,支持从 localStorage 平滑迁移 */ export function useCodexCommonConfig({ codexConfig, @@ -26,31 +28,69 @@ export function useCodexCommonConfig({ }: UseCodexCommonConfigProps) { const [useCommonConfig, setUseCommonConfig] = useState(false); const [commonConfigSnippet, setCommonConfigSnippetState] = useState( - () => { - if (typeof window === "undefined") { - return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET; - } - try { - const stored = window.localStorage.getItem( - CODEX_COMMON_CONFIG_STORAGE_KEY, - ); - if (stored && stored.trim()) { - return stored; - } - } catch { - // ignore localStorage 读取失败 - } - return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET; - }, + DEFAULT_CODEX_COMMON_CONFIG_SNIPPET, ); const [commonConfigError, setCommonConfigError] = useState(""); + const [isLoading, setIsLoading] = useState(true); // 用于跟踪是否正在通过通用配置更新 const isUpdatingFromCommonConfig = useRef(false); + // 初始化:从 config.json 加载,支持从 localStorage 迁移 + useEffect(() => { + let mounted = true; + + const loadSnippet = async () => { + try { + // 使用统一 API 加载 + const snippet = await configApi.getCommonConfigSnippet("codex"); + + if (snippet && snippet.trim()) { + if (mounted) { + setCommonConfigSnippetState(snippet); + } + } else { + // 如果 config.json 中没有,尝试从 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); + } + // 清理 localStorage + window.localStorage.removeItem(LEGACY_STORAGE_KEY); + console.log( + "[迁移] Codex 通用配置已从 localStorage 迁移到 config.json", + ); + } + } catch (e) { + console.warn("[迁移] 从 localStorage 迁移失败:", e); + } + } + } + } catch (error) { + console.error("加载 Codex 通用配置失败:", error); + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + loadSnippet(); + + return () => { + mounted = false; + }; + }, []); + // 初始化时检查通用配置片段(编辑模式) useEffect(() => { - if (initialData?.settingsConfig) { + if (initialData?.settingsConfig && !isLoading) { const config = typeof initialData.settingsConfig.config === "string" ? initialData.settingsConfig.config @@ -58,24 +98,7 @@ export function useCodexCommonConfig({ const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet); setUseCommonConfig(hasCommon); } - }, [initialData, commonConfigSnippet]); - - // 同步本地存储的通用配置片段 - useEffect(() => { - if (typeof window === "undefined") return; - try { - if (commonConfigSnippet.trim()) { - window.localStorage.setItem( - CODEX_COMMON_CONFIG_STORAGE_KEY, - commonConfigSnippet, - ); - } else { - window.localStorage.removeItem(CODEX_COMMON_CONFIG_STORAGE_KEY); - } - } catch { - // ignore - } - }, [commonConfigSnippet]); + }, [initialData, commonConfigSnippet, isLoading]); // 处理通用配置开关 const handleCommonConfigToggle = useCallback( @@ -114,6 +137,12 @@ export function useCodexCommonConfig({ if (!value.trim()) { setCommonConfigError(""); + // 保存到 config.json(清空) + configApi.setCommonConfigSnippet("codex", "").catch((error) => { + console.error("保存 Codex 通用配置失败:", error); + setCommonConfigError(`保存失败: ${error}`); + }); + if (useCommonConfig) { const { updatedConfig } = updateTomlCommonConfigSnippet( codexConfig, @@ -128,6 +157,11 @@ export function useCodexCommonConfig({ // TOML 格式校验较为复杂,暂时不做校验,直接清空错误 setCommonConfigError(""); + // 保存到 config.json + configApi.setCommonConfigSnippet("codex", value).catch((error) => { + console.error("保存 Codex 通用配置失败:", error); + setCommonConfigError(`保存失败: ${error}`); + }); // 若当前启用通用配置,需要替换为最新片段 if (useCommonConfig) { @@ -165,7 +199,7 @@ export function useCodexCommonConfig({ // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) useEffect(() => { - if (isUpdatingFromCommonConfig.current) { + if (isUpdatingFromCommonConfig.current || isLoading) { return; } const hasCommon = hasTomlCommonConfigSnippet( @@ -173,12 +207,13 @@ export function useCodexCommonConfig({ commonConfigSnippet, ); setUseCommonConfig(hasCommon); - }, [codexConfig, commonConfigSnippet]); + }, [codexConfig, commonConfigSnippet, isLoading]); return { useCommonConfig, commonConfigSnippet, commonConfigError, + isLoading, handleCommonConfigToggle, handleCommonConfigSnippetChange, }; diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts index d2414e7..cdf59f8 100644 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -44,8 +44,8 @@ export function useCommonConfigSnippet({ const loadSnippet = async () => { try { - // 尝试从 config.json 加载 - const snippet = await configApi.getClaudeCommonConfigSnippet(); + // 使用统一 API 加载 + const snippet = await configApi.getCommonConfigSnippet("claude"); if (snippet && snippet.trim()) { if (mounted) { @@ -59,14 +59,14 @@ export function useCommonConfigSnippet({ window.localStorage.getItem(LEGACY_STORAGE_KEY); if (legacySnippet && legacySnippet.trim()) { // 迁移到 config.json - await configApi.setClaudeCommonConfigSnippet(legacySnippet); + await configApi.setCommonConfigSnippet("claude", legacySnippet); if (mounted) { setCommonConfigSnippetState(legacySnippet); } // 清理 localStorage window.localStorage.removeItem(LEGACY_STORAGE_KEY); console.log( - "[迁移] 通用配置已从 localStorage 迁移到 config.json", + "[迁移] Claude 通用配置已从 localStorage 迁移到 config.json", ); } } catch (e) { @@ -139,8 +139,9 @@ export function useCommonConfigSnippet({ if (!value.trim()) { setCommonConfigError(""); // 保存到 config.json(清空) - configApi.setClaudeCommonConfigSnippet("").catch((error) => { + configApi.setCommonConfigSnippet("claude", "").catch((error) => { console.error("保存通用配置失败:", error); + setCommonConfigError(`保存失败: ${error}`); }); if (useCommonConfig) { @@ -162,7 +163,7 @@ export function useCommonConfigSnippet({ } else { setCommonConfigError(""); // 保存到 config.json - configApi.setClaudeCommonConfigSnippet(value).catch((error) => { + configApi.setCommonConfigSnippet("claude", value).catch((error) => { console.error("保存通用配置失败:", error); setCommonConfigError(`保存失败: ${error}`); }); diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts index 9a10f4e..d27e9ae 100644 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { configApi } from "@/lib/api"; -const GEMINI_COMMON_CONFIG_STORAGE_KEY = - "cc-switch:gemini-common-config-snippet"; +const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet"; const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = `{ "timeout": 30000, "maxRetries": 3 @@ -105,6 +105,7 @@ function hasCommonConfigSnippet(config: any, commonConfig: any): boolean { /** * 管理 Gemini 通用配置片段 (JSON 格式) + * 从 config.json 读取和保存,支持从 localStorage 平滑迁移 */ export function useGeminiCommonConfig({ configValue, @@ -113,31 +114,69 @@ export function useGeminiCommonConfig({ }: UseGeminiCommonConfigProps) { const [useCommonConfig, setUseCommonConfig] = useState(false); const [commonConfigSnippet, setCommonConfigSnippetState] = useState( - () => { - if (typeof window === "undefined") { - return DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET; - } - try { - const stored = window.localStorage.getItem( - GEMINI_COMMON_CONFIG_STORAGE_KEY, - ); - if (stored && stored.trim()) { - return stored; - } - } catch { - // ignore localStorage 读取失败 - } - return DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET; - }, + DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET, ); const [commonConfigError, setCommonConfigError] = useState(""); + const [isLoading, setIsLoading] = useState(true); // 用于跟踪是否正在通过通用配置更新 const isUpdatingFromCommonConfig = useRef(false); + // 初始化:从 config.json 加载,支持从 localStorage 迁移 + useEffect(() => { + let mounted = true; + + const loadSnippet = async () => { + try { + // 使用统一 API 加载 + const snippet = await configApi.getCommonConfigSnippet("gemini"); + + if (snippet && snippet.trim()) { + if (mounted) { + setCommonConfigSnippetState(snippet); + } + } else { + // 如果 config.json 中没有,尝试从 localStorage 迁移 + if (typeof window !== "undefined") { + try { + const legacySnippet = + window.localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacySnippet && legacySnippet.trim()) { + // 迁移到 config.json + await configApi.setCommonConfigSnippet("gemini", legacySnippet); + if (mounted) { + setCommonConfigSnippetState(legacySnippet); + } + // 清理 localStorage + window.localStorage.removeItem(LEGACY_STORAGE_KEY); + console.log( + "[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json", + ); + } + } catch (e) { + console.warn("[迁移] 从 localStorage 迁移失败:", e); + } + } + } + } catch (error) { + console.error("加载 Gemini 通用配置失败:", error); + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + loadSnippet(); + + return () => { + mounted = false; + }; + }, []); + // 初始化时检查通用配置片段(编辑模式) useEffect(() => { - if (initialData?.settingsConfig) { + if (initialData?.settingsConfig && !isLoading) { try { const config = typeof initialData.settingsConfig.config === "object" @@ -150,24 +189,7 @@ export function useGeminiCommonConfig({ // ignore parse error } } - }, [initialData, commonConfigSnippet]); - - // 同步本地存储的通用配置片段 - useEffect(() => { - if (typeof window === "undefined") return; - try { - if (commonConfigSnippet.trim()) { - window.localStorage.setItem( - GEMINI_COMMON_CONFIG_STORAGE_KEY, - commonConfigSnippet, - ); - } else { - window.localStorage.removeItem(GEMINI_COMMON_CONFIG_STORAGE_KEY); - } - } catch { - // ignore - } - }, [commonConfigSnippet]); + }, [initialData, commonConfigSnippet, isLoading]); // 处理通用配置开关 const handleCommonConfigToggle = useCallback( @@ -214,6 +236,12 @@ export function useGeminiCommonConfig({ if (!value.trim()) { setCommonConfigError(""); + // 保存到 config.json(清空) + configApi.setCommonConfigSnippet("gemini", "").catch((error) => { + console.error("保存 Gemini 通用配置失败:", error); + setCommonConfigError(`保存失败: ${error}`); + }); + if (useCommonConfig) { // 移除旧的通用配置 try { @@ -236,6 +264,11 @@ export function useGeminiCommonConfig({ try { JSON.parse(value); setCommonConfigError(""); + // 保存到 config.json + configApi.setCommonConfigSnippet("gemini", value).catch((error) => { + console.error("保存 Gemini 通用配置失败:", error); + setCommonConfigError(`保存失败: ${error}`); + }); } catch { setCommonConfigError("通用配置片段格式错误(必须是有效的 JSON)"); return; @@ -276,7 +309,7 @@ export function useGeminiCommonConfig({ // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) useEffect(() => { - if (isUpdatingFromCommonConfig.current) { + if (isUpdatingFromCommonConfig.current || isLoading) { return; } try { @@ -287,12 +320,13 @@ export function useGeminiCommonConfig({ } catch { // ignore parse error } - }, [configValue, commonConfigSnippet]); + }, [configValue, commonConfigSnippet, isLoading]); return { useCommonConfig, commonConfigSnippet, commonConfigError, + isLoading, handleCommonConfigToggle, handleCommonConfigSnippetChange, }; diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 81061aa..4fe3d10 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -1,21 +1,49 @@ // 配置相关 API import { invoke } from "@tauri-apps/api/core"; +export type AppType = "claude" | "codex" | "gemini"; + /** - * 获取 Claude 通用配置片段 + * 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet) * @returns 通用配置片段(JSON 字符串),如果不存在则返回 null + * @deprecated 使用 getCommonConfigSnippet('claude') 替代 */ export async function getClaudeCommonConfigSnippet(): Promise { return invoke("get_claude_common_config_snippet"); } /** - * 设置 Claude 通用配置片段 + * 设置 Claude 通用配置片段(已废弃,使用 setCommonConfigSnippet) * @param snippet - 通用配置片段(JSON 字符串) * @throws 如果 JSON 格式无效 + * @deprecated 使用 setCommonConfigSnippet('claude', snippet) 替代 */ export async function setClaudeCommonConfigSnippet( snippet: string, ): Promise { return invoke("set_claude_common_config_snippet", { snippet }); } + +/** + * 获取通用配置片段(统一接口) + * @param appType - 应用类型(claude/codex/gemini) + * @returns 通用配置片段(原始字符串),如果不存在则返回 null + */ +export async function getCommonConfigSnippet( + appType: AppType, +): Promise { + return invoke("get_common_config_snippet", { appType }); +} + +/** + * 设置通用配置片段(统一接口) + * @param appType - 应用类型(claude/codex/gemini) + * @param snippet - 通用配置片段(原始字符串) + * @throws 如果格式无效(Claude/Gemini 验证 JSON,Codex 暂不验证) + */ +export async function setCommonConfigSnippet( + appType: AppType, + snippet: string, +): Promise { + return invoke("set_common_config_snippet", { appType, snippet }); +}