diff --git a/src-tauri/src/config_merge.rs b/src-tauri/src/config_merge.rs index 91ad107d1..2e1f37728 100644 --- a/src-tauri/src/config_merge.rs +++ b/src-tauri/src/config_merge.rs @@ -705,7 +705,7 @@ fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> Merge /// - ENV format: KEY=VALUE lines /// - Flat JSON: {"KEY": "VALUE", ...} /// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}} -fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map { +pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map { let trimmed = snippet.trim(); if trimmed.is_empty() { return serde_json::Map::new(); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 98f2ff53e..9e1e4bbdb 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -13,6 +13,7 @@ use serde::Deserialize; use serde_json::Value; use crate::app_config::AppType; +use crate::config_merge::{extract_json_difference, extract_toml_difference_str, is_common_config_enabled}; use crate::error::AppError; use crate::provider::{Provider, UsageResult}; use crate::services::mcp::McpService; @@ -386,7 +387,18 @@ impl ProviderService { // Only backfill when switching to a different provider if let Ok(live_config) = read_live_settings(app_type.clone()) { if let Some(mut current_provider) = providers.get(¤t_id).cloned() { - current_provider.settings_config = live_config; + // Check if common config is enabled for this provider + let common_enabled = is_common_config_enabled(current_provider.meta.as_ref(), &app_type); + + let config_to_save = if common_enabled { + // Extract custom config from live (remove common config parts) + Self::extract_custom_from_live(state, &app_type, &live_config)? + } else { + // Common config not enabled, use live config directly + live_config + }; + + current_provider.settings_config = config_to_save; // Ignore backfill failure, don't affect switch flow let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); } @@ -459,6 +471,87 @@ impl ProviderService { } } + /// Extract custom config from live config (remove common config parts). + /// + /// This is used during backfill to avoid polluting the provider's settings_config + /// with common config values that should remain in the common config snippet. + fn extract_custom_from_live( + state: &AppState, + app_type: &AppType, + live_config: &Value, + ) -> Result { + // Get common config snippet from database + let common_snippet = state + .db + .get_config_snippet(app_type.as_str())? + .unwrap_or_default(); + + if common_snippet.trim().is_empty() { + // No common config, return live config as-is + return Ok(live_config.clone()); + } + + match app_type { + AppType::Claude => { + // Parse common config as JSON + let common_config: Value = serde_json::from_str(&common_snippet).map_err(|e| { + AppError::Config(format!("Failed to parse common config snippet: {e}")) + })?; + + // Extract difference (custom = live - common) + let (custom_config, _) = extract_json_difference(live_config, &common_config); + Ok(custom_config) + } + AppType::Codex => { + // Codex: Extract TOML config field difference + let mut result = live_config.clone(); + + if let Some(config_str) = live_config.get("config").and_then(|v| v.as_str()) { + // Extract TOML difference for config field + // Returns (custom_toml, has_common_keys, error) + let (custom_toml, _, _) = extract_toml_difference_str(config_str, &common_snippet); + if let Some(obj) = result.as_object_mut() { + obj.insert("config".to_string(), Value::String(custom_toml)); + } + } + + Ok(result) + } + AppType::Gemini => { + // Gemini: Extract env field difference + // Parse common config (supports ENV format and JSON format) + let common_env = crate::config_merge::parse_gemini_common_snippet(&common_snippet); + + if common_env.is_empty() { + return Ok(live_config.clone()); + } + + let mut result = live_config.clone(); + + if let Some(live_env) = live_config.get("env").and_then(|v| v.as_object()) { + // Extract difference: custom = live_env - common_env + let mut custom_env = serde_json::Map::new(); + for (key, value) in live_env { + if common_env.get(key) != Some(value) { + // Key doesn't exist in common or value is different + custom_env.insert(key.clone(), value.clone()); + } + } + + if let Some(obj) = result.as_object_mut() { + obj.insert("env".to_string(), Value::Object(custom_env)); + } + } + + Ok(result) + } + AppType::OpenCode => { + // OpenCode doesn't support common config + Ok(live_config.clone()) + } + } + } + /// Extract common config for Claude (JSON format) fn extract_claude_common_config(settings: &Value) -> Result { let mut config = settings.clone(); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 864f10dea..9eee81e7f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1506,26 +1506,57 @@ impl ProxyService { /// /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份, /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。 + /// + /// **重要**: 新架构下 settings_config 存储的是自定义配置(custom diff), + /// 备份时需要先与 common config 合并,生成完整的 finalConfig。 pub async fn update_live_backup_from_provider( &self, app_type: &str, provider: &Provider, ) -> Result<(), String> { + let app_type_enum = + AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?; + + // Get common config snippet for merge + let common_snippet = self + .db + .get_config_snippet(app_type) + .ok() + .flatten(); + + // Merge custom config with common config to get final config + let merge_result = merge_config_for_live( + &app_type_enum, + provider, + common_snippet.as_deref(), + ); + + // Log warning if any + if let Some(warning) = &merge_result.warning { + log::warn!( + "Common config merge warning for {} provider '{}': {}", + app_type, + provider.id, + warning + ); + } + + let final_config = merge_result.config; + let backup_json = match app_type { "claude" => { - // Claude: settings_config 直接作为备份 - serde_json::to_string(&provider.settings_config) + // Claude: 使用合并后的 final config 作为备份 + serde_json::to_string(&final_config) .map_err(|e| format!("序列化 Claude 配置失败: {e}"))? } "codex" => { - // Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用 - serde_json::to_string(&provider.settings_config) + // Codex: 使用合并后的 final config + serde_json::to_string(&final_config) .map_err(|e| format!("序列化 Codex 配置失败: {e}"))? } "gemini" => { // Gemini: 只提取 env 字段(与原始备份格式一致) - // proxy.rs 的 read_gemini_live() 返回 {"env": {...}} - let env_backup = if let Some(env) = provider.settings_config.get("env") { + let env_backup = if let Some(env) = final_config.get("env") { json!({ "env": env }) } else { json!({ "env": {} }) @@ -1541,7 +1572,7 @@ impl ProxyService { .await .map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?; - log::info!("已更新 {app_type} Live 备份(热切换)"); + log::info!("已更新 {app_type} Live 备份(热切换,含 common config 合并)"); Ok(()) } diff --git a/src/components/JsonEditor.tsx b/src/components/JsonEditor.tsx index 5a8fb3c6c..65ba9f01c 100644 --- a/src/components/JsonEditor.tsx +++ b/src/components/JsonEditor.tsx @@ -280,7 +280,7 @@ const JsonEditor: React.FC = ({ style={{ width: "100%", height: isFullHeight ? undefined : "auto" }} className={isFullHeight ? "flex-1 min-h-0" : ""} /> - {language === "json" && ( + {language === "json" && !readOnly && (