From 120ac92e777ec969d2ed9c5e81006f351aef0116 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 27 Nov 2025 10:21:01 +0800 Subject: [PATCH] refactor(gemini): improve config handling and code consistency - Fix envObjToString to preserve custom environment variables - Add bidirectional MCP format conversion (httpUrl <-> url) - Merge provider config with existing settings.json instead of overwriting - Remove redundant is_packycode_gemini function - Simplify is_google_official_gemini using detect_gemini_auth_type - Add handleGeminiModelChange for consistent field handling --- src-tauri/src/gemini_mcp.rs | 20 +++- .../src/services/provider/gemini_auth.rs | 95 +------------------ src-tauri/src/services/provider/live.rs | 47 +++++---- .../providers/forms/ProviderForm.tsx | 32 ++++--- .../forms/hooks/useGeminiConfigState.ts | 43 +++++++-- 5 files changed, 106 insertions(+), 131 deletions(-) diff --git a/src-tauri/src/gemini_mcp.rs b/src-tauri/src/gemini_mcp.rs index 3976190c8..9374fc35e 100644 --- a/src-tauri/src/gemini_mcp.rs +++ b/src-tauri/src/gemini_mcp.rs @@ -49,6 +49,11 @@ pub fn read_mcp_json() -> Result, AppError> { } /// 读取 Gemini settings.json 中的 mcpServers 映射 +/// +/// 执行反向格式转换以保持与统一 MCP 结构的兼容性: +/// - httpUrl → url + type: "http" +/// - 仅有 url 字段 → 保持不变(SSE 类型) +/// - 仅有 command 字段 → 保持不变(stdio 类型) pub fn read_mcp_servers_map() -> Result, AppError> { let path = user_config_path(); if !path.exists() { @@ -56,12 +61,25 @@ pub fn read_mcp_servers_map() -> Result } let root = read_json_value(&path)?; - let servers = root + let mut servers: std::collections::HashMap = root .get("mcpServers") .and_then(|v| v.as_object()) .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); + // 反向格式转换:Gemini 特有格式 → 统一 MCP 格式 + for (_, spec) in servers.iter_mut() { + if let Some(obj) = spec.as_object_mut() { + // httpUrl → url + type: "http" + if let Some(http_url) = obj.remove("httpUrl") { + obj.insert("url".to_string(), http_url); + obj.insert("type".to_string(), Value::String("http".to_string())); + } + // 如果有 url 但没有 type,不添加 type(默认为 SSE) + // 如果有 command 但没有 type,不添加 type(默认为 stdio) + } + } + Ok(servers) } diff --git a/src-tauri/src/services/provider/gemini_auth.rs b/src-tauri/src/services/provider/gemini_auth.rs index 245fe438f..a0a109527 100644 --- a/src-tauri/src/services/provider/gemini_auth.rs +++ b/src-tauri/src/services/provider/gemini_auth.rs @@ -89,104 +89,13 @@ fn contains_packycode_keyword(value: &str) -> bool { .any(|keyword| lower.contains(keyword)) } -/// Detect if provider is PackyCode Gemini (uses API Key authentication) -/// -/// PackyCode is an official partner requiring special security configuration. -/// -/// # Detection Rules (priority from high to low) -/// -/// 1. **Partner Promotion Key** (most reliable): -/// - `provider.meta.partner_promotion_key == "packycode"` -/// -/// 2. **Provider name**: -/// - Name contains "packycode", "packyapi" or "packy" (case-insensitive) -/// -/// 3. **Website URL**: -/// - `provider.website_url` contains keywords -/// -/// 4. **Base URL**: -/// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` contains keywords -/// -/// # Why multiple detection methods -/// -/// - Users may manually create providers without `partner_promotion_key` -/// - Meta fields may be modified after copying from presets -/// - Ensure all PackyCode providers get correct security flags -#[allow(dead_code)] -pub(crate) fn is_packycode_gemini(provider: &Provider) -> bool { - // Strategy 1: Check partner_promotion_key (most reliable) - if provider - .meta - .as_ref() - .and_then(|meta| meta.partner_promotion_key.as_deref()) - .is_some_and(|key| key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY)) - { - return true; - } - - // Strategy 2: Check provider name - if contains_packycode_keyword(&provider.name) { - return true; - } - - // Strategy 3: Check website URL - if let Some(site) = provider.website_url.as_deref() { - if contains_packycode_keyword(site) { - return true; - } - } - - // Strategy 4: Check Base URL - if let Some(base_url) = provider - .settings_config - .pointer("/env/GOOGLE_GEMINI_BASE_URL") - .and_then(|v| v.as_str()) - { - if contains_packycode_keyword(base_url) { - return true; - } - } - - false -} - /// Detect if provider is Google Official Gemini (uses OAuth authentication) /// /// Google Official Gemini uses OAuth personal authentication, no API Key needed. /// -/// # Detection Rules (priority from high to low) -/// -/// 1. **Partner Promotion Key** (most reliable): -/// - `provider.meta.partner_promotion_key == "google-official"` -/// -/// 2. **Provider name**: -/// - Name equals "google" (case-insensitive) -/// - Or name starts with "google " (e.g., "Google Official") -/// -/// # OAuth vs API Key -/// -/// - **OAuth mode**: `security.auth.selectedType = "oauth-personal"` -/// - User needs to login via browser with Google account -/// - No API Key needed in `.env` file -/// -/// - **API Key mode**: `security.auth.selectedType = "gemini-api-key"` -/// - Used for third-party relay services (like PackyCode) -/// - Requires `GEMINI_API_KEY` in `.env` file -#[allow(dead_code)] +/// This is a convenience wrapper around `detect_gemini_auth_type`. pub(crate) fn is_google_official_gemini(provider: &Provider) -> bool { - // Strategy 1: Check partner_promotion_key (most reliable) - if provider - .meta - .as_ref() - .and_then(|meta| meta.partner_promotion_key.as_deref()) - .is_some_and(|key| key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY)) - { - return true; - } - - // Strategy 2: Check name matching (fallback) - let name_lower = provider.name.to_ascii_lowercase(); - name_lower == "google" || name_lower.starts_with("google ") + detect_gemini_auth_type(provider) == GeminiAuthType::GoogleOfficial } /// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index ac5f22d72..10942e76f 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -310,28 +310,44 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { let mut env_map = json_to_env(&provider.settings_config)?; - // Prepare config to write to ~/.gemini/settings.json (preserve existing file content when absent) - let mut config_to_write = if let Some(config_value) = provider.settings_config.get("config") { - if config_value.is_null() { - Some(json!({})) - } else if config_value.is_object() { - Some(config_value.clone()) - } else { + // Prepare config to write to ~/.gemini/settings.json + // Behavior: + // - config is object: use it (merge with existing to preserve mcpServers etc.) + // - config is null or absent: preserve existing file content + let settings_path = get_gemini_settings_path(); + let mut config_to_write: Option = None; + + if let Some(config_value) = provider.settings_config.get("config") { + if config_value.is_object() { + // Merge with existing settings to preserve mcpServers and other fields + let mut merged = if settings_path.exists() { + read_json_file::(&settings_path).unwrap_or_else(|_| json!({})) + } else { + json!({}) + }; + + // Merge provider config into existing settings + if let (Some(merged_obj), Some(config_obj)) = + (merged.as_object_mut(), config_value.as_object()) + { + for (k, v) in config_obj { + merged_obj.insert(k.clone(), v.clone()); + } + } + config_to_write = Some(merged); + } else if !config_value.is_null() { return Err(AppError::localized( "gemini.validation.invalid_config", "Gemini 配置格式错误: config 必须是对象或 null", "Gemini config invalid: config must be an object or null", )); } - } else { - None - }; + // config is null: don't modify existing settings.json (preserve mcpServers etc.) + } - if config_to_write.is_none() { - let settings_path = get_gemini_settings_path(); - if settings_path.exists() { - config_to_write = Some(read_json_file(&settings_path)?); - } + // If no config specified or config is null, preserve existing file + if config_to_write.is_none() && settings_path.exists() { + config_to_write = Some(read_json_file(&settings_path)?); } match auth_type { @@ -353,7 +369,6 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { } if let Some(config_value) = config_to_write { - let settings_path = get_gemini_settings_path(); write_json_file(&settings_path, &config_value)?; } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 1715880b5..4102d11cd 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -327,11 +327,11 @@ export function ProviderForm({ configError: geminiConfigError, handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange, handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange, + handleGeminiModelChange: originalHandleGeminiModelChange, handleGeminiEnvChange, handleGeminiConfigChange, resetGeminiConfig, envStringToObj, - envObjToString, } = useGeminiConfigState({ initialData: appId === "gemini" ? initialData : undefined, }); @@ -369,6 +369,22 @@ export function ProviderForm({ [originalHandleGeminiBaseUrlChange, form], ); + const handleGeminiModelChange = useCallback( + (model: string) => { + originalHandleGeminiModelChange(model); + // 同步更新 settingsConfig + try { + const config = JSON.parse(form.watch("settingsConfig") || "{}"); + if (!config.env) config.env = {}; + config.env.GEMINI_MODEL = model.trim(); + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [originalHandleGeminiModelChange, form], + ); + // 使用 Gemini 通用配置 hook (仅 Gemini 模式) const { useCommonConfig: useGeminiCommonConfigFlag, @@ -824,19 +840,7 @@ export function ProviderForm({ onCustomEndpointsChange={setDraftCustomEndpoints} shouldShowModelField={true} model={geminiModel} - onModelChange={(model) => { - // 同时更新 form.settingsConfig 和 geminiEnv - const config = JSON.parse(form.watch("settingsConfig") || "{}"); - if (!config.env) config.env = {}; - config.env.GEMINI_MODEL = model; - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); - - // 同步更新 geminiEnv,确保提交时不丢失 - const envObj = envStringToObj(geminiEnv); - envObj.GEMINI_MODEL = model.trim(); - const newEnv = envObjToString(envObj); - handleGeminiEnvChange(newEnv); - }} + onModelChange={handleGeminiModelChange} speedTestEndpoints={speedTestEndpoints} /> )} diff --git a/src/components/providers/forms/hooks/useGeminiConfigState.ts b/src/components/providers/forms/hooks/useGeminiConfigState.ts index cad122094..a5ede5e07 100644 --- a/src/components/providers/forms/hooks/useGeminiConfigState.ts +++ b/src/components/providers/forms/hooks/useGeminiConfigState.ts @@ -22,18 +22,32 @@ export function useGeminiConfigState({ const [configError, setConfigError] = useState(""); // 将 JSON env 对象转换为 .env 格式字符串 + // 保留所有环境变量,已知 key 优先显示 const envObjToString = useCallback( (envObj: Record): string => { + const priorityKeys = [ + "GOOGLE_GEMINI_BASE_URL", + "GEMINI_API_KEY", + "GEMINI_MODEL", + ]; const lines: string[] = []; - if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") { - lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`); + const addedKeys = new Set(); + + // 先添加已知 key(按顺序) + for (const key of priorityKeys) { + if (typeof envObj[key] === "string" && envObj[key]) { + lines.push(`${key}=${envObj[key]}`); + addedKeys.add(key); + } } - if (typeof envObj.GEMINI_API_KEY === "string") { - lines.push(`GEMINI_API_KEY=${envObj.GEMINI_API_KEY}`); - } - if (typeof envObj.GEMINI_MODEL === "string") { - lines.push(`GEMINI_MODEL=${envObj.GEMINI_MODEL}`); + + // 再添加其他自定义 key(保留用户添加的环境变量) + for (const [key, value] of Object.entries(envObj)) { + if (!addedKeys.has(key) && typeof value === "string") { + lines.push(`${key}=${value}`); + } } + return lines.join("\n"); }, [], @@ -164,6 +178,20 @@ export function useGeminiConfigState({ [geminiEnv, envStringToObj, envObjToString, setGeminiEnv], ); + // 处理 Gemini Model 变化 + const handleGeminiModelChange = useCallback( + (model: string) => { + const trimmed = model.trim(); + setGeminiModel(trimmed); + + const envObj = envStringToObj(geminiEnv); + envObj.GEMINI_MODEL = trimmed; + const newEnv = envObjToString(envObj); + setGeminiEnv(newEnv); + }, + [geminiEnv, envStringToObj, envObjToString, setGeminiEnv], + ); + // 处理 env 变化 const handleGeminiEnvChange = useCallback( (value: string) => { @@ -223,6 +251,7 @@ export function useGeminiConfigState({ setGeminiConfig, handleGeminiApiKeyChange, handleGeminiBaseUrlChange, + handleGeminiModelChange, handleGeminiEnvChange, handleGeminiConfigChange, resetGeminiConfig,