From 6d078e7f33abb6cbb5114e852641620701d0c8b7 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Mar 2026 21:45:20 +0800 Subject: [PATCH] feat: apply common config as runtime overlay instead of materialized merge Common config snippets are now dynamically overlaid when writing live files, rather than being pre-merged into provider snapshots at edit time. This ensures that updating a snippet immediately takes effect for the current provider and automatically propagates to other providers on their next switch. Key changes: - Add write_live_with_common_config() overlay pipeline - Strip common config from live before backfilling provider snapshots - Normalize provider snapshots on save to keep them snippet-free - Add explicit commonConfigEnabled flag in ProviderMeta (Option) - Migrate legacy providers on snippet save (infer flag from subset check) - Add Codex TOML snippet validation in set_common_config_snippet - Stabilize onConfigChange callbacks with useCallback in ProviderForm --- src-tauri/src/commands/config.rs | 92 ++- src-tauri/src/provider.rs | 3 + src-tauri/src/services/provider/live.rs | 678 +++++++++++++++++- src-tauri/src/services/provider/mod.rs | 85 ++- src-tauri/src/services/proxy.rs | 4 +- .../providers/forms/ProviderForm.tsx | 31 +- .../forms/hooks/useCodexCommonConfig.ts | 44 +- .../forms/hooks/useCommonConfigSnippet.ts | 42 +- .../forms/hooks/useGeminiCommonConfig.ts | 35 +- src/types.ts | 2 + 10 files changed, 982 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 8edf1561..8bb166f2 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -28,6 +28,39 @@ fn invalid_json_format_error(error: serde_json::Error) -> String { } } +fn invalid_toml_format_error(error: toml_edit::TomlError) -> String { + let lang = settings::get_settings() + .language + .unwrap_or_else(|| "zh".to_string()); + + match lang.as_str() { + "en" => format!("Invalid TOML format: {error}"), + "ja" => format!("TOML形式が無効です: {error}"), + _ => format!("无效的 TOML 格式: {error}"), + } +} + +fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), String> { + if snippet.trim().is_empty() { + return Ok(()); + } + + match app_type { + "claude" | "gemini" | "omo" | "omo-slim" => { + serde_json::from_str::(snippet) + .map_err(invalid_json_format_error)?; + } + "codex" => { + snippet + .parse::() + .map_err(invalid_toml_format_error)?; + } + _ => {} + } + + Ok(()) +} + #[tauri::command] pub async fn get_config_status(app: String) -> Result { match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -213,16 +246,12 @@ pub async fn set_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - if !snippet.trim().is_empty() { - match app_type.as_str() { - "claude" | "gemini" | "omo" | "omo-slim" => { - serde_json::from_str::(&snippet) - .map_err(invalid_json_format_error)?; - } - "codex" => {} - _ => {} - } - } + let old_snippet = state + .db + .get_config_snippet(&app_type) + .map_err(|e| e.to_string())?; + + validate_common_config_snippet(&app_type, &snippet)?; let value = if snippet.trim().is_empty() { None @@ -230,11 +259,33 @@ pub async fn set_common_config_snippet( Some(snippet) }; + if matches!(app_type.as_str(), "claude" | "codex" | "gemini") { + if let Some(legacy_snippet) = old_snippet.as_deref().filter(|value| !value.trim().is_empty()) + { + let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?; + crate::services::provider::ProviderService::migrate_legacy_common_config_usage( + state.inner(), + app, + legacy_snippet, + ) + .map_err(|e| e.to_string())?; + } + } + state .db .set_config_snippet(&app_type, value) .map_err(|e| e.to_string())?; + if matches!(app_type.as_str(), "claude" | "codex" | "gemini") { + let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?; + crate::services::provider::ProviderService::sync_current_provider_for_app( + state.inner(), + app, + ) + .map_err(|e| e.to_string())?; + } + if app_type == "omo" && state .db @@ -264,6 +315,27 @@ pub async fn set_common_config_snippet( Ok(()) } +#[cfg(test)] +mod tests { + use super::validate_common_config_snippet; + + #[test] + fn validate_common_config_snippet_accepts_comment_only_codex_snippet() { + validate_common_config_snippet("codex", "# comment only\n") + .expect("comment-only codex snippet should be valid"); + } + + #[test] + fn validate_common_config_snippet_rejects_invalid_codex_snippet() { + let err = validate_common_config_snippet("codex", "[broken") + .expect_err("invalid codex snippet should be rejected"); + assert!( + err.contains("TOML") || err.contains("toml") || err.contains("格式"), + "expected TOML validation error, got {err}" + ); + } +} + #[tauri::command] pub async fn extract_common_config_snippet( appType: String, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index fe16090a..8ddbe9da 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -197,6 +197,9 @@ pub struct ProviderMeta { /// 自定义端点列表(按 URL 去重存储) #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub custom_endpoints: HashMap, + /// 是否在写入 live 时应用通用配置片段 + #[serde(rename = "commonConfigEnabled", skip_serializing_if = "Option::is_none")] + pub common_config_enabled: Option, /// 用量查询脚本配置 #[serde(skip_serializing_if = "Option::is_none")] pub usage_script: Option, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index da4e15fa..8acc04a7 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -5,10 +5,12 @@ use std::collections::HashMap; use serde_json::{json, Value}; +use toml_edit::{DocumentMut, Item, TableLike}; use crate::app_config::AppType; use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file}; +use crate::database::Database; use crate::error::AppError; use crate::provider::Provider; use crate::services::mcp::McpService; @@ -31,6 +33,525 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value { v } +fn json_is_subset(target: &Value, source: &Value) -> bool { + match source { + Value::Object(source_map) => { + let Some(target_map) = target.as_object() else { + return false; + }; + source_map + .iter() + .all(|(key, source_value)| target_map.get(key).is_some_and(|target_value| { + json_is_subset(target_value, source_value) + })) + } + Value::Array(source_arr) => { + let Some(target_arr) = target.as_array() else { + return false; + }; + json_array_contains_subset(target_arr, source_arr) + } + _ => target == source, + } +} + +fn json_array_contains_subset(target_arr: &[Value], source_arr: &[Value]) -> bool { + let mut matched = vec![false; target_arr.len()]; + + source_arr.iter().all(|source_item| { + if let Some((index, _)) = target_arr.iter().enumerate().find(|(index, target_item)| { + !matched[*index] && json_is_subset(target_item, source_item) + }) { + matched[index] = true; + true + } else { + false + } + }) +} + +fn json_remove_array_items(target_arr: &mut Vec, source_arr: &[Value]) { + for source_item in source_arr { + if let Some(index) = target_arr + .iter() + .position(|target_item| json_is_subset(target_item, source_item)) + { + target_arr.remove(index); + } + } +} + +fn json_deep_merge(target: &mut Value, source: &Value) { + match (target, source) { + (Value::Object(target_map), Value::Object(source_map)) => { + for (key, source_value) in source_map { + match target_map.get_mut(key) { + Some(target_value) => json_deep_merge(target_value, source_value), + None => { + target_map.insert(key.clone(), source_value.clone()); + } + } + } + } + (target_value, source_value) => { + *target_value = source_value.clone(); + } + } +} + +fn json_deep_remove(target: &mut Value, source: &Value) { + let (Some(target_map), Some(source_map)) = (target.as_object_mut(), source.as_object()) else { + return; + }; + + for (key, source_value) in source_map { + let mut remove_key = false; + + if let Some(target_value) = target_map.get_mut(key) { + if source_value.is_object() && target_value.is_object() { + json_deep_remove(target_value, source_value); + remove_key = target_value.as_object().is_some_and(|obj| obj.is_empty()); + } else if let (Some(target_arr), Some(source_arr)) = + (target_value.as_array_mut(), source_value.as_array()) + { + json_remove_array_items(target_arr, source_arr); + remove_key = target_arr.is_empty(); + } else if json_is_subset(target_value, source_value) { + remove_key = true; + } + } + + if remove_key { + target_map.remove(key); + } + } +} + +fn toml_value_is_subset(target: &toml_edit::Value, source: &toml_edit::Value) -> bool { + match (target, source) { + (toml_edit::Value::String(target), toml_edit::Value::String(source)) => { + target.value() == source.value() + } + (toml_edit::Value::Integer(target), toml_edit::Value::Integer(source)) => { + target.value() == source.value() + } + (toml_edit::Value::Float(target), toml_edit::Value::Float(source)) => { + target.value() == source.value() + } + (toml_edit::Value::Boolean(target), toml_edit::Value::Boolean(source)) => { + target.value() == source.value() + } + (toml_edit::Value::Datetime(target), toml_edit::Value::Datetime(source)) => { + target.value() == source.value() + } + (toml_edit::Value::Array(target), toml_edit::Value::Array(source)) => { + toml_array_contains_subset(target, source) + } + (toml_edit::Value::InlineTable(target), toml_edit::Value::InlineTable(source)) => { + source.iter().all(|(key, source_item)| { + target + .get(key) + .is_some_and(|target_item| toml_value_is_subset(target_item, source_item)) + }) + } + _ => false, + } +} + +fn toml_array_contains_subset(target: &toml_edit::Array, source: &toml_edit::Array) -> bool { + let mut matched = vec![false; target.len()]; + let target_items: Vec<&toml_edit::Value> = target.iter().collect(); + + source.iter().all(|source_item| { + if let Some((index, _)) = target_items.iter().enumerate().find(|(index, target_item)| { + !matched[*index] && toml_value_is_subset(target_item, source_item) + }) { + matched[index] = true; + true + } else { + false + } + }) +} + +fn toml_remove_array_items(target: &mut toml_edit::Array, source: &toml_edit::Array) { + for source_item in source.iter() { + let index = { + let target_items: Vec<&toml_edit::Value> = target.iter().collect(); + target_items + .iter() + .enumerate() + .find(|(_, target_item)| toml_value_is_subset(target_item, source_item)) + .map(|(index, _)| index) + }; + + if let Some(index) = index { + target.remove(index); + } + } +} + +fn toml_item_is_subset(target: &Item, source: &Item) -> bool { + if let Some(source_table) = source.as_table_like() { + let Some(target_table) = target.as_table_like() else { + return false; + }; + return source_table.iter().all(|(key, source_item)| { + target_table + .get(key) + .is_some_and(|target_item| toml_item_is_subset(target_item, source_item)) + }); + } + + match (target.as_value(), source.as_value()) { + (Some(target_value), Some(source_value)) => toml_value_is_subset(target_value, source_value), + _ => false, + } +} + +fn merge_toml_item(target: &mut Item, source: &Item) { + if let Some(source_table) = source.as_table_like() { + if let Some(target_table) = target.as_table_like_mut() { + merge_toml_table_like(target_table, source_table); + return; + } + } + + *target = source.clone(); +} + +fn merge_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) { + for (key, source_item) in source.iter() { + match target.get_mut(key) { + Some(target_item) => merge_toml_item(target_item, source_item), + None => { + target.insert(key, source_item.clone()); + } + } + } +} + +fn remove_toml_item(target: &mut Item, source: &Item) { + if let Some(source_table) = source.as_table_like() { + if let Some(target_table) = target.as_table_like_mut() { + remove_toml_table_like(target_table, source_table); + if target_table.is_empty() { + *target = Item::None; + } + return; + } + } + + if let Some(source_value) = source.as_value() { + let mut remove_item = false; + + if let Some(target_value) = target.as_value_mut() { + match (target_value, source_value) { + (toml_edit::Value::Array(target_arr), toml_edit::Value::Array(source_arr)) => { + toml_remove_array_items(target_arr, source_arr); + remove_item = target_arr.is_empty(); + } + (target_value, source_value) if toml_value_is_subset(target_value, source_value) => { + remove_item = true; + } + _ => {} + } + } + + if remove_item { + *target = Item::None; + } + } +} + +fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) { + let keys: Vec = source.iter().map(|(key, _)| key.to_string()).collect(); + + for key in keys { + let mut remove_key = false; + if let (Some(target_item), Some(source_item)) = (target.get_mut(&key), source.get(&key)) { + remove_toml_item(target_item, source_item); + remove_key = target_item.is_none() + || target_item + .as_table_like() + .is_some_and(|table_like| table_like.is_empty()); + } + + if remove_key { + target.remove(&key); + } + } +} + +fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool { + let trimmed = snippet.trim(); + if trimmed.is_empty() { + return false; + } + + match app_type { + AppType::Claude => match serde_json::from_str::(trimmed) { + Ok(source) if source.is_object() => json_is_subset(settings, &source), + _ => false, + }, + AppType::Codex => { + let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or(""); + if config_toml.trim().is_empty() { + return false; + } + + let target_doc = match config_toml.parse::() { + Ok(doc) => doc, + Err(_) => return false, + }; + let source_doc = match trimmed.parse::() { + Ok(doc) => doc, + Err(_) => return false, + }; + + toml_item_is_subset(target_doc.as_item(), source_doc.as_item()) + } + AppType::Gemini => match serde_json::from_str::(trimmed) { + Ok(Value::Object(source_map)) => { + let Some(target_map) = settings.get("env").and_then(Value::as_object) else { + return false; + }; + source_map.iter().all(|(key, source_value)| { + target_map + .get(key) + .is_some_and(|target_value| json_is_subset(target_value, source_value)) + }) + } + _ => false, + }, + AppType::OpenCode | AppType::OpenClaw => false, + } +} + +pub(crate) fn provider_uses_common_config( + app_type: &AppType, + provider: &Provider, + snippet: Option<&str>, +) -> bool { + match provider + .meta + .as_ref() + .and_then(|meta| meta.common_config_enabled) + { + Some(explicit) => explicit && snippet.is_some_and(|value| !value.trim().is_empty()), + None => snippet.is_some_and(|value| { + settings_contain_common_config(app_type, &provider.settings_config, value) + }), + } +} + +pub(crate) fn remove_common_config_from_settings( + app_type: &AppType, + settings: &Value, + snippet: &str, +) -> Result { + let trimmed = snippet.trim(); + if trimmed.is_empty() { + return Ok(settings.clone()); + } + + match app_type { + AppType::Claude => { + let source = serde_json::from_str::(trimmed) + .map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?; + let mut result = settings.clone(); + json_deep_remove(&mut result, &source); + Ok(result) + } + AppType::Codex => { + let mut result = settings.clone(); + let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or(""); + let mut target_doc = if config_toml.trim().is_empty() { + DocumentMut::new() + } else { + config_toml.parse::().map_err(|e| { + AppError::Message(format!("Invalid Codex config.toml while removing common config: {e}")) + })? + }; + let source_doc = trimmed.parse::().map_err(|e| { + AppError::Message(format!("Invalid Codex common config snippet: {e}")) + })?; + + remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table()); + if let Some(obj) = result.as_object_mut() { + obj.insert("config".to_string(), Value::String(target_doc.to_string())); + } + Ok(result) + } + AppType::Gemini => { + let source = serde_json::from_str::(trimmed) + .map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?; + let mut result = settings.clone(); + if let Some(env) = result.get_mut("env") { + json_deep_remove(env, &source); + } + Ok(result) + } + AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + } +} + +fn apply_common_config_to_settings( + app_type: &AppType, + settings: &Value, + snippet: &str, +) -> Result { + let trimmed = snippet.trim(); + if trimmed.is_empty() { + return Ok(settings.clone()); + } + + match app_type { + AppType::Claude => { + let source = serde_json::from_str::(trimmed) + .map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?; + let mut result = settings.clone(); + json_deep_merge(&mut result, &source); + Ok(result) + } + AppType::Codex => { + let mut result = settings.clone(); + let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or(""); + let mut target_doc = if config_toml.trim().is_empty() { + DocumentMut::new() + } else { + config_toml.parse::().map_err(|e| { + AppError::Message(format!("Invalid Codex config.toml while applying common config: {e}")) + })? + }; + let source_doc = trimmed.parse::().map_err(|e| { + AppError::Message(format!("Invalid Codex common config snippet: {e}")) + })?; + + merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table()); + if let Some(obj) = result.as_object_mut() { + obj.insert("config".to_string(), Value::String(target_doc.to_string())); + } + Ok(result) + } + AppType::Gemini => { + let source = serde_json::from_str::(trimmed) + .map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?; + let mut result = settings.clone(); + if let Some(env) = result.get_mut("env") { + json_deep_merge(env, &source); + } else if let Some(obj) = result.as_object_mut() { + obj.insert("env".to_string(), source); + } + Ok(result) + } + AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + } +} + +pub(crate) fn write_live_with_common_config( + db: &Database, + app_type: &AppType, + provider: &Provider, +) -> Result<(), AppError> { + let snippet = db.get_config_snippet(app_type.as_str())?; + let mut effective_provider = provider.clone(); + + if provider_uses_common_config(app_type, provider, snippet.as_deref()) { + if let Some(snippet_text) = snippet.as_deref() { + match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text) + { + Ok(settings) => effective_provider.settings_config = settings, + Err(err) => { + log::warn!( + "Failed to apply common config for {} provider '{}': {err}", + app_type.as_str(), + provider.id + ); + } + } + } + } + + write_live_snapshot(app_type, &effective_provider) +} + +pub(crate) fn strip_common_config_from_live_settings( + db: &Database, + app_type: &AppType, + provider: &Provider, + live_settings: Value, +) -> Value { + let snippet = match db.get_config_snippet(app_type.as_str()) { + Ok(snippet) => snippet, + Err(err) => { + log::warn!( + "Failed to load common config for {} while backfilling '{}': {err}", + app_type.as_str(), + provider.id + ); + return live_settings; + } + }; + + if !provider_uses_common_config(app_type, provider, snippet.as_deref()) { + return live_settings; + } + + let Some(snippet_text) = snippet.as_deref() else { + return live_settings; + }; + + match remove_common_config_from_settings(app_type, &live_settings, snippet_text) { + Ok(settings) => settings, + Err(err) => { + log::warn!( + "Failed to strip common config for {} provider '{}': {err}", + app_type.as_str(), + provider.id + ); + live_settings + } + } +} + +pub(crate) fn normalize_provider_common_config_for_storage( + db: &Database, + app_type: &AppType, + provider: &mut Provider, +) -> Result<(), AppError> { + let uses_common_config = provider + .meta + .as_ref() + .and_then(|meta| meta.common_config_enabled) + .unwrap_or(false); + + if !uses_common_config { + return Ok(()); + } + + let Some(snippet) = db.get_config_snippet(app_type.as_str())? else { + return Ok(()); + }; + + if snippet.trim().is_empty() { + return Ok(()); + } + + match remove_common_config_from_settings(app_type, &provider.settings_config, &snippet) { + Ok(settings) => provider.settings_config = settings, + Err(err) => { + log::warn!( + "Failed to normalize common config before saving {} provider '{}': {err}", + app_type.as_str(), + provider.id + ); + } + } + + Ok(()) +} + /// Live configuration snapshot for backup/restore #[derive(Clone)] #[allow(dead_code)] @@ -245,7 +766,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<() let providers = state.db.get_all_providers(app_type.as_str())?; for provider in providers.values() { - if let Err(e) = write_live_snapshot(app_type, provider) { + if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) { log::warn!( "Failed to sync {:?} provider '{}' to live: {e}", app_type, @@ -263,6 +784,29 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<() Ok(()) } +pub(crate) fn sync_current_provider_for_app_to_live( + state: &AppState, + app_type: &AppType, +) -> Result<(), AppError> { + if app_type.is_additive_mode() { + sync_all_providers_to_live(state, app_type)?; + } else { + let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)? { + Some(id) => id, + None => return Ok(()), + }; + + let providers = state.db.get_all_providers(app_type.as_str())?; + if let Some(provider) = providers.get(¤t_id) { + write_live_with_common_config(state.db.as_ref(), app_type, provider)?; + } + } + + McpService::sync_all_enabled(state)?; + + Ok(()) +} + /// Sync current provider to live configuration /// /// 使用有效的当前供应商 ID(验证过存在性)。 @@ -286,7 +830,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { let providers = state.db.get_all_providers(app_type.as_str())?; if let Some(provider) = providers.get(¤t_id) { - write_live_snapshot(&app_type, provider)?; + write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; } // Note: get_effective_current_provider already validates existence, // so providers.get() should always succeed here @@ -742,3 +1286,133 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() { + let settings = json!({ + "env": { + "ANTHROPIC_API_KEY": "sk-test" + } + }); + let snippet = r#"{ + "includeCoAuthoredBy": false, + "env": { + "CLAUDE_CODE_USE_BEDROCK": "1" + } +}"#; + + let applied = + apply_common_config_to_settings(&AppType::Claude, &settings, snippet).unwrap(); + assert_eq!(applied["includeCoAuthoredBy"], json!(false)); + assert_eq!(applied["env"]["CLAUDE_CODE_USE_BEDROCK"], json!("1")); + + let stripped = + remove_common_config_from_settings(&AppType::Claude, &applied, snippet).unwrap(); + assert_eq!(stripped, settings); + } + + #[test] + fn codex_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() { + let settings = json!({ + "auth": { + "OPENAI_API_KEY": "sk-test" + }, + "config": "model_provider = \"openai\"\n[general]\nmodel = \"gpt-5\"\n" + }); + let snippet = "[shared]\nreasoning = \"medium\"\n"; + + let applied = + apply_common_config_to_settings(&AppType::Codex, &settings, snippet).unwrap(); + let applied_config = applied["config"].as_str().unwrap_or_default(); + assert!(applied_config.contains("[shared]")); + assert!(applied_config.contains("reasoning = \"medium\"")); + + let stripped = + remove_common_config_from_settings(&AppType::Codex, &applied, snippet).unwrap(); + assert_eq!(stripped, settings); + } + + #[test] + fn explicit_common_config_flag_overrides_legacy_subset_detection() { + let mut provider = Provider::with_id( + "claude-test".to_string(), + "Claude Test".to_string(), + json!({ + "includeCoAuthoredBy": false + }), + None, + ); + provider.meta = Some(crate::provider::ProviderMeta { + common_config_enabled: Some(false), + ..Default::default() + }); + + assert!( + !provider_uses_common_config( + &AppType::Claude, + &provider, + Some(r#"{ "includeCoAuthoredBy": false }"#), + ), + "explicit false should win over legacy subset detection" + ); + } + + #[test] + fn claude_common_config_array_subset_detection_and_strip_preserve_extra_items() { + let settings = json!({ + "allowedTools": ["tool1", "tool2"] + }); + let snippet = r#"{ + "allowedTools": ["tool1"] +}"#; + + assert!( + settings_contain_common_config(&AppType::Claude, &settings, snippet), + "array subset should be detected for legacy providers" + ); + + let stripped = + remove_common_config_from_settings(&AppType::Claude, &settings, snippet).unwrap(); + assert_eq!( + stripped, + json!({ + "allowedTools": ["tool2"] + }) + ); + } + + #[test] + fn codex_common_config_array_subset_detection_and_strip_preserve_extra_items() { + let settings = json!({ + "auth": {}, + "config": "allowed_tools = [\"tool1\", \"tool2\"]\n" + }); + let snippet = "allowed_tools = [\"tool1\"]\n"; + + assert!( + settings_contain_common_config(&AppType::Codex, &settings, snippet), + "TOML array subset should be detected for legacy providers" + ); + + let stripped = + remove_common_config_from_settings(&AppType::Codex, &settings, snippet).unwrap(); + assert_eq!(stripped["auth"], json!({})); + let stripped_config = stripped["config"].as_str().unwrap_or_default(); + let parsed = stripped_config + .parse::() + .expect("stripped codex config should remain valid TOML"); + let allowed_tools = parsed["allowed_tools"] + .as_array() + .expect("allowed_tools should remain an array"); + let values: Vec<&str> = allowed_tools + .iter() + .map(|value| value.as_str().expect("tool id should be string")) + .collect(); + assert_eq!(values, vec!["tool2"]); + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index d3931f82..4c0bc695 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -27,7 +27,10 @@ pub use live::{ // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; -pub(crate) use live::write_live_snapshot; +pub(crate) use live::{ + normalize_provider_common_config_for_storage, strip_common_config_from_live_settings, + sync_current_provider_for_app_to_live, write_live_with_common_config, +}; // Internal re-exports use live::{ @@ -167,6 +170,7 @@ impl ProviderService { // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; + normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; // Save to database state.db.save_provider(app_type.as_str(), &provider)?; @@ -181,7 +185,7 @@ impl ProviderService { // Users must explicitly switch/apply an OMO provider to activate it. return Ok(true); } - write_live_snapshot(&app_type, &provider)?; + write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } @@ -192,7 +196,7 @@ impl ProviderService { state .db .set_current_provider(app_type.as_str(), &provider.id)?; - write_live_snapshot(&app_type, &provider)?; + write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; } Ok(true) @@ -208,6 +212,7 @@ impl ProviderService { // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; + normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; // Save to database state.db.save_provider(app_type.as_str(), &provider)?; @@ -244,7 +249,7 @@ impl ProviderService { } return Ok(true); } - write_live_snapshot(&app_type, &provider)?; + write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } @@ -273,7 +278,7 @@ impl ProviderService { ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; } else { - write_live_snapshot(&app_type, &provider)?; + write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; // Sync MCP McpService::sync_all_enabled(state)?; } @@ -560,7 +565,12 @@ 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; + current_provider.settings_config = strip_common_config_from_live_settings( + state.db.as_ref(), + &app_type, + ¤t_provider, + live_config, + ); if let Err(e) = state.db.save_provider(app_type.as_str(), ¤t_provider) { @@ -585,7 +595,7 @@ impl ProviderService { } // Sync to live (write_gemini_live handles security flag internally for Gemini) - write_live_snapshot(&app_type, provider)?; + write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; // Sync MCP McpService::sync_all_enabled(state)?; @@ -598,6 +608,67 @@ impl ProviderService { sync_current_to_live(state) } + pub fn sync_current_provider_for_app( + state: &AppState, + app_type: AppType, + ) -> Result<(), AppError> { + sync_current_provider_for_app_to_live(state, &app_type) + } + + pub fn migrate_legacy_common_config_usage( + state: &AppState, + app_type: AppType, + legacy_snippet: &str, + ) -> Result<(), AppError> { + if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() { + return Ok(()); + } + + let providers = state.db.get_all_providers(app_type.as_str())?; + + for provider in providers.values() { + if provider + .meta + .as_ref() + .and_then(|meta| meta.common_config_enabled) + .is_some() + { + continue; + } + + if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) { + continue; + } + + let mut updated_provider = provider.clone(); + updated_provider + .meta + .get_or_insert_with(Default::default) + .common_config_enabled = Some(true); + + match live::remove_common_config_from_settings( + &app_type, + &updated_provider.settings_config, + legacy_snippet, + ) { + Ok(settings) => updated_provider.settings_config = settings, + Err(err) => { + log::warn!( + "Failed to normalize legacy common config for {} provider '{}': {err}", + app_type.as_str(), + updated_provider.id + ); + } + } + + state + .db + .save_provider(app_type.as_str(), &updated_provider)?; + } + + Ok(()) + } + /// Extract common config snippet from current provider /// /// Extracts the current provider's configuration and removes provider-specific fields diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 5325f4af..a3e5ff3f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -8,7 +8,7 @@ use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; -use crate::services::provider::write_live_snapshot; +use crate::services::provider::write_live_with_common_config; use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; @@ -1266,7 +1266,7 @@ impl ProxyService { return Ok(false); }; - write_live_snapshot(app_type, provider) + write_live_with_common_config(self.db.as_ref(), app_type, provider) .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; Ok(true) diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 8e1c1a87..788e1c73 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -237,13 +237,20 @@ export function ProviderForm({ mode: "onSubmit", }); + const handleSettingsConfigChange = useCallback( + (config: string) => { + form.setValue("settingsConfig", config); + }, + [form], + ); + const { apiKey, handleApiKeyChange, showApiKey: shouldShowApiKey, } = useApiKeyState({ initialConfig: form.getValues("settingsConfig"), - onConfigChange: (config) => form.setValue("settingsConfig", config), + onConfigChange: handleSettingsConfigChange, selectedPresetId, category, appType: appId, @@ -254,7 +261,7 @@ export function ProviderForm({ category, settingsConfig: form.getValues("settingsConfig"), codexConfig: "", - onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), + onSettingsConfigChange: handleSettingsConfigChange, onCodexConfigChange: () => {}, }); @@ -267,7 +274,7 @@ export function ProviderForm({ handleModelChange, } = useModelState({ settingsConfig: form.getValues("settingsConfig"), - onConfigChange: (config) => form.setValue("settingsConfig", config), + onConfigChange: handleSettingsConfigChange, }); const [localApiFormat, setLocalApiFormat] = useState(() => { @@ -373,7 +380,7 @@ export function ProviderForm({ selectedPresetId: appId === "claude" ? selectedPresetId : null, presetEntries: appId === "claude" ? presetEntries : [], settingsConfig: form.getValues("settingsConfig"), - onConfigChange: (config) => form.setValue("settingsConfig", config), + onConfigChange: handleSettingsConfigChange, }); const { @@ -386,8 +393,10 @@ export function ProviderForm({ handleExtract: handleClaudeExtract, } = useCommonConfigSnippet({ settingsConfig: form.getValues("settingsConfig"), - onConfigChange: (config) => form.setValue("settingsConfig", config), + onConfigChange: handleSettingsConfigChange, initialData: appId === "claude" ? initialData : undefined, + initialEnabled: + appId === "claude" ? initialData?.meta?.commonConfigEnabled : undefined, selectedPresetId: selectedPresetId ?? undefined, enabled: appId === "claude", }); @@ -404,6 +413,8 @@ export function ProviderForm({ codexConfig, onConfigChange: handleCodexConfigChange, initialData: appId === "codex" ? initialData : undefined, + initialEnabled: + appId === "codex" ? initialData?.meta?.commonConfigEnabled : undefined, selectedPresetId: selectedPresetId ?? undefined, }); @@ -487,6 +498,8 @@ export function ProviderForm({ envStringToObj, envObjToString, initialData: appId === "gemini" ? initialData : undefined, + initialEnabled: + appId === "gemini" ? initialData?.meta?.commonConfigEnabled : undefined, selectedPresetId: selectedPresetId ?? undefined, }); @@ -809,6 +822,14 @@ export function ProviderForm({ payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined); payload.meta = { ...(baseMeta ?? {}), + commonConfigEnabled: + appId === "claude" + ? useCommonConfig + : appId === "codex" + ? useCodexCommonConfigFlag + : appId === "gemini" + ? useGeminiCommonConfigFlag + : undefined, endpointAutoSelect, testConfig: testConfig.enabled ? testConfig : undefined, proxyConfig: proxyConfig.enabled ? proxyConfig : undefined, diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index 849908c7..23b2498c 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -16,6 +16,7 @@ interface UseCodexCommonConfigProps { initialData?: { settingsConfig?: Record; }; + initialEnabled?: boolean; selectedPresetId?: string; } @@ -27,6 +28,7 @@ export function useCodexCommonConfig({ codexConfig, onConfigChange, initialData, + initialEnabled, selectedPresetId, }: UseCodexCommonConfigProps) { const { t } = useTranslation(); @@ -42,11 +44,14 @@ export function useCodexCommonConfig({ const isUpdatingFromCommonConfig = useRef(false); // 用于跟踪新建模式是否已初始化默认勾选 const hasInitializedNewMode = useRef(false); + // 用于跟踪编辑模式是否已初始化显式开关/预览 + const hasInitializedEditMode = useRef(false); // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 useEffect(() => { hasInitializedNewMode.current = false; - }, [selectedPresetId]); + hasInitializedEditMode.current = false; + }, [selectedPresetId, initialEnabled]); // 初始化:从 config.json 加载,支持从 localStorage 迁移 useEffect(() => { @@ -107,10 +112,43 @@ export function useCodexCommonConfig({ typeof initialData.settingsConfig.config === "string" ? initialData.settingsConfig.config : ""; - const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet); + const inferredHasCommon = hasTomlCommonConfigSnippet( + config, + commonConfigSnippet, + ); + const hasCommon = initialEnabled ?? inferredHasCommon; setUseCommonConfig(hasCommon); + + if ( + hasCommon && + !inferredHasCommon && + !hasInitializedEditMode.current + ) { + hasInitializedEditMode.current = true; + const { updatedConfig, error } = updateTomlCommonConfigSnippet( + codexConfig, + commonConfigSnippet, + true, + ); + if (!error) { + isUpdatingFromCommonConfig.current = true; + onConfigChange(updatedConfig); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + } + } else { + hasInitializedEditMode.current = true; + } } - }, [initialData, commonConfigSnippet, isLoading]); + }, [ + codexConfig, + commonConfigSnippet, + initialData, + initialEnabled, + isLoading, + onConfigChange, + ]); // 新建模式:如果通用配置片段存在且有效,默认启用 useEffect(() => { diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts index 80bf37ff..d8fa0456 100644 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -18,6 +18,7 @@ interface UseCommonConfigSnippetProps { initialData?: { settingsConfig?: Record; }; + initialEnabled?: boolean; selectedPresetId?: string; /** When false, the hook skips all logic and returns disabled state. Default: true */ enabled?: boolean; @@ -31,6 +32,7 @@ export function useCommonConfigSnippet({ settingsConfig, onConfigChange, initialData, + initialEnabled, selectedPresetId, enabled = true, }: UseCommonConfigSnippetProps) { @@ -47,12 +49,15 @@ export function useCommonConfigSnippet({ const isUpdatingFromCommonConfig = useRef(false); // 用于跟踪新建模式是否已初始化默认勾选 const hasInitializedNewMode = useRef(false); + // 用于跟踪编辑模式是否已初始化显式开关/预览 + const hasInitializedEditMode = useRef(false); // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 useEffect(() => { if (!enabled) return; hasInitializedNewMode.current = false; - }, [selectedPresetId, enabled]); + hasInitializedEditMode.current = false; + }, [selectedPresetId, enabled, initialEnabled]); // 初始化:从 config.json 加载,支持从 localStorage 迁移 useEffect(() => { @@ -115,13 +120,44 @@ export function useCommonConfigSnippet({ if (!enabled) return; if (initialData && !isLoading) { const configString = JSON.stringify(initialData.settingsConfig, null, 2); - const hasCommon = hasCommonConfigSnippet( + const inferredHasCommon = hasCommonConfigSnippet( configString, commonConfigSnippet, ); + const hasCommon = initialEnabled ?? inferredHasCommon; setUseCommonConfig(hasCommon); + + if ( + hasCommon && + !inferredHasCommon && + !hasInitializedEditMode.current + ) { + hasInitializedEditMode.current = true; + const { updatedConfig, error } = updateCommonConfigSnippet( + settingsConfig, + commonConfigSnippet, + true, + ); + if (!error) { + isUpdatingFromCommonConfig.current = true; + onConfigChange(updatedConfig); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + } + } else { + hasInitializedEditMode.current = true; + } } - }, [enabled, initialData, commonConfigSnippet, isLoading]); + }, [ + enabled, + initialData, + initialEnabled, + commonConfigSnippet, + isLoading, + onConfigChange, + settingsConfig, + ]); // 新建模式:如果通用配置片段存在且有效,默认启用 useEffect(() => { diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts index 71f76125..53a2acbd 100644 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts @@ -19,6 +19,7 @@ interface UseGeminiCommonConfigProps { initialData?: { settingsConfig?: Record; }; + initialEnabled?: boolean; selectedPresetId?: string; } @@ -43,6 +44,7 @@ export function useGeminiCommonConfig({ envStringToObj, envObjToString, initialData, + initialEnabled, selectedPresetId, }: UseGeminiCommonConfigProps) { const { t } = useTranslation(); @@ -58,11 +60,14 @@ export function useGeminiCommonConfig({ const isUpdatingFromCommonConfig = useRef(false); // 用于跟踪新建模式是否已初始化默认勾选 const hasInitializedNewMode = useRef(false); + // 用于跟踪编辑模式是否已初始化显式开关/预览 + const hasInitializedEditMode = useRef(false); // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 useEffect(() => { hasInitializedNewMode.current = false; - }, [selectedPresetId]); + hasInitializedEditMode.current = false; + }, [selectedPresetId, initialEnabled]); const parseSnippetEnv = useCallback( ( @@ -220,20 +225,46 @@ export function useGeminiCommonConfig({ : {}; const parsed = parseSnippetEnv(commonConfigSnippet); if (parsed.error) return; - const hasCommon = hasEnvCommonConfigSnippet( + const inferredHasCommon = hasEnvCommonConfigSnippet( env, parsed.env as Record, ); + const hasCommon = initialEnabled ?? inferredHasCommon; setUseCommonConfig(hasCommon); + + if ( + hasCommon && + !inferredHasCommon && + !hasInitializedEditMode.current + ) { + hasInitializedEditMode.current = true; + const currentEnv = envStringToObj(envValue); + const merged = applySnippetToEnv(currentEnv, parsed.env); + const nextEnvString = envObjToString(merged); + + isUpdatingFromCommonConfig.current = true; + onEnvChange(nextEnvString); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + } else { + hasInitializedEditMode.current = true; + } } catch { // ignore parse error } } }, [ + applySnippetToEnv, commonConfigSnippet, + envObjToString, + envStringToObj, + envValue, hasEnvCommonConfigSnippet, initialData, + initialEnabled, isLoading, + onEnvChange, parseSnippetEnv, ]); diff --git a/src/types.ts b/src/types.ts index d40722ab..c7103554 100644 --- a/src/types.ts +++ b/src/types.ts @@ -126,6 +126,8 @@ export interface ProviderProxyConfig { export interface ProviderMeta { // 自定义端点:以 URL 为键,值为端点信息 custom_endpoints?: Record; + // 是否在切换/同步到 live 时应用通用配置片段 + commonConfigEnabled?: boolean; // 用量查询脚本配置 usage_script?: UsageScript; // 请求地址管理:测速后自动选择最佳端点