From aa1231903ffe2f1fd8ea030ea9ebb0e5830a7a30 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Fri, 30 Jan 2026 10:16:57 +0800 Subject: [PATCH] refactor(common-config): consolidate hooks and migrate Gemini to ENV format - Delete redundant wrapper hooks (useCommonConfigSnippet, useGeminiCommonConfig) - Change Gemini common config from JSON to ENV format (.env style) - Add backend validation with forbidden keys filtering (GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL) - Fix localStorage migration to skip empty parsed snippets - Add error handling for silent JSON parse failures - Clean up debug logs and unused types --- src-tauri/src/commands/config.rs | 22 +++- src-tauri/src/config_merge.rs | 60 ++++++++- src-tauri/src/services/provider/mod.rs | 23 ++-- src/components/DeepLinkImportDialog.tsx | 3 - .../forms/GeminiCommonConfigModal.tsx | 12 +- .../providers/forms/ProviderForm.tsx | 96 ++++++++------ src/components/providers/forms/hooks/index.ts | 2 - .../forms/hooks/useCodexCommonConfig.ts | 6 - .../forms/hooks/useCommonConfigSnippet.ts | 104 --------------- .../forms/hooks/useGeminiCommonConfig.ts | 118 ------------------ src/hooks/commonConfigAdapters.ts | 6 +- src/hooks/useCommonConfigBase.ts | 7 +- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/api/config.ts | 15 +-- src/utils/providerConfigUtils.ts | 44 ++++--- 17 files changed, 198 insertions(+), 323 deletions(-) delete mode 100644 src/components/providers/forms/hooks/useCommonConfigSnippet.ts delete mode 100644 src/components/providers/forms/hooks/useGeminiCommonConfig.ts diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index f000a4d29..e1be78f6c 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -217,14 +217,30 @@ pub async fn set_common_config_snippet( // 验证格式(根据应用类型) if !snippet.trim().is_empty() { match app_type.as_str() { - "claude" | "gemini" => { + "claude" => { // 验证 JSON 格式 serde_json::from_str::(&snippet) .map_err(invalid_json_format_error)?; } + "gemini" => { + // 验证 ENV/JSON 格式并拒绝禁用键 + let validation = crate::config_merge::validate_gemini_common_snippet(&snippet); + + // 如果有禁用键,返回错误 + if !validation.forbidden_keys_found.is_empty() { + return Err(format!( + "GEMINI_FORBIDDEN_KEYS:{}", + validation.forbidden_keys_found.join(",") + )); + } + + // 如果非空但解析后无有效内容,返回错误 + if !validation.is_valid { + return Err("GEMINI_INVALID_SNIPPET".to_string()); + } + } "codex" => { - // TOML 格式暂不验证(或可使用 toml crate) - // 注意:TOML 验证较为复杂,暂时跳过 + // TOML 格式,暂不验证(前端验证) } _ => {} } diff --git a/src-tauri/src/config_merge.rs b/src-tauri/src/config_merge.rs index bb46dc82e..c69b8e4be 100644 --- a/src-tauri/src/config_merge.rs +++ b/src-tauri/src/config_merge.rs @@ -565,13 +565,24 @@ fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeR /// /// This function supports all formats for backward compatibility. fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult { - // Parse common config (try JSON first, then ENV format) - let common_env = parse_gemini_common_snippet(common_snippet); + // Parse and validate common config (filters forbidden keys) + let validation = validate_gemini_common_snippet(common_snippet); + let common_env = validation.env; + + // Generate warning if forbidden keys were found + let warning = if !validation.forbidden_keys_found.is_empty() { + Some(format!( + "GEMINI_FORBIDDEN_KEYS:{}", + validation.forbidden_keys_found.join(",") + )) + } else { + None + }; if common_env.is_empty() { return MergeResult { config: custom_config.clone(), - warning: None, + warning, }; } @@ -597,7 +608,7 @@ fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> Merge MergeResult { config: merged_config, - warning: None, + warning, } } @@ -646,6 +657,47 @@ pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map, + /// List of forbidden keys that were found and filtered + pub forbidden_keys_found: Vec, + /// Whether the snippet is valid (parseable and non-empty after filtering) + pub is_valid: bool, +} + +/// Parse and validate Gemini common config snippet. +/// +/// This function: +/// 1. Parses the snippet (supports ENV/JSON formats) +/// 2. Filters out forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY) +/// 3. Returns validation result with filtered env and forbidden keys found +pub fn validate_gemini_common_snippet(snippet: &str) -> GeminiSnippetValidation { + let raw_env = parse_gemini_common_snippet(snippet); + let mut filtered_env = serde_json::Map::new(); + let mut forbidden_keys_found = Vec::new(); + + for (key, value) in raw_env { + if GEMINI_FORBIDDEN_KEYS.contains(&key.as_str()) { + forbidden_keys_found.push(key); + } else { + filtered_env.insert(key, value); + } + } + + GeminiSnippetValidation { + is_valid: !filtered_env.is_empty() || snippet.trim().is_empty(), + env: filtered_env, + forbidden_keys_found, + } +} + /// Strip surrounding quotes from ENV value. /// Supports both single and double quotes: "value" -> value, 'value' -> value fn strip_env_quotes(s: &str) -> &str { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 404b52f30..9e7bebcfb 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -654,15 +654,19 @@ impl ProviderService { Ok(cleaned.trim().to_string()) } - /// Extract common config for Gemini (JSON format) + /// Extract common config for Gemini (ENV format) /// /// Extracts `.env` values while excluding provider-specific credentials: /// - GOOGLE_GEMINI_BASE_URL /// - GEMINI_API_KEY + /// + /// Returns ENV format (KEY=VALUE per line) instead of JSON. + /// Values containing newlines/carriage returns are skipped to prevent + /// ENV format injection/truncation. fn extract_gemini_common_config(settings: &Value) -> Result { let env = settings.get("env").and_then(|v| v.as_object()); - let mut snippet = serde_json::Map::new(); + let mut lines: Vec = Vec::new(); if let Some(env) = env { for (key, value) in env { if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" { @@ -673,17 +677,22 @@ impl ProviderService { }; let trimmed = v.trim(); if !trimmed.is_empty() { - snippet.insert(key.to_string(), Value::String(trimmed.to_string())); + // Skip values containing newlines to prevent ENV format injection + if trimmed.contains('\n') || trimmed.contains('\r') { + continue; + } + lines.push(format!("{key}={trimmed}")); } } } - if snippet.is_empty() { - return Ok("{}".to_string()); + if lines.is_empty() { + return Ok(String::new()); } - serde_json::to_string_pretty(&Value::Object(snippet)) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + // Sort for consistent output + lines.sort(); + Ok(lines.join("\n")) } /// Extract common config for OpenCode (JSON format) diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index daf1d7327..05acafa89 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -53,15 +53,12 @@ export function DeepLinkImportDialog() { const unlistenImport = listen( "deeplink-import", async (event) => { - console.log("Deep link import event received:", event.payload); - // If config is present, merge it to get the complete configuration if (event.payload.config || event.payload.configUrl) { try { const mergedRequest = await deeplinkApi.mergeDeeplinkConfig( event.payload, ); - console.log("Config merged successfully:", mergedRequest); setRequest(mergedRequest); } catch (error) { console.error("Failed to merge config:", error); diff --git a/src/components/providers/forms/GeminiCommonConfigModal.tsx b/src/components/providers/forms/GeminiCommonConfigModal.tsx index c7413217c..36ff88c3e 100644 --- a/src/components/providers/forms/GeminiCommonConfigModal.tsx +++ b/src/components/providers/forms/GeminiCommonConfigModal.tsx @@ -18,6 +18,7 @@ interface GeminiCommonConfigModalProps { /** * GeminiCommonConfigModal - Common Gemini configuration editor modal * Allows editing of common env snippet shared across Gemini providers + * Uses ENV format (KEY=VALUE) instead of JSON */ export const GeminiCommonConfigModal: React.FC< GeminiCommonConfigModalProps @@ -88,13 +89,14 @@ export const GeminiCommonConfigModal: React.FC< {error && ( diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index f2906a704..7469e0bff 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -66,13 +66,16 @@ import { useCodexConfigState, useApiKeyLink, useTemplateValues, - useCommonConfigSnippet, useCodexCommonConfig, useSpeedTestEndpoints, useCodexTomlValidation, useGeminiConfigState, - useGeminiCommonConfig, } from "./hooks"; +import { useCommonConfigBase } from "@/hooks/useCommonConfigBase"; +import { + claudeAdapter, + createGeminiAdapter, +} from "@/hooks/commonConfigAdapters"; import { useProvidersQuery } from "@/lib/query/queries"; /** @@ -469,6 +472,17 @@ export function ProviderForm({ }); // 使用通用配置片段 hook (仅 Claude 模式) + // 直接使用 useCommonConfigBase + claudeAdapter + const claudeCommonConfig = useCommonConfigBase({ + adapter: claudeAdapter, + inputValue: form.getValues("settingsConfig"), + onInputChange: (config) => form.setValue("settingsConfig", config), + initialData: appId === "claude" ? initialData : undefined, + selectedPresetId: selectedPresetId ?? undefined, + enabled: appId === "claude", + }); + + // 解构 Claude 通用配置 const { useCommonConfig, commonConfigSnippet, @@ -478,17 +492,10 @@ export function ProviderForm({ isExtracting: isClaudeExtracting, handleExtract: handleClaudeExtract, isLoading: isClaudeCommonConfigLoading, - finalConfig: claudeFinalConfig, + finalValue: claudeFinalConfig, getPendingCommonConfigSnippet: getPendingClaudeCommonConfigSnippet, markCommonConfigSaved: markClaudeCommonConfigSaved, - } = useCommonConfigSnippet({ - settingsConfig: form.getValues("settingsConfig"), - onConfigChange: (config) => form.setValue("settingsConfig", config), - initialData: appId === "claude" ? initialData : undefined, - selectedPresetId: selectedPresetId ?? undefined, - enabled: appId === "claude", - currentProviderId: providerId, - }); + } = claudeCommonConfig; // 使用 Codex 通用配置片段 hook (仅 Codex 模式) const { @@ -537,14 +544,16 @@ export function ProviderForm({ (key: string) => { originalHandleGeminiApiKeyChange(key); // 同步更新 settingsConfig + let config: { env?: Record }; try { - const config = JSON.parse(form.getValues("settingsConfig") || "{}"); - if (!config.env) config.env = {}; - config.env.GEMINI_API_KEY = key.trim(); - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + config = JSON.parse(form.getValues("settingsConfig") || "{}"); } catch { - // ignore + // 解析失败时初始化为有效结构 + config = { env: {} }; } + if (!config.env) config.env = {}; + config.env.GEMINI_API_KEY = key.trim(); + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); }, [originalHandleGeminiApiKeyChange, form], ); @@ -553,14 +562,16 @@ export function ProviderForm({ (url: string) => { originalHandleGeminiBaseUrlChange(url); // 同步更新 settingsConfig + let config: { env?: Record }; try { - const config = JSON.parse(form.getValues("settingsConfig") || "{}"); - if (!config.env) config.env = {}; - config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, ""); - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + config = JSON.parse(form.getValues("settingsConfig") || "{}"); } catch { - // ignore + // 解析失败时初始化为有效结构 + config = { env: {} }; } + if (!config.env) config.env = {}; + config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, ""); + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); }, [originalHandleGeminiBaseUrlChange, form], ); @@ -569,19 +580,36 @@ export function ProviderForm({ (model: string) => { originalHandleGeminiModelChange(model); // 同步更新 settingsConfig + let config: { env?: Record }; try { - const config = JSON.parse(form.getValues("settingsConfig") || "{}"); - if (!config.env) config.env = {}; - config.env.GEMINI_MODEL = model.trim(); - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + config = JSON.parse(form.getValues("settingsConfig") || "{}"); } catch { - // ignore + // 解析失败时初始化为有效结构 + config = { env: {} }; } + if (!config.env) config.env = {}; + config.env.GEMINI_MODEL = model.trim(); + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); }, [originalHandleGeminiModelChange, form], ); // 使用 Gemini 通用配置 hook (仅 Gemini 模式) + // 直接使用 useCommonConfigBase + createGeminiAdapter + const geminiAdapter = useMemo( + () => createGeminiAdapter({ envStringToObj, envObjToString }), + [envStringToObj, envObjToString], + ); + const geminiCommonConfig = useCommonConfigBase({ + adapter: geminiAdapter, + inputValue: geminiEnv, + onInputChange: handleGeminiEnvChange, + initialData: appId === "gemini" ? initialData : undefined, + selectedPresetId: selectedPresetId ?? undefined, + enabled: appId === "gemini", + }); + + // 解构 Gemini 通用配置 const { useCommonConfig: useGeminiCommonConfigFlag, commonConfigSnippet: geminiCommonConfigSnippet, @@ -591,18 +619,10 @@ export function ProviderForm({ isExtracting: isGeminiExtracting, handleExtract: handleGeminiExtract, isLoading: isGeminiCommonConfigLoading, - finalEnv: geminiFinalEnv, + finalValue: geminiFinalEnv, getPendingCommonConfigSnippet: getPendingGeminiCommonConfigSnippet, markCommonConfigSaved: markGeminiCommonConfigSaved, - } = useGeminiCommonConfig({ - envValue: geminiEnv, - onEnvChange: handleGeminiEnvChange, - envStringToObj, - envObjToString, - initialData: appId === "gemini" ? initialData : undefined, - selectedPresetId: selectedPresetId ?? undefined, - currentProviderId: providerId, - }); + } = geminiCommonConfig; const supportsCommonConfig = appId === "claude" || appId === "codex" || appId === "gemini"; @@ -1041,8 +1061,8 @@ export function ProviderForm({ const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {}; // 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置) if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) { - // Parse common config as JSON (flat {"KEY": "VALUE"} format) - // Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook + // Parse common config snippet (supports ENV/Flat JSON/Wrapped JSON formats) + // Uses parseGeminiCommonConfigSnippet for unified parsing const { env: commonEnvObj, warning } = parseGeminiCommonConfig( geminiCommonConfigSnippet.trim(), ); diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 2afa3b5d5..ab599ad6c 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -11,6 +11,4 @@ export { useCodexTomlValidation } from "./useCodexTomlValidation"; export { useGeminiConfigState } from "./useGeminiConfigState"; // Common Config Hooks -export { useCommonConfigSnippet } from "./useCommonConfigSnippet"; export { useCodexCommonConfig } from "./useCodexCommonConfig"; -export { useGeminiCommonConfig } from "./useGeminiCommonConfig"; diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index b3c3e11e3..6380165fa 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -10,12 +10,6 @@ import { codexAdapter } from "@/hooks/commonConfigAdapters"; import { extractTomlDifference } from "@/utils/tomlConfigMerge"; import type { ProviderMeta } from "@/types"; -/** TOML 校验错误码 */ -export type TomlValidationErrorCode = - | "TOML_SYNTAX_ERROR" - | "TOML_PARSE_FAILED" - | ""; - interface UseCodexCommonConfigProps { /** * 当前 Codex 配置(可能是纯 TOML 或 JSON wrapper 格式) diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts deleted file mode 100644 index d7e2bd375..000000000 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useMemo } from "react"; -import { - useCommonConfigBase, - type UseCommonConfigBaseReturn, -} from "@/hooks/useCommonConfigBase"; -import { claudeAdapter } from "@/hooks/commonConfigAdapters"; -import type { ProviderMeta } from "@/types"; - -interface UseCommonConfigSnippetProps { - /** - * 当前配置(用于显示和运行时合并) - * 新架构:传入自定义配置,返回最终配置 - */ - settingsConfig: string; - /** - * 配置变化回调 - */ - onConfigChange: (config: string) => void; - /** - * 初始数据(编辑模式) - */ - initialData?: { - settingsConfig?: Record; - meta?: ProviderMeta; - }; - /** - * 当前选中的预设 ID - */ - selectedPresetId?: string; - /** - * 当 false 时跳过所有逻辑,返回禁用状态。默认:true - */ - enabled?: boolean; - /** - * 当前正在编辑的供应商 ID - */ - currentProviderId?: string; -} - -export interface UseCommonConfigSnippetReturn { - /** 是否启用通用配置 */ - useCommonConfig: boolean; - /** 通用配置片段 (JSON 格式) */ - commonConfigSnippet: string; - /** 通用配置错误信息 */ - commonConfigError: string; - /** 是否正在加载 */ - isLoading: boolean; - /** 是否正在提取 */ - isExtracting: boolean; - /** 通用配置开关处理函数 */ - handleCommonConfigToggle: (checked: boolean) => void; - /** 通用配置片段变化处理函数 */ - handleCommonConfigSnippetChange: (snippet: string) => void; - /** 从当前配置提取通用配置 */ - handleExtract: () => Promise; - /** 最终配置(运行时合并结果,只读) */ - finalConfig: string; - /** 是否有待保存的通用配置变更 */ - hasUnsavedCommonConfig: boolean; - /** 获取待保存的通用配置片段(用于 handleSubmit) */ - getPendingCommonConfigSnippet: () => string | null; - /** 标记通用配置已保存 */ - markCommonConfigSaved: () => void; -} - -/** - * 管理 Claude 通用配置片段 - * - * 基于 useCommonConfigBase 泛型 Hook + Claude JSON 适配器实现。 - */ -export function useCommonConfigSnippet({ - settingsConfig, - onConfigChange, - initialData, - selectedPresetId, - enabled = true, -}: UseCommonConfigSnippetProps): UseCommonConfigSnippetReturn { - const adapter = useMemo(() => claudeAdapter, []); - - const base: UseCommonConfigBaseReturn = useCommonConfigBase({ - adapter, - inputValue: settingsConfig, - onInputChange: onConfigChange, - initialData, - selectedPresetId, - enabled, - }); - - return { - 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 deleted file mode 100644 index e04ed7696..000000000 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useMemo } from "react"; -import { - useCommonConfigBase, - type UseCommonConfigBaseReturn, -} from "@/hooks/useCommonConfigBase"; -import { createGeminiAdapter } from "@/hooks/commonConfigAdapters"; -import type { ProviderMeta } from "@/types"; - -interface UseGeminiCommonConfigProps { - /** - * 当前 env 值(字符串格式,如 "KEY=VALUE\nKEY2=VALUE2") - */ - envValue: string; - /** - * env 变化回调 - */ - onEnvChange: (env: string) => void; - /** - * 字符串转对象 - */ - envStringToObj: (envString: string) => Record; - /** - * 对象转字符串 - */ - envObjToString: (envObj: Record) => string; - /** - * 初始数据(编辑模式) - */ - initialData?: { - settingsConfig?: Record; - meta?: ProviderMeta; - }; - /** - * 当前选中的预设 ID - */ - selectedPresetId?: string; - /** - * 当前正在编辑的供应商 ID - */ - currentProviderId?: string; -} - -export interface UseGeminiCommonConfigReturn { - /** 是否启用通用配置 */ - useCommonConfig: boolean; - /** 通用配置片段 (JSON 格式) */ - commonConfigSnippet: string; - /** 通用配置错误信息 */ - commonConfigError: string; - /** 是否正在加载 */ - isLoading: boolean; - /** 是否正在提取 */ - isExtracting: boolean; - /** 通用配置开关处理函数 */ - handleCommonConfigToggle: (checked: boolean) => void; - /** 通用配置片段变化处理函数 */ - handleCommonConfigSnippetChange: (snippet: string) => void; - /** 从当前配置提取通用配置 */ - handleExtract: () => Promise; - /** 最终 env 对象(运行时合并结果,只读) */ - finalEnv: Record; - /** 是否有待保存的通用配置变更 */ - hasUnsavedCommonConfig: boolean; - /** 获取待保存的通用配置片段(用于 handleSubmit) */ - getPendingCommonConfigSnippet: () => string | null; - /** 标记通用配置已保存 */ - markCommonConfigSaved: () => void; -} - -/** - * 管理 Gemini 通用配置片段 - * - * 基于 useCommonConfigBase 泛型 Hook + Gemini ENV/JSON 适配器实现。 - * - * 架构: - * - envValue:传入当前 env 字符串 - * - commonConfigSnippet:存储在数据库中的通用配置片段 - * - finalEnv:运行时计算 = merge(commonConfig, customEnv) - * - 开启/关闭通用配置只改变 enabled 状态,不修改 envValue - */ -export function useGeminiCommonConfig({ - envValue, - onEnvChange, - envStringToObj, - envObjToString, - initialData, - selectedPresetId, -}: UseGeminiCommonConfigProps): UseGeminiCommonConfigReturn { - // 创建适配器(需要转换函数) - const adapter = useMemo( - () => createGeminiAdapter({ envStringToObj, envObjToString }), - [envStringToObj, envObjToString], - ); - - const base: UseCommonConfigBaseReturn> = - useCommonConfigBase({ - adapter, - inputValue: envValue, - onInputChange: onEnvChange, - initialData, - selectedPresetId, - }); - - return { - 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 index d7763eb8b..8968a6508 100644 --- a/src/hooks/commonConfigAdapters.ts +++ b/src/hooks/commonConfigAdapters.ts @@ -249,7 +249,7 @@ export const codexAdapter: CommonConfigAdapter = { // ============================================================================ const GEMINI_LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet"; -const GEMINI_DEFAULT_SNIPPET = "{}"; +const GEMINI_DEFAULT_SNIPPET = ""; export interface GeminiAdapterOptions { /** 字符串转对象 */ @@ -306,7 +306,9 @@ export function createGeminiAdapter( ) { return t("geminiConfig.commonConfigInvalidValues"); } - return t("geminiConfig.invalidJsonFormat"); + return t("geminiConfig.invalidEnvFormat", { + defaultValue: "配置格式错误", + }); } if (Object.keys(result.env).length === 0) { diff --git a/src/hooks/useCommonConfigBase.ts b/src/hooks/useCommonConfigBase.ts index cd7d8230a..4f78d52ed 100644 --- a/src/hooks/useCommonConfigBase.ts +++ b/src/hooks/useCommonConfigBase.ts @@ -234,7 +234,9 @@ export function useCommonConfigBase({ ); if (legacySnippet && legacySnippet.trim()) { const parsed = adapter.parseSnippet(legacySnippet); - if (!parsed.error) { + // 只有在解析成功且有有效内容时才迁移 + // 这避免了将 "{}" 这样的空 JSON 迁移为"有效配置" + if (!parsed.error && adapter.hasValidContent(legacySnippet)) { await configApi.setCommonConfigSnippet( adapter.appKey, legacySnippet, @@ -246,6 +248,9 @@ export function useCommonConfigBase({ console.log( `[迁移] ${adapter.appKey} 通用配置已从 localStorage 迁移到数据库`, ); + } else { + // 解析失败或无有效内容,清理 localStorage 不迁移 + window.localStorage.removeItem(adapter.legacyStorageKey); } } } catch (e) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d560f8015..3dcfdb3ac 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -584,6 +584,7 @@ "saveFailed": "Save failed: {{error}}", "extractedConfigInvalid": "Extracted config format is invalid", "invalidJsonFormat": "Common config snippet format error (must be valid JSON)", + "invalidEnvFormat": "Config format error (use KEY=VALUE format, one per line)", "commonConfigInvalidKeys": "Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})", "commonConfigInvalidValues": "Common config snippet values must be strings", "noCommonConfigToApply": "Common config snippet is empty or has no applicable entries", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d44f3171b..0dab7941f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -584,6 +584,7 @@ "saveFailed": "保存に失敗しました: {{error}}", "extractedConfigInvalid": "抽出した設定のフォーマットが不正です", "invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)", + "invalidEnvFormat": "設定フォーマットエラー(KEY=VALUE 形式を使用してください、各行に1つ)", "commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})", "commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります", "noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index abc84f0b7..1a9782ca3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -584,6 +584,7 @@ "saveFailed": "保存失败: {{error}}", "extractedConfigInvalid": "提取的配置格式错误", "invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)", + "invalidEnvFormat": "配置格式错误(请使用 KEY=VALUE 格式,每行一个)", "commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})", "commonConfigInvalidValues": "通用配置片段的值必须是字符串", "noCommonConfigToApply": "通用配置片段为空或没有可写入的内容", diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 1262014ca..0129588ad 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -81,7 +81,10 @@ export async function getCommonConfigSnippet( * 设置通用配置片段(统一接口) * @param appType - 应用类型(claude/codex/gemini) * @param snippet - 通用配置片段(原始字符串) - * @throws 如果格式无效(Claude/Gemini 验证 JSON,Codex 暂不验证) + * @throws 如果格式无效 + * - Claude: 验证 JSON 格式 + * - Gemini: 验证 ENV/JSON 格式,拒绝包含禁用键(GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL) + * - Codex: TOML 格式,暂不验证 */ export async function setCommonConfigSnippet( appType: AppType, @@ -98,7 +101,7 @@ export async function setCommonConfigSnippet( * * @param appType - 应用类型(claude/codex/gemini) * @param options - 可选:提取来源 - * @returns 提取的通用配置片段(JSON/TOML 字符串) + * @returns 提取的通用配置片段(Claude: JSON, Codex: TOML, Gemini: ENV) */ export type ExtractCommonConfigSnippetOptions = { settingsConfig?: string; @@ -201,10 +204,6 @@ async function executeSyncWithLock(appType: AppType): Promise { // 输出结果 if (result.error) { console.warn(`[syncCommonConfig] ${appType} 同步失败: ${result.error}`); - } else if (result.count > 0) { - console.log( - `[syncCommonConfig] 共更新 ${result.count} 个 ${appType} 供应商`, - ); } // 通知回调 @@ -321,15 +320,11 @@ async function doSyncCommonConfigToProviders( }, }, }; - console.log( - `[syncCommonConfig] 供应商 ${id} 检测到通用配置,已补写 meta`, - ); } try { await providersApi.update(providerToSave, appType); updatedCount++; - console.log(`[syncCommonConfig] 已更新供应商 ${id}`); } catch (updateError) { errors.push(`供应商 ${id}: 保存失败 - ${String(updateError)}`); } diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 9274d6c05..e04afeb72 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -63,13 +63,26 @@ export const hasCommonConfigSnippet = ( } }; -// 检查 Gemini 配置是否已包含通用配置片段(env JSON) +/** + * 检查 Gemini 配置是否已包含通用配置片段 + * + * 支持三种 snippet 格式: + * - ENV 格式: KEY=VALUE (一行一个) + * - Flat JSON: {"KEY": "VALUE", ...} + * - Wrapped JSON: {"env": {"KEY": "VALUE", ...}} + * + * @param jsonString - Provider 的 settingsConfig (JSON 字符串) + * @param snippetString - 通用配置片段 (ENV/JSON 格式) + * @returns 是否已包含通用配置 + */ export const hasGeminiCommonConfigSnippet = ( jsonString: string, snippetString: string, ): boolean => { try { if (!snippetString.trim()) return false; + + // 解析 provider 的 settingsConfig const config = jsonString ? JSON.parse(jsonString) : {}; if (!isPlainObject(config)) return false; const envValue = (config as Record).env; @@ -79,27 +92,18 @@ export const hasGeminiCommonConfigSnippet = ( unknown >; - const parsed = JSON.parse(snippetString); - if (!isPlainObject(parsed)) return false; + // 使用 parseGeminiCommonConfigSnippet 解析 snippet (支持 ENV/JSON) + const parseResult = parseGeminiCommonConfigSnippet(snippetString, { + strictForbiddenKeys: false, // 过滤禁用键而非报错 + }); - const entries = Object.entries(parsed).filter( - (entry): entry is [string, string] => { - const [key, value] = entry; - if ( - GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes( - key as GeminiForbiddenEnvKey, - ) - ) { - return false; - } - if (typeof value !== "string") return false; - return value.trim().length > 0; - }, - ); + // 解析失败或为空 + if (parseResult.error || Object.keys(parseResult.env).length === 0) { + return false; + } - if (entries.length === 0) return false; - - return entries.every(([key, value]) => { + // 检查所有有效键值对是否都存在于 provider.env 中 + return Object.entries(parseResult.env).every(([key, value]) => { const current = env[key]; return typeof current === "string" && current === value.trim(); });