From cc235c6c63a016381ea08fac5db472bd412fdcc2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 22 Feb 2026 11:06:03 +0800 Subject: [PATCH] refactor(provider): switch from full config overwrite to partial key-field merging Replace the provider switching mechanism for Claude/Codex/Gemini from full settings_config overwrite to partial key-field replacement, preserving user's non-provider settings (plugins, MCP, permissions, etc.) across switches. - Add write_live_partial() with per-app implementations for Claude (JSON env merge), Codex (auth replace + TOML partial merge), and Gemini (env merge) - Add backfill_key_fields() to extract only provider-specific fields when saving live config back to provider entries - Update switch_normal, sync_current_to_live, add, update to use partial merge - Remove common config snippet feature for Claude/Codex/Gemini (no longer needed with partial merging); preserve OMO common config - Delete 6 frontend files (3 components + 3 hooks), clean up 11 modified files - Remove backend extract_common_config_* methods, 3 Tauri commands, CommonConfigSnippets struct, and related migration code - Update integration tests to validate key-field-only backfill behavior --- src-tauri/src/app_config.rs | 60 --- src-tauri/src/commands/config.rs | 55 --- src-tauri/src/commands/provider.rs | 22 +- src-tauri/src/database/migration.rs | 33 -- src-tauri/src/database/tests.rs | 4 - src-tauri/src/lib.rs | 3 - src-tauri/src/services/provider/live.rs | 352 ++++++++++++- src-tauri/src/services/provider/mod.rs | 272 +--------- src-tauri/tests/provider_commands.rs | 28 +- src-tauri/tests/provider_service.rs | 14 +- .../forms/CodexCommonConfigModal.tsx | 107 ---- .../providers/forms/CodexConfigEditor.tsx | 50 +- .../providers/forms/CodexConfigSections.tsx | 48 +- .../providers/forms/CommonConfigEditor.tsx | 280 ----------- .../forms/GeminiCommonConfigModal.tsx | 106 ---- .../providers/forms/GeminiConfigEditor.tsx | 41 +- .../providers/forms/GeminiConfigSections.tsx | 52 +- .../providers/forms/ProviderForm.tsx | 105 +--- src/components/providers/forms/hooks/index.ts | 3 - .../forms/hooks/useCodexCommonConfig.ts | 308 ------------ .../forms/hooks/useCommonConfigSnippet.ts | 331 ------------- .../forms/hooks/useGeminiCommonConfig.ts | 465 ------------------ src/i18n/locales/en.json | 32 +- src/i18n/locales/ja.json | 32 +- src/i18n/locales/zh.json | 32 +- src/lib/api/config.ts | 49 -- src/utils/providerConfigUtils.ts | 222 --------- 27 files changed, 429 insertions(+), 2677 deletions(-) delete mode 100644 src/components/providers/forms/CodexCommonConfigModal.tsx delete mode 100644 src/components/providers/forms/CommonConfigEditor.tsx delete mode 100644 src/components/providers/forms/GeminiCommonConfigModal.tsx delete mode 100644 src/components/providers/forms/hooks/useCodexCommonConfig.ts 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/app_config.rs b/src-tauri/src/app_config.rs index 18e3fbec8..f2bb99eef 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -352,49 +352,6 @@ 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, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opencode: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub openclaw: 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(), - AppType::OpenCode => self.opencode.as_ref(), - AppType::OpenClaw => self.openclaw.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, - AppType::OpenCode => self.opencode = snippet, - AppType::OpenClaw => self.openclaw = snippet, - } - } -} - /// 多应用配置结构(向后兼容) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultiAppConfig { @@ -412,12 +369,6 @@ pub struct MultiAppConfig { /// Claude Skills 配置 #[serde(default)] pub skills: SkillStore, - /// 通用配置片段(按应用分治) - #[serde(default)] - pub common_config_snippets: CommonConfigSnippets, - /// Claude 通用配置片段(旧字段,用于向后兼容迁移) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claude_common_config_snippet: Option, } fn default_version() -> u32 { @@ -439,8 +390,6 @@ impl Default for MultiAppConfig { mcp: McpRoot::default(), prompts: PromptRoot::default(), skills: SkillStore::default(), - common_config_snippets: CommonConfigSnippets::default(), - claude_common_config_snippet: None, } } } @@ -534,15 +483,6 @@ 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 8edf15617..ca7759f4c 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -164,38 +164,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { Ok(true) } -#[tauri::command] -pub async fn get_claude_common_config_snippet( - state: tauri::State<'_, crate::store::AppState>, -) -> Result, String> { - state - .db - .get_config_snippet("claude") - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn set_claude_common_config_snippet( - snippet: String, - state: tauri::State<'_, crate::store::AppState>, -) -> Result<(), String> { - if !snippet.trim().is_empty() { - serde_json::from_str::(&snippet).map_err(invalid_json_format_error)?; - } - - let value = if snippet.trim().is_empty() { - None - } else { - Some(snippet) - }; - - state - .db - .set_config_snippet("claude", value) - .map_err(|e| e.to_string())?; - Ok(()) -} - #[tauri::command] pub async fn get_common_config_snippet( app_type: String, @@ -263,26 +231,3 @@ pub async fn set_common_config_snippet( } Ok(()) } - -#[tauri::command] -pub async fn extract_common_config_snippet( - appType: String, - settingsConfig: Option, - state: tauri::State<'_, crate::store::AppState>, -) -> Result { - let app = AppType::from_str(&appType).map_err(|e| e.to_string())?; - - if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) { - let settings: serde_json::Value = - serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?; - - return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings( - app, - &settings, - ) - .map_err(|e| e.to_string()); - } - - crate::services::provider::ProviderService::extract_common_config_snippet(&state, app) - .map_err(|e| e.to_string()) -} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index ef9c1bb0e..21e6ab829 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -97,27 +97,7 @@ pub fn switch_provider( } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { - let imported = ProviderService::import_default_config(state, app_type.clone())?; - - if imported { - // Extract common config snippet (mirrors old startup logic in lib.rs) - if state - .db - .get_config_snippet(app_type.as_str()) - .ok() - .flatten() - .is_none() - { - match ProviderService::extract_common_config_snippet(state, app_type.clone()) { - Ok(snippet) if !snippet.is_empty() && snippet != "{}" => { - let _ = state - .db - .set_config_snippet(app_type.as_str(), Some(snippet)); - } - _ => {} - } - } - } + let imported = ProviderService::import_default_config(state, app_type)?; Ok(imported) } diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs index 3c52e7127..f42431587 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -58,9 +58,6 @@ impl Database { // 4. 迁移 Skills Self::migrate_skills(tx, config)?; - // 5. 迁移 Common Config - Self::migrate_common_config(tx, config)?; - Ok(()) } @@ -212,34 +209,4 @@ impl Database { Ok(()) } - - /// 迁移通用配置片段 - fn migrate_common_config( - tx: &rusqlite::Transaction<'_>, - config: &MultiAppConfig, - ) -> Result<(), AppError> { - if let Some(snippet) = &config.common_config_snippets.claude { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_claude", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - if let Some(snippet) = &config.common_config_snippets.codex { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_codex", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - if let Some(snippet) = &config.common_config_snippets.gemini { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_gemini", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - - Ok(()) - } } diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 8e37b3d60..e76dd7d6b 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -513,8 +513,6 @@ fn schema_dry_run_does_not_write_to_disk() { mcp: Default::default(), prompts: Default::default(), skills: Default::default(), - common_config_snippets: Default::default(), - claude_common_config_snippet: None, }; // Dry-run should succeed without any file I/O errors @@ -563,8 +561,6 @@ fn dry_run_validates_schema_compatibility() { mcp: Default::default(), prompts: Default::default(), skills: Default::default(), - common_config_snippets: Default::default(), - claude_common_config_snippet: None, }; // Dry-run should validate the full migration path diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5b023673e..ad7cb66b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -845,11 +845,8 @@ pub fn run() { commands::get_skills_migration_result, commands::get_app_config_path, 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::extract_common_config_snippet, commands::read_live_provider_settings, commands::get_settings, commands::save_settings, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index da4e15fa6..7f7d0c848 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -237,6 +237,356 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re Ok(()) } +// ============================================================================ +// Key fields definitions for partial merge +// ============================================================================ + +/// Claude env-level key fields that belong to the provider +const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "ANTHROPIC_MODEL", + "ANTHROPIC_REASONING_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", +]; + +/// Claude top-level key fields (legacy format) +const CLAUDE_KEY_TOP_LEVEL: &[&str] = &["apiBaseUrl", "primaryModel", "smallFastModel"]; + +/// Codex TOML key fields +const CODEX_KEY_TOP_LEVEL: &[&str] = &["model_provider", "model", "model_reasoning_effort"]; + +/// Gemini env-level key fields +const GEMINI_KEY_ENV_FIELDS: &[&str] = + &["GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY", "GEMINI_MODEL"]; + +// ============================================================================ +// Partial merge: write only key fields to live config +// ============================================================================ + +/// Write only provider-specific key fields to live configuration, +/// preserving all other user settings in the live file. +/// +/// Used for switch-mode apps (Claude, Codex, Gemini) during: +/// - `switch_normal()` — switching providers +/// - `sync_current_to_live()` — startup sync +/// - `add()` / `update()` when the provider is current +pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { + match app_type { + AppType::Claude => write_claude_live_partial(provider), + AppType::Codex => write_codex_live_partial(provider), + AppType::Gemini => write_gemini_live_partial(provider), + // Additive mode apps still use full snapshot + AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider), + } +} + +/// Claude: merge only key env and top-level fields into live settings.json +fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> { + let path = get_claude_settings_path(); + + // 1. Read existing live config (start from empty if file doesn't exist) + let mut live = if path.exists() { + read_json_file(&path).unwrap_or_else(|_| json!({})) + } else { + json!({}) + }; + + // 2. Ensure live.env exists as an object + if !live.get("env").is_some_and(|v| v.is_object()) { + live.as_object_mut() + .unwrap() + .insert("env".into(), json!({})); + } + + // 3. Clear key env fields from live, then write from provider + let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap(); + for key in CLAUDE_KEY_ENV_FIELDS { + live_env.remove(*key); + } + + if let Some(provider_env) = provider + .settings_config + .get("env") + .and_then(|v| v.as_object()) + { + for key in CLAUDE_KEY_ENV_FIELDS { + if let Some(value) = provider_env.get(*key) { + live_env.insert(key.to_string(), value.clone()); + } + } + } + + // 4. Handle top-level legacy key fields + let live_obj = live.as_object_mut().unwrap(); + for key in CLAUDE_KEY_TOP_LEVEL { + live_obj.remove(*key); + } + if let Some(provider_obj) = provider.settings_config.as_object() { + for key in CLAUDE_KEY_TOP_LEVEL { + if let Some(value) = provider_obj.get(*key) { + live_obj.insert(key.to_string(), value.clone()); + } + } + } + + // 5. Sanitize and write + let settings = sanitize_claude_settings_for_live(&live); + write_json_file(&path, &settings)?; + Ok(()) +} + +/// Codex: replace auth.json entirely, partially merge config.toml key fields +fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> { + let obj = provider + .settings_config + .as_object() + .ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?; + + // auth.json is entirely provider-specific, replace it wholesale + let auth = obj + .get("auth") + .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; + + let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or(""); + + // Read existing config.toml (or start from empty) + let config_path = get_codex_config_path(); + let existing_toml = if config_path.exists() { + std::fs::read_to_string(&config_path).unwrap_or_default() + } else { + String::new() + }; + + // Parse both existing and provider TOML + let mut live_doc = existing_toml + .parse::() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + + // Remove key fields from live doc + let live_root = live_doc.as_table_mut(); + for key in CODEX_KEY_TOP_LEVEL { + live_root.remove(key); + } + live_root.remove("model_providers"); + + // Parse provider TOML and extract key fields + if !provider_config_str.is_empty() { + if let Ok(provider_doc) = provider_config_str.parse::() { + let provider_root = provider_doc.as_table(); + + // Copy key top-level fields from provider + for key in CODEX_KEY_TOP_LEVEL { + if let Some(item) = provider_root.get(key) { + live_root.insert(key, item.clone()); + } + } + + // Copy model_providers table from provider + if let Some(mp) = provider_root.get("model_providers") { + live_root.insert("model_providers", mp.clone()); + } + } + } + + // Write using atomic write + crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?; + Ok(()) +} + +/// Gemini: merge only key env fields, preserve settings.json (MCP etc.) +fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> { + use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic}; + + let auth_type = detect_gemini_auth_type(provider); + + // 1. Read existing env from live .env file + let mut env_map = if get_gemini_env_path().exists() { + read_gemini_env().unwrap_or_default() + } else { + HashMap::new() + }; + + // 2. Remove key fields from existing env + for key in GEMINI_KEY_ENV_FIELDS { + env_map.remove(*key); + } + + // 3. Extract key fields from provider and merge + if let Some(provider_env) = provider + .settings_config + .get("env") + .and_then(|v| v.as_object()) + { + for key in GEMINI_KEY_ENV_FIELDS { + if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) { + if !value.is_empty() { + env_map.insert(key.to_string(), value.to_string()); + } + } + } + } + + // 4. Handle auth type specific behavior + match auth_type { + GeminiAuthType::GoogleOfficial => { + // Google official uses OAuth, clear all env + env_map.clear(); + write_gemini_env_atomic(&env_map)?; + } + GeminiAuthType::Packycode | GeminiAuthType::Generic => { + // Validate and write env + crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?; + write_gemini_env_atomic(&env_map)?; + } + } + + // 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.) + use crate::gemini_config::get_gemini_settings_path; + let settings_path = get_gemini_settings_path(); + + if let Some(config_value) = provider.settings_config.get("config") { + if config_value.is_object() { + let mut merged = if settings_path.exists() { + read_json_file::(&settings_path).unwrap_or_else(|_| json!({})) + } else { + json!({}) + }; + 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()); + } + } + write_json_file(&settings_path, &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", + )); + } + } + + // 6. Set security flag based on auth type + match auth_type { + GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?, + GeminiAuthType::Packycode | GeminiAuthType::Generic => { + crate::gemini_config::write_packycode_settings()?; + } + } + + Ok(()) +} + +// ============================================================================ +// Backfill: extract only key fields from live config +// ============================================================================ + +/// Extract only provider-specific key fields from a live config value. +/// +/// Used during backfill to ensure the provider's `settings_config` converges +/// to containing only key fields over time. +pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value { + match app_type { + AppType::Claude => backfill_claude_key_fields(live_config), + AppType::Codex => backfill_codex_key_fields(live_config), + AppType::Gemini => backfill_gemini_key_fields(live_config), + // Additive mode: return full config (no backfill needed) + _ => live_config.clone(), + } +} + +fn backfill_claude_key_fields(live: &Value) -> Value { + let mut result = json!({}); + let result_obj = result.as_object_mut().unwrap(); + + // Extract key env fields + if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) { + let mut env_obj = serde_json::Map::new(); + for key in CLAUDE_KEY_ENV_FIELDS { + if let Some(value) = live_env.get(*key) { + env_obj.insert(key.to_string(), value.clone()); + } + } + if !env_obj.is_empty() { + result_obj.insert("env".to_string(), Value::Object(env_obj)); + } + } + + // Extract key top-level fields + if let Some(live_obj) = live.as_object() { + for key in CLAUDE_KEY_TOP_LEVEL { + if let Some(value) = live_obj.get(*key) { + result_obj.insert(key.to_string(), value.clone()); + } + } + } + + result +} + +fn backfill_codex_key_fields(live: &Value) -> Value { + let mut result = json!({}); + let result_obj = result.as_object_mut().unwrap(); + + // auth is entirely provider-specific — keep it as-is + if let Some(auth) = live.get("auth") { + result_obj.insert("auth".to_string(), auth.clone()); + } + + // Extract key TOML fields from config string + if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) { + if let Ok(doc) = config_str.parse::() { + let mut new_doc = toml_edit::DocumentMut::new(); + let new_root = new_doc.as_table_mut(); + + // Copy key top-level fields + for key in CODEX_KEY_TOP_LEVEL { + if let Some(item) = doc.as_table().get(key) { + new_root.insert(key, item.clone()); + } + } + + // Copy model_providers table + if let Some(mp) = doc.as_table().get("model_providers") { + new_root.insert("model_providers", mp.clone()); + } + + let toml_str = new_doc.to_string(); + if !toml_str.trim().is_empty() { + result_obj.insert("config".to_string(), Value::String(toml_str)); + } + } + } + + result +} + +fn backfill_gemini_key_fields(live: &Value) -> Value { + let mut result = json!({}); + let result_obj = result.as_object_mut().unwrap(); + + // Extract key env fields + if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) { + let mut env_obj = serde_json::Map::new(); + for key in GEMINI_KEY_ENV_FIELDS { + if let Some(value) = live_env.get(*key) { + env_obj.insert(key.to_string(), value.clone()); + } + } + if !env_obj.is_empty() { + result_obj.insert("env".to_string(), Value::Object(env_obj)); + } + } + + result +} + /// Sync all providers to live configuration (for additive mode apps) /// /// Writes all providers from the database to the live configuration file. @@ -286,7 +636,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_partial(&app_type, provider)?; } // Note: get_effective_current_provider already validates existence, // so providers.get() should always succeed here diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7a35754a4..448d896f6 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -31,7 +31,8 @@ pub(crate) use live::write_live_snapshot; // Internal re-exports use live::{ - remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, + backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live, + write_gemini_live, write_live_partial, }; use usage::validate_usage_script; @@ -84,47 +85,6 @@ mod tests { assert_eq!(api_key, "token"); assert_eq!(base_url, "https://claude.example"); } - - #[test] - fn extract_codex_common_config_preserves_mcp_servers_base_url() { - let config_toml = r#"model_provider = "azure" -model = "gpt-4" -disable_response_storage = true - -[model_providers.azure] -name = "Azure OpenAI" -base_url = "https://azure.example/v1" -wire_api = "responses" - -[mcp_servers.my_server] -base_url = "http://localhost:8080" -"#; - - let settings = json!({ "config": config_toml }); - let extracted = ProviderService::extract_codex_common_config(&settings) - .expect("extract_codex_common_config should succeed"); - - assert!( - !extracted - .lines() - .any(|line| line.trim_start().starts_with("model_provider")), - "should remove top-level model_provider" - ); - assert!( - !extracted - .lines() - .any(|line| line.trim_start().starts_with("model =")), - "should remove top-level model" - ); - assert!( - !extracted.contains("[model_providers"), - "should remove entire model_providers table" - ); - assert!( - extracted.contains("http://localhost:8080"), - "should keep mcp_servers.* base_url" - ); - } } impl ProviderService { @@ -191,7 +151,7 @@ impl ProviderService { state .db .set_current_provider(app_type.as_str(), &provider.id)?; - write_live_snapshot(&app_type, &provider)?; + write_live_partial(&app_type, &provider)?; } Ok(true) @@ -272,7 +232,7 @@ impl ProviderService { ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; } else { - write_live_snapshot(&app_type, &provider)?; + write_live_partial(&app_type, &provider)?; // Sync MCP McpService::sync_all_enabled(state)?; } @@ -578,7 +538,9 @@ 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; + // Only extract key fields from live config for backfill + current_provider.settings_config = + backfill_key_fields(&app_type, &live_config); if let Err(e) = state.db.save_provider(app_type.as_str(), ¤t_provider) { @@ -602,8 +564,8 @@ impl ProviderService { state.db.set_current_provider(app_type.as_str(), id)?; } - // Sync to live (write_gemini_live handles security flag internally for Gemini) - write_live_snapshot(&app_type, provider)?; + // Sync to live (partial merge: only key fields, preserving user settings) + write_live_partial(&app_type, provider)?; // Sync MCP McpService::sync_all_enabled(state)?; @@ -616,222 +578,6 @@ impl ProviderService { sync_current_to_live(state) } - /// Extract common config snippet from current provider - /// - /// Extracts the current provider's configuration and removes provider-specific fields - /// (API keys, model settings, endpoints) to create a reusable common config snippet. - pub fn extract_common_config_snippet( - state: &AppState, - app_type: AppType, - ) -> Result { - // Get current provider - let current_id = Self::current(state, app_type.clone())?; - if current_id.is_empty() { - return Err(AppError::Message("No current provider".to_string())); - } - - let providers = state.db.get_all_providers(app_type.as_str())?; - let provider = providers - .get(¤t_id) - .ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?; - - match app_type { - AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), - AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), - AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), - AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), - AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), - } - } - - /// Extract common config snippet from a config value (e.g. editor content). - pub fn extract_common_config_snippet_from_settings( - app_type: AppType, - settings_config: &Value, - ) -> Result { - match app_type { - AppType::Claude => Self::extract_claude_common_config(settings_config), - AppType::Codex => Self::extract_codex_common_config(settings_config), - AppType::Gemini => Self::extract_gemini_common_config(settings_config), - AppType::OpenCode => Self::extract_opencode_common_config(settings_config), - AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), - } - } - - /// Extract common config for Claude (JSON format) - fn extract_claude_common_config(settings: &Value) -> Result { - let mut config = settings.clone(); - - // Fields to exclude from common config - const ENV_EXCLUDES: &[&str] = &[ - // Auth - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", - // Models (5 fields) - "ANTHROPIC_MODEL", - "ANTHROPIC_REASONING_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - // Endpoint - "ANTHROPIC_BASE_URL", - ]; - - const TOP_LEVEL_EXCLUDES: &[&str] = &[ - "apiBaseUrl", - // Legacy model fields - "primaryModel", - "smallFastModel", - ]; - - // Remove env fields - if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { - for key in ENV_EXCLUDES { - env.remove(*key); - } - // If env is empty after removal, remove the env object itself - if env.is_empty() { - config.as_object_mut().map(|obj| obj.remove("env")); - } - } - - // Remove top-level fields - if let Some(obj) = config.as_object_mut() { - for key in TOP_LEVEL_EXCLUDES { - obj.remove(*key); - } - } - - // Check if result is empty - if config.as_object().is_none_or(|obj| obj.is_empty()) { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&config) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - - /// Extract common config for Codex (TOML format) - fn extract_codex_common_config(settings: &Value) -> Result { - // Codex config is stored as { "auth": {...}, "config": "toml string" } - let config_toml = settings - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if config_toml.is_empty() { - return Ok(String::new()); - } - - let mut doc = config_toml - .parse::() - .map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?; - - // Remove provider-specific fields. - let root = doc.as_table_mut(); - root.remove("model"); - root.remove("model_provider"); - // Legacy/alt formats might use a top-level base_url. - root.remove("base_url"); - - // Remove entire model_providers table (provider-specific configuration) - root.remove("model_providers"); - - // Clean up multiple empty lines (keep at most one blank line). - let mut cleaned = String::new(); - let mut blank_run = 0usize; - for line in doc.to_string().lines() { - if line.trim().is_empty() { - blank_run += 1; - if blank_run <= 1 { - cleaned.push('\n'); - } - continue; - } - blank_run = 0; - cleaned.push_str(line); - cleaned.push('\n'); - } - - Ok(cleaned.trim().to_string()) - } - - /// Extract common config for Gemini (JSON format) - /// - /// Extracts `.env` values while excluding provider-specific credentials: - /// - GOOGLE_GEMINI_BASE_URL - /// - GEMINI_API_KEY - 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(); - if let Some(env) = env { - for (key, value) in env { - if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" { - continue; - } - let Value::String(v) = value else { - continue; - }; - let trimmed = v.trim(); - if !trimmed.is_empty() { - snippet.insert(key.to_string(), Value::String(trimmed.to_string())); - } - } - } - - if snippet.is_empty() { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&Value::Object(snippet)) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - - /// Extract common config for OpenCode (JSON format) - fn extract_opencode_common_config(settings: &Value) -> Result { - // OpenCode uses a different config structure with npm, options, models - // For common config, we exclude provider-specific fields like apiKey - let mut config = settings.clone(); - - // Remove provider-specific fields - if let Some(obj) = config.as_object_mut() { - if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) { - options.remove("apiKey"); - options.remove("baseURL"); - } - // Keep npm and models as they might be common - } - - if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&config) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - - /// Extract common config for OpenClaw (JSON format) - fn extract_openclaw_common_config(settings: &Value) -> Result { - // OpenClaw uses a different config structure with baseUrl, apiKey, api, models - // For common config, we exclude provider-specific fields like apiKey - let mut config = settings.clone(); - - // Remove provider-specific fields - if let Some(obj) = config.as_object_mut() { - obj.remove("apiKey"); - obj.remove("baseUrl"); - // Keep api and models as they might be common - } - - if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&config) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 328848926..46fcb60ce 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -126,12 +126,9 @@ command = "say" .get("config") .and_then(|v| v.as_str()) .unwrap_or_default(); - // 供应商配置应该包含在 live 文件中 - // 注意:live 文件还会包含 MCP 同步后的内容 - assert!( - config_text.contains("mcp_servers.latest"), - "live file should contain provider's original config" - ); + // With partial merge, only key fields (model_provider, model, model_providers) + // are written to the live file. MCP servers are synced separately. + // The provider's stored config should still contain mcp_servers.latest. assert!( new_config_text.contains("mcp_servers.latest"), "provider snapshot should contain provider's original config" @@ -268,11 +265,22 @@ fn switch_provider_updates_claude_live_and_state() { let legacy_provider = providers .get("old-provider") .expect("legacy provider still exists"); - // 回填机制:切换前会将 live 配置回填到当前供应商 - // 这保护了用户在 live 文件中的手动修改 + // Backfill mechanism: before switching, the live config's key fields are + // backfilled to the current provider. With partial merge, only key fields + // (auth, model, endpoint) are extracted — non-key fields like workspace + // are NOT included in the backfill. assert_eq!( - legacy_provider.settings_config, legacy_live, - "previous provider should be backfilled with live config" + legacy_provider + .settings_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("legacy-key"), + "previous provider should be backfilled with live auth key" + ); + assert!( + legacy_provider.settings_config.get("workspace").is_none(), + "backfill should NOT include non-key fields like workspace" ); let new_provider = providers.get("new-provider").expect("new provider exists"); diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 6288bddd0..4d02615d6 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -414,9 +414,19 @@ fn provider_service_switch_claude_updates_live_and_state() { let legacy_provider = providers .get("old-provider") .expect("legacy provider still exists"); + // With partial merge backfill, only key fields are extracted from live config assert_eq!( - legacy_provider.settings_config, legacy_live, - "previous provider should receive backfilled live config" + legacy_provider + .settings_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("legacy-key"), + "previous provider should receive backfilled auth key" + ); + assert!( + legacy_provider.settings_config.get("workspace").is_none(), + "backfill should NOT include non-key fields like workspace" ); } diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx deleted file mode 100644 index e8ddb8664..000000000 --- a/src/components/providers/forms/CodexCommonConfigModal.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Save, Download, Loader2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { FullScreenPanel } from "@/components/common/FullScreenPanel"; -import { Button } from "@/components/ui/button"; -import JsonEditor from "@/components/JsonEditor"; - -interface CodexCommonConfigModalProps { - isOpen: boolean; - onClose: () => void; - value: string; - onChange: (value: string) => void; - error?: string; - onExtract?: () => void; - isExtracting?: boolean; -} - -/** - * CodexCommonConfigModal - Common Codex configuration editor modal - * Allows editing of common TOML configuration shared across providers - */ -export const CodexCommonConfigModal: React.FC = ({ - isOpen, - onClose, - value, - onChange, - error, - onExtract, - isExtracting, -}) => { - const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - - return ( - - {onExtract && ( - - )} - - - - } - > -
-

- {t("codexConfig.commonConfigHint")} -

- - - - {error && ( -

{error}

- )} -
-
- ); -}; diff --git a/src/components/providers/forms/CodexConfigEditor.tsx b/src/components/providers/forms/CodexConfigEditor.tsx index 42ba3cae3..cd65d8d01 100644 --- a/src/components/providers/forms/CodexConfigEditor.tsx +++ b/src/components/providers/forms/CodexConfigEditor.tsx @@ -1,6 +1,5 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections"; -import { CodexCommonConfigModal } from "./CodexCommonConfigModal"; interface CodexConfigEditorProps { authValue: string; @@ -13,23 +12,9 @@ interface CodexConfigEditorProps { onAuthBlur?: () => void; - useCommonConfig: boolean; - - onCommonConfigToggle: (checked: boolean) => void; - - commonConfigSnippet: string; - - onCommonConfigSnippetChange: (value: string) => void; - - commonConfigError: string; - authError: string; - configError: string; // config.toml 错误提示 - - onExtract?: () => void; - - isExtracting?: boolean; + configError: string; } const CodexConfigEditor: React.FC = ({ @@ -38,25 +23,9 @@ const CodexConfigEditor: React.FC = ({ onAuthChange, onConfigChange, onAuthBlur, - useCommonConfig, - onCommonConfigToggle, - commonConfigSnippet, - onCommonConfigSnippetChange, - commonConfigError, authError, configError, - onExtract, - isExtracting, }) => { - const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); - - // Auto-open common config modal if there's an error - useEffect(() => { - if (commonConfigError && !isCommonConfigModalOpen) { - setIsCommonConfigModalOpen(true); - } - }, [commonConfigError, isCommonConfigModalOpen]); - return (
{/* Auth JSON Section */} @@ -71,23 +40,8 @@ const CodexConfigEditor: React.FC = ({ setIsCommonConfigModalOpen(true)} - commonConfigError={commonConfigError} configError={configError} /> - - {/* Common Config Modal */} - setIsCommonConfigModalOpen(false)} - value={commonConfigSnippet} - onChange={onCommonConfigSnippetChange} - error={commonConfigError} - onExtract={onExtract} - isExtracting={isExtracting} - />
); }; diff --git a/src/components/providers/forms/CodexConfigSections.tsx b/src/components/providers/forms/CodexConfigSections.tsx index 99ada4273..83971f277 100644 --- a/src/components/providers/forms/CodexConfigSections.tsx +++ b/src/components/providers/forms/CodexConfigSections.tsx @@ -78,10 +78,6 @@ export const CodexAuthSection: React.FC = ({ interface CodexConfigSectionProps { value: string; onChange: (value: string) => void; - useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; - onEditCommonConfig: () => void; - commonConfigError?: string; configError?: string; } @@ -91,10 +87,6 @@ interface CodexConfigSectionProps { export const CodexConfigSection: React.FC = ({ value, onChange, - useCommonConfig, - onCommonConfigToggle, - onEditCommonConfig, - commonConfigError, configError, }) => { const { t } = useTranslation(); @@ -117,40 +109,12 @@ export const CodexConfigSection: React.FC = ({ return (
-
- - - -
- -
- -
- - {commonConfigError && ( -

- {commonConfigError} -

- )} + void; - useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; - commonConfigSnippet: string; - onCommonConfigSnippetChange: (value: string) => void; - commonConfigError: string; - onEditClick: () => void; - isModalOpen: boolean; - onModalClose: () => void; - onExtract?: () => void; - isExtracting?: boolean; -} - -export function CommonConfigEditor({ - value, - onChange, - useCommonConfig, - onCommonConfigToggle, - commonConfigSnippet, - onCommonConfigSnippetChange, - commonConfigError, - onEditClick, - isModalOpen, - onModalClose, - onExtract, - isExtracting, -}: CommonConfigEditorProps) { - const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - - // Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync - // (parent uses form.getValues which doesn't trigger re-renders) - const [localValue, setLocalValue] = useState(value); - - useEffect(() => { - setLocalValue(value); - }, [value]); - - const handleLocalChange = useCallback( - (newValue: string) => { - setLocalValue(newValue); - onChange(newValue); - }, - [onChange], - ); - - const toggleStates = useMemo(() => { - try { - const config = JSON.parse(localValue); - return { - hideAttribution: - config?.attribution?.commit === "" && config?.attribution?.pr === "", - alwaysThinking: config?.alwaysThinkingEnabled === true, - teammates: - config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" || - config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1, - }; - } catch { - return { - hideAttribution: false, - alwaysThinking: false, - teammates: false, - }; - } - }, [localValue]); - - const handleToggle = useCallback( - (toggleKey: string, checked: boolean) => { - try { - const config = JSON.parse(localValue || "{}"); - - switch (toggleKey) { - case "hideAttribution": - if (checked) { - config.attribution = { commit: "", pr: "" }; - } else { - delete config.attribution; - } - break; - case "alwaysThinking": - if (checked) { - config.alwaysThinkingEnabled = true; - } else { - delete config.alwaysThinkingEnabled; - } - break; - case "teammates": - if (!config.env) config.env = {}; - if (checked) { - config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; - } else { - delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS; - if (Object.keys(config.env).length === 0) delete config.env; - } - break; - } - - handleLocalChange(JSON.stringify(config, null, 2)); - } catch { - // Don't modify if JSON is invalid - } - }, - [localValue, handleLocalChange], - ); - - return ( - <> -
-
- -
- -
-
-
- -
- {commonConfigError && !isModalOpen && ( -

- {commonConfigError} -

- )} -
- - - -
- -
- - - {onExtract && ( - - )} - - - - } - > -
-

- {t("claudeConfig.commonConfigHint", { - defaultValue: "通用配置片段将合并到所有启用它的供应商配置中", - })} -

- - {commonConfigError && ( -

- {commonConfigError} -

- )} -
-
- - ); -} diff --git a/src/components/providers/forms/GeminiCommonConfigModal.tsx b/src/components/providers/forms/GeminiCommonConfigModal.tsx deleted file mode 100644 index c7413217c..000000000 --- a/src/components/providers/forms/GeminiCommonConfigModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Save, Download, Loader2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { FullScreenPanel } from "@/components/common/FullScreenPanel"; -import { Button } from "@/components/ui/button"; -import JsonEditor from "@/components/JsonEditor"; - -interface GeminiCommonConfigModalProps { - isOpen: boolean; - onClose: () => void; - value: string; - onChange: (value: string) => void; - error?: string; - onExtract?: () => void; - isExtracting?: boolean; -} - -/** - * GeminiCommonConfigModal - Common Gemini configuration editor modal - * Allows editing of common env snippet shared across Gemini providers - */ -export const GeminiCommonConfigModal: React.FC< - GeminiCommonConfigModalProps -> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => { - const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - - return ( - - {onExtract && ( - - )} - - - - } - > -
-

- {t("geminiConfig.commonConfigHint", { - defaultValue: - "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)", - })} -

- - - - {error && ( -

{error}

- )} -
-
- ); -}; diff --git a/src/components/providers/forms/GeminiConfigEditor.tsx b/src/components/providers/forms/GeminiConfigEditor.tsx index 2518b051f..985570ac1 100644 --- a/src/components/providers/forms/GeminiConfigEditor.tsx +++ b/src/components/providers/forms/GeminiConfigEditor.tsx @@ -1,6 +1,5 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections"; -import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal"; interface GeminiConfigEditorProps { envValue: string; @@ -8,15 +7,8 @@ interface GeminiConfigEditorProps { onEnvChange: (value: string) => void; onConfigChange: (value: string) => void; onEnvBlur?: () => void; - useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; - commonConfigSnippet: string; - onCommonConfigSnippetChange: (value: string) => void; - commonConfigError: string; envError: string; configError: string; - onExtract?: () => void; - isExtracting?: boolean; } const GeminiConfigEditor: React.FC = ({ @@ -25,25 +17,9 @@ const GeminiConfigEditor: React.FC = ({ onEnvChange, onConfigChange, onEnvBlur, - useCommonConfig, - onCommonConfigToggle, - commonConfigSnippet, - onCommonConfigSnippetChange, - commonConfigError, envError, configError, - onExtract, - isExtracting, }) => { - const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); - - // Auto-open common config modal if there's an error - useEffect(() => { - if (commonConfigError && !isCommonConfigModalOpen) { - setIsCommonConfigModalOpen(true); - } - }, [commonConfigError, isCommonConfigModalOpen]); - return (
{/* Env Section */} @@ -52,10 +28,6 @@ const GeminiConfigEditor: React.FC = ({ onChange={onEnvChange} onBlur={onEnvBlur} error={envError} - useCommonConfig={useCommonConfig} - onCommonConfigToggle={onCommonConfigToggle} - onEditCommonConfig={() => setIsCommonConfigModalOpen(true)} - commonConfigError={commonConfigError} /> {/* Config JSON Section */} @@ -64,17 +36,6 @@ const GeminiConfigEditor: React.FC = ({ onChange={onConfigChange} configError={configError} /> - - {/* Common Config Modal */} - setIsCommonConfigModalOpen(false)} - value={commonConfigSnippet} - onChange={onCommonConfigSnippetChange} - error={commonConfigError} - onExtract={onExtract} - isExtracting={isExtracting} - />
); }; diff --git a/src/components/providers/forms/GeminiConfigSections.tsx b/src/components/providers/forms/GeminiConfigSections.tsx index 999f986dc..2999fc309 100644 --- a/src/components/providers/forms/GeminiConfigSections.tsx +++ b/src/components/providers/forms/GeminiConfigSections.tsx @@ -7,10 +7,6 @@ interface GeminiEnvSectionProps { onChange: (value: string) => void; onBlur?: () => void; error?: string; - useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; - onEditCommonConfig: () => void; - commonConfigError?: string; } /** @@ -21,10 +17,6 @@ export const GeminiEnvSection: React.FC = ({ onChange, onBlur, error, - useCommonConfig, - onCommonConfigToggle, - onEditCommonConfig, - commonConfigError, }) => { const { t } = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(false); @@ -53,44 +45,12 @@ export const GeminiEnvSection: React.FC = ({ return (
-
- - - -
- -
- -
- - {commonConfigError && ( -

- {commonConfigError} -

- )} + form.setValue("settingsConfig", config), }); - const { - useCommonConfig, - commonConfigSnippet, - commonConfigError, - handleCommonConfigToggle, - handleCommonConfigSnippetChange, - isExtracting: isClaudeExtracting, - handleExtract: handleClaudeExtract, - } = useCommonConfigSnippet({ - settingsConfig: form.getValues("settingsConfig"), - onConfigChange: (config) => form.setValue("settingsConfig", config), - initialData: appId === "claude" ? initialData : undefined, - selectedPresetId: selectedPresetId ?? undefined, - enabled: appId === "claude", - }); - - const { - useCommonConfig: useCodexCommonConfigFlag, - commonConfigSnippet: codexCommonConfigSnippet, - commonConfigError: codexCommonConfigError, - handleCommonConfigToggle: handleCodexCommonConfigToggle, - handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange, - isExtracting: isCodexExtracting, - handleExtract: handleCodexExtract, - } = useCodexCommonConfig({ - codexConfig, - onConfigChange: handleCodexConfigChange, - initialData: appId === "codex" ? initialData : undefined, - selectedPresetId: selectedPresetId ?? undefined, - }); - const { geminiEnv, geminiConfig, @@ -428,7 +393,6 @@ export function ProviderForm({ handleGeminiConfigChange, resetGeminiConfig, envStringToObj, - envObjToString, } = useGeminiConfigState({ initialData: appId === "gemini" ? initialData : undefined, }); @@ -479,23 +443,6 @@ export function ProviderForm({ [originalHandleGeminiModelChange, updateGeminiEnvField], ); - const { - useCommonConfig: useGeminiCommonConfigFlag, - commonConfigSnippet: geminiCommonConfigSnippet, - commonConfigError: geminiCommonConfigError, - handleCommonConfigToggle: handleGeminiCommonConfigToggle, - handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange, - isExtracting: isGeminiExtracting, - handleExtract: handleGeminiExtract, - } = useGeminiCommonConfig({ - envValue: geminiEnv, - onEnvChange: handleGeminiEnvChange, - envStringToObj, - envObjToString, - initialData: appId === "gemini" ? initialData : undefined, - selectedPresetId: selectedPresetId ?? undefined, - }); - // ── Extracted hooks: OpenCode / OMO / OpenClaw ───────────────────── const { @@ -535,8 +482,6 @@ export function ProviderForm({ getSettingsConfig: () => form.getValues("settingsConfig"), }); - const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); - const handleSubmit = (values: ProviderFormData) => { if (appId === "claude" && templateValueEntries.length > 0) { const validation = validateTemplateValues(); @@ -1396,15 +1341,8 @@ export function ProviderForm({ configValue={codexConfig} onAuthChange={setCodexAuth} onConfigChange={handleCodexConfigChange} - useCommonConfig={useCodexCommonConfigFlag} - onCommonConfigToggle={handleCodexCommonConfigToggle} - commonConfigSnippet={codexCommonConfigSnippet} - onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange} - commonConfigError={codexCommonConfigError} authError={codexAuthError} configError={codexConfigError} - onExtract={handleCodexExtract} - isExtracting={isCodexExtracting} /> {settingsConfigErrorField} @@ -1415,17 +1353,8 @@ export function ProviderForm({ configValue={geminiConfig} onEnvChange={handleGeminiEnvChange} onConfigChange={handleGeminiConfigChange} - useCommonConfig={useGeminiCommonConfigFlag} - onCommonConfigToggle={handleGeminiCommonConfigToggle} - commonConfigSnippet={geminiCommonConfigSnippet} - onCommonConfigSnippetChange={ - handleGeminiCommonConfigSnippetChange - } - commonConfigError={geminiCommonConfigError} envError={envError} configError={geminiConfigError} - onExtract={handleGeminiExtract} - isExtracting={isGeminiExtracting} /> {settingsConfigErrorField} @@ -1499,20 +1428,26 @@ export function ProviderForm({ ) : ( <> - form.setValue("settingsConfig", value)} - useCommonConfig={useCommonConfig} - onCommonConfigToggle={handleCommonConfigToggle} - commonConfigSnippet={commonConfigSnippet} - onCommonConfigSnippetChange={handleCommonConfigSnippetChange} - commonConfigError={commonConfigError} - onEditClick={() => setIsCommonConfigModalOpen(true)} - isModalOpen={isCommonConfigModalOpen} - onModalClose={() => setIsCommonConfigModalOpen(false)} - onExtract={handleClaudeExtract} - isExtracting={isClaudeExtracting} - /> +
+ + form.setValue("settingsConfig", value)} + placeholder={`{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "your-api-key-here" + } +}`} + rows={14} + showValidation={true} + language="json" + /> +

+ {t("claudeConfig.fullSettingsHint")} +

+
{settingsConfigErrorField} )} diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 760904339..10f374106 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -6,12 +6,9 @@ export { useCodexConfigState } from "./useCodexConfigState"; export { useApiKeyLink } from "./useApiKeyLink"; export { useCustomEndpoints } from "./useCustomEndpoints"; export { useTemplateValues } from "./useTemplateValues"; -export { useCommonConfigSnippet } from "./useCommonConfigSnippet"; -export { useCodexCommonConfig } from "./useCodexCommonConfig"; export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints"; export { useCodexTomlValidation } from "./useCodexTomlValidation"; export { useGeminiConfigState } from "./useGeminiConfigState"; -export { useGeminiCommonConfig } from "./useGeminiCommonConfig"; export { useOmoModelSource } from "./useOmoModelSource"; export { useOpencodeFormState } from "./useOpencodeFormState"; export { useOmoDraftState } from "./useOmoDraftState"; diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts deleted file mode 100644 index b209003ed..000000000 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { - updateTomlCommonConfigSnippet, - hasTomlCommonConfigSnippet, -} from "@/utils/providerConfigUtils"; -import { configApi } from "@/lib/api"; - -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`; - -interface UseCodexCommonConfigProps { - codexConfig: string; - onConfigChange: (config: string) => void; - initialData?: { - settingsConfig?: Record; - }; - selectedPresetId?: string; -} - -/** - * 管理 Codex 通用配置片段 (TOML 格式) - * 从 config.json 读取和保存,支持从 localStorage 平滑迁移 - */ -export function useCodexCommonConfig({ - codexConfig, - onConfigChange, - initialData, - selectedPresetId, -}: UseCodexCommonConfigProps) { - const { t } = useTranslation(); - const [useCommonConfig, setUseCommonConfig] = useState(false); - const [commonConfigSnippet, setCommonConfigSnippetState] = useState( - DEFAULT_CODEX_COMMON_CONFIG_SNIPPET, - ); - const [commonConfigError, setCommonConfigError] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [isExtracting, setIsExtracting] = useState(false); - - // 用于跟踪是否正在通过通用配置更新 - const isUpdatingFromCommonConfig = useRef(false); - // 用于跟踪新建模式是否已初始化默认勾选 - const hasInitializedNewMode = useRef(false); - - // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 - useEffect(() => { - hasInitializedNewMode.current = false; - }, [selectedPresetId]); - - // 初始化:从 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 && !isLoading) { - const config = - typeof initialData.settingsConfig.config === "string" - ? initialData.settingsConfig.config - : ""; - const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet); - setUseCommonConfig(hasCommon); - } - }, [initialData, commonConfigSnippet, isLoading]); - - // 新建模式:如果通用配置片段存在且有效,默认启用 - useEffect(() => { - // 仅新建模式、加载完成、尚未初始化过 - if (!initialData && !isLoading && !hasInitializedNewMode.current) { - hasInitializedNewMode.current = true; - - // 检查 TOML 片段是否有实质内容(不只是注释和空行) - const lines = commonConfigSnippet.split("\n"); - const hasContent = lines.some((line) => { - const trimmed = line.trim(); - return trimmed && !trimmed.startsWith("#"); - }); - - if (hasContent) { - setUseCommonConfig(true); - // 合并通用配置到当前配置 - const { updatedConfig, error } = updateTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - true, - ); - if (!error) { - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } - } - } - }, [ - initialData, - commonConfigSnippet, - isLoading, - codexConfig, - onConfigChange, - ]); - - // 处理通用配置开关 - const handleCommonConfigToggle = useCallback( - (checked: boolean) => { - const { updatedConfig, error: snippetError } = - updateTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - checked, - ); - - if (snippetError) { - setCommonConfigError(snippetError); - setUseCommonConfig(false); - return; - } - - setCommonConfigError(""); - setUseCommonConfig(checked); - // 标记正在通过通用配置更新 - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - }, - [codexConfig, commonConfigSnippet, onConfigChange], - ); - - // 处理通用配置片段变化 - const handleCommonConfigSnippetChange = useCallback( - (value: string) => { - const previousSnippet = commonConfigSnippet; - setCommonConfigSnippetState(value); - - if (!value.trim()) { - setCommonConfigError(""); - // 保存到 config.json(清空) - configApi.setCommonConfigSnippet("codex", "").catch((error) => { - console.error("保存 Codex 通用配置失败:", error); - setCommonConfigError( - t("codexConfig.saveFailed", { error: String(error) }), - ); - }); - - if (useCommonConfig) { - const { updatedConfig } = updateTomlCommonConfigSnippet( - codexConfig, - previousSnippet, - false, - ); - onConfigChange(updatedConfig); - setUseCommonConfig(false); - } - return; - } - - // TOML 格式校验较为复杂,暂时不做校验,直接清空错误 - setCommonConfigError(""); - // 保存到 config.json - configApi.setCommonConfigSnippet("codex", value).catch((error) => { - console.error("保存 Codex 通用配置失败:", error); - setCommonConfigError( - t("codexConfig.saveFailed", { error: String(error) }), - ); - }); - - // 若当前启用通用配置,需要替换为最新片段 - if (useCommonConfig) { - const removeResult = updateTomlCommonConfigSnippet( - codexConfig, - previousSnippet, - false, - ); - if (removeResult.error) { - setCommonConfigError(removeResult.error); - return; - } - const addResult = updateTomlCommonConfigSnippet( - removeResult.updatedConfig, - value, - true, - ); - - if (addResult.error) { - setCommonConfigError(addResult.error); - return; - } - - // 标记正在通过通用配置更新,避免触发状态检查 - isUpdatingFromCommonConfig.current = true; - onConfigChange(addResult.updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } - }, - [commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange], - ); - - // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) - useEffect(() => { - if (isUpdatingFromCommonConfig.current || isLoading) { - return; - } - const hasCommon = hasTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - ); - setUseCommonConfig(hasCommon); - }, [codexConfig, commonConfigSnippet, isLoading]); - - // 从编辑器当前内容提取通用配置片段 - const handleExtract = useCallback(async () => { - setIsExtracting(true); - setCommonConfigError(""); - - try { - const extracted = await configApi.extractCommonConfigSnippet("codex", { - settingsConfig: JSON.stringify({ - config: codexConfig ?? "", - }), - }); - - if (!extracted || !extracted.trim()) { - setCommonConfigError(t("codexConfig.extractNoCommonConfig")); - return; - } - - // 更新片段状态 - setCommonConfigSnippetState(extracted); - - // 保存到后端 - await configApi.setCommonConfigSnippet("codex", extracted); - } catch (error) { - console.error("提取 Codex 通用配置失败:", error); - setCommonConfigError( - t("codexConfig.extractFailed", { error: String(error) }), - ); - } finally { - setIsExtracting(false); - } - }, [codexConfig, t]); - - return { - useCommonConfig, - commonConfigSnippet, - commonConfigError, - isLoading, - isExtracting, - handleCommonConfigToggle, - handleCommonConfigSnippetChange, - handleExtract, - }; -} diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts deleted file mode 100644 index 2f5114572..000000000 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { - updateCommonConfigSnippet, - hasCommonConfigSnippet, - validateJsonConfig, -} from "@/utils/providerConfigUtils"; -import { configApi } from "@/lib/api"; - -const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet"; -const DEFAULT_COMMON_CONFIG_SNIPPET = `{ - "includeCoAuthoredBy": false -}`; - -interface UseCommonConfigSnippetProps { - settingsConfig: string; - onConfigChange: (config: string) => void; - initialData?: { - settingsConfig?: Record; - }; - selectedPresetId?: string; - /** When false, the hook skips all logic and returns disabled state. Default: true */ - enabled?: boolean; -} - -/** - * 管理 Claude 通用配置片段 - * 从 config.json 读取和保存,支持从 localStorage 平滑迁移 - */ -export function useCommonConfigSnippet({ - settingsConfig, - onConfigChange, - initialData, - selectedPresetId, - enabled = true, -}: UseCommonConfigSnippetProps) { - const { t } = useTranslation(); - const [useCommonConfig, setUseCommonConfig] = useState(false); - const [commonConfigSnippet, setCommonConfigSnippetState] = useState( - DEFAULT_COMMON_CONFIG_SNIPPET, - ); - const [commonConfigError, setCommonConfigError] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [isExtracting, setIsExtracting] = useState(false); - - // 用于跟踪是否正在通过通用配置更新 - const isUpdatingFromCommonConfig = useRef(false); - // 用于跟踪新建模式是否已初始化默认勾选 - const hasInitializedNewMode = useRef(false); - - // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 - useEffect(() => { - if (!enabled) return; - hasInitializedNewMode.current = false; - }, [selectedPresetId, enabled]); - - // 初始化:从 config.json 加载,支持从 localStorage 迁移 - useEffect(() => { - if (!enabled) { - setIsLoading(false); - return; - } - let mounted = true; - - const loadSnippet = async () => { - try { - // 使用统一 API 加载 - const snippet = await configApi.getCommonConfigSnippet("claude"); - - 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("claude", legacySnippet); - if (mounted) { - setCommonConfigSnippetState(legacySnippet); - } - // 清理 localStorage - window.localStorage.removeItem(LEGACY_STORAGE_KEY); - console.log( - "[迁移] Claude 通用配置已从 localStorage 迁移到 config.json", - ); - } - } catch (e) { - console.warn("[迁移] 从 localStorage 迁移失败:", e); - } - } - } - } catch (error) { - console.error("加载通用配置失败:", error); - } finally { - if (mounted) { - setIsLoading(false); - } - } - }; - - loadSnippet(); - - return () => { - mounted = false; - }; - }, [enabled]); - - // 初始化时检查通用配置片段(编辑模式) - useEffect(() => { - if (!enabled) return; - if (initialData && !isLoading) { - const configString = JSON.stringify(initialData.settingsConfig, null, 2); - const hasCommon = hasCommonConfigSnippet( - configString, - commonConfigSnippet, - ); - setUseCommonConfig(hasCommon); - } - }, [enabled, initialData, commonConfigSnippet, isLoading]); - - // 新建模式:如果通用配置片段存在且有效,默认启用 - useEffect(() => { - if (!enabled) return; - // 仅新建模式、加载完成、尚未初始化过 - if (!initialData && !isLoading && !hasInitializedNewMode.current) { - hasInitializedNewMode.current = true; - - // 检查片段是否有实质内容 - try { - const snippetObj = JSON.parse(commonConfigSnippet); - const hasContent = Object.keys(snippetObj).length > 0; - if (hasContent) { - setUseCommonConfig(true); - // 合并通用配置到当前配置 - const { updatedConfig, error } = updateCommonConfigSnippet( - settingsConfig, - commonConfigSnippet, - true, - ); - if (!error) { - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } - } - } catch { - // ignore parse error - } - } - }, [ - enabled, - initialData, - commonConfigSnippet, - isLoading, - settingsConfig, - onConfigChange, - ]); - - // 处理通用配置开关 - const handleCommonConfigToggle = useCallback( - (checked: boolean) => { - const { updatedConfig, error: snippetError } = updateCommonConfigSnippet( - settingsConfig, - commonConfigSnippet, - checked, - ); - - if (snippetError) { - setCommonConfigError(snippetError); - setUseCommonConfig(false); - return; - } - - setCommonConfigError(""); - setUseCommonConfig(checked); - // 标记正在通过通用配置更新 - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - }, - [settingsConfig, commonConfigSnippet, onConfigChange], - ); - - // 处理通用配置片段变化 - const handleCommonConfigSnippetChange = useCallback( - (value: string) => { - const previousSnippet = commonConfigSnippet; - setCommonConfigSnippetState(value); - - if (!value.trim()) { - setCommonConfigError(""); - // 保存到 config.json(清空) - configApi.setCommonConfigSnippet("claude", "").catch((error) => { - console.error("保存通用配置失败:", error); - setCommonConfigError( - t("claudeConfig.saveFailed", { error: String(error) }), - ); - }); - - if (useCommonConfig) { - const { updatedConfig } = updateCommonConfigSnippet( - settingsConfig, - previousSnippet, - false, - ); - onConfigChange(updatedConfig); - setUseCommonConfig(false); - } - return; - } - - // 验证JSON格式 - const validationError = validateJsonConfig(value, "通用配置片段"); - if (validationError) { - setCommonConfigError(validationError); - } else { - setCommonConfigError(""); - // 保存到 config.json - configApi.setCommonConfigSnippet("claude", value).catch((error) => { - console.error("保存通用配置失败:", error); - setCommonConfigError( - t("claudeConfig.saveFailed", { error: String(error) }), - ); - }); - } - - // 若当前启用通用配置且格式正确,需要替换为最新片段 - if (useCommonConfig && !validationError) { - const removeResult = updateCommonConfigSnippet( - settingsConfig, - previousSnippet, - false, - ); - if (removeResult.error) { - setCommonConfigError(removeResult.error); - return; - } - const addResult = updateCommonConfigSnippet( - removeResult.updatedConfig, - value, - true, - ); - - if (addResult.error) { - setCommonConfigError(addResult.error); - return; - } - - // 标记正在通过通用配置更新,避免触发状态检查 - isUpdatingFromCommonConfig.current = true; - onConfigChange(addResult.updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } - }, - [commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange], - ); - - // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) - useEffect(() => { - if (!enabled) return; - if (isUpdatingFromCommonConfig.current || isLoading) { - return; - } - const hasCommon = hasCommonConfigSnippet( - settingsConfig, - commonConfigSnippet, - ); - setUseCommonConfig(hasCommon); - }, [enabled, settingsConfig, commonConfigSnippet, isLoading]); - - // 从编辑器当前内容提取通用配置片段 - const handleExtract = useCallback(async () => { - setIsExtracting(true); - setCommonConfigError(""); - - try { - const extracted = await configApi.extractCommonConfigSnippet("claude", { - settingsConfig, - }); - - if (!extracted || extracted === "{}") { - setCommonConfigError(t("claudeConfig.extractNoCommonConfig")); - return; - } - - // 验证 JSON 格式 - const validationError = validateJsonConfig(extracted, "提取的配置"); - if (validationError) { - setCommonConfigError(validationError); - return; - } - - // 更新片段状态 - setCommonConfigSnippetState(extracted); - - // 保存到后端 - await configApi.setCommonConfigSnippet("claude", extracted); - } catch (error) { - console.error("提取通用配置失败:", error); - setCommonConfigError( - t("claudeConfig.extractFailed", { error: String(error) }), - ); - } finally { - setIsExtracting(false); - } - }, [settingsConfig, t]); - - return { - useCommonConfig, - commonConfigSnippet, - commonConfigError, - isLoading, - isExtracting, - handleCommonConfigToggle, - handleCommonConfigSnippetChange, - handleExtract, - }; -} diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts deleted file mode 100644 index 4627afd8b..000000000 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { configApi } from "@/lib/api"; - -const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet"; -const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}"; - -const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [ - "GOOGLE_GEMINI_BASE_URL", - "GEMINI_API_KEY", -] as const; -type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number]; - -interface UseGeminiCommonConfigProps { - envValue: string; - onEnvChange: (env: string) => void; - envStringToObj: (envString: string) => Record; - envObjToString: (envObj: Record) => string; - initialData?: { - settingsConfig?: Record; - }; - selectedPresetId?: string; -} - -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - -/** - * 管理 Gemini 通用配置片段 (JSON 格式) - * 写入 Gemini 的 .env,但会排除以下敏感字段: - * - GOOGLE_GEMINI_BASE_URL - * - GEMINI_API_KEY - */ -export function useGeminiCommonConfig({ - envValue, - onEnvChange, - envStringToObj, - envObjToString, - initialData, - selectedPresetId, -}: UseGeminiCommonConfigProps) { - const { t } = useTranslation(); - const [useCommonConfig, setUseCommonConfig] = useState(false); - const [commonConfigSnippet, setCommonConfigSnippetState] = useState( - DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET, - ); - const [commonConfigError, setCommonConfigError] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [isExtracting, setIsExtracting] = useState(false); - - // 用于跟踪是否正在通过通用配置更新 - const isUpdatingFromCommonConfig = useRef(false); - // 用于跟踪新建模式是否已初始化默认勾选 - const hasInitializedNewMode = useRef(false); - - // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 - useEffect(() => { - hasInitializedNewMode.current = false; - }, [selectedPresetId]); - - const parseSnippetEnv = useCallback( - ( - snippetString: string, - ): { env: Record; error?: string } => { - const trimmed = snippetString.trim(); - if (!trimmed) { - return { env: {} }; - } - - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return { env: {}, error: t("geminiConfig.invalidJsonFormat") }; - } - - if (!isPlainObject(parsed)) { - return { env: {}, error: t("geminiConfig.invalidJsonFormat") }; - } - - const keys = Object.keys(parsed); - const forbiddenKeys = keys.filter((key) => - GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey), - ); - if (forbiddenKeys.length > 0) { - return { - env: {}, - error: t("geminiConfig.commonConfigInvalidKeys", { - keys: forbiddenKeys.join(", "), - }), - }; - } - - const env: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - if (typeof value !== "string") { - return { - env: {}, - error: t("geminiConfig.commonConfigInvalidValues"), - }; - } - const normalized = value.trim(); - if (!normalized) continue; - env[key] = normalized; - } - - return { env }; - }, - [t], - ); - - const hasEnvCommonConfigSnippet = useCallback( - (envObj: Record, snippetEnv: Record) => { - const entries = Object.entries(snippetEnv); - if (entries.length === 0) return false; - return entries.every(([key, value]) => envObj[key] === value); - }, - [], - ); - - const applySnippetToEnv = useCallback( - (envObj: Record, snippetEnv: Record) => { - const updated = { ...envObj }; - for (const [key, value] of Object.entries(snippetEnv)) { - if (typeof value === "string") { - updated[key] = value; - } - } - return updated; - }, - [], - ); - - const removeSnippetFromEnv = useCallback( - (envObj: Record, snippetEnv: Record) => { - const updated = { ...envObj }; - for (const [key, value] of Object.entries(snippetEnv)) { - if (typeof value === "string" && updated[key] === value) { - delete updated[key]; - } - } - return updated; - }, - [], - ); - - // 初始化:从 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()) { - const parsed = parseSnippetEnv(legacySnippet); - if (parsed.error) { - console.warn( - "[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移", - ); - return; - } - // 迁移到 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; - }; - }, [parseSnippetEnv]); - - // 初始化时检查通用配置片段(编辑模式) - useEffect(() => { - if (initialData?.settingsConfig && !isLoading) { - try { - const env = - isPlainObject(initialData.settingsConfig.env) && - Object.keys(initialData.settingsConfig.env).length > 0 - ? (initialData.settingsConfig.env as Record) - : {}; - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error) return; - const hasCommon = hasEnvCommonConfigSnippet( - env, - parsed.env as Record, - ); - setUseCommonConfig(hasCommon); - } catch { - // ignore parse error - } - } - }, [ - commonConfigSnippet, - hasEnvCommonConfigSnippet, - initialData, - isLoading, - parseSnippetEnv, - ]); - - // 新建模式:如果通用配置片段存在且有效,默认启用 - useEffect(() => { - // 仅新建模式、加载完成、尚未初始化过 - if (!initialData && !isLoading && !hasInitializedNewMode.current) { - hasInitializedNewMode.current = true; - - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error) return; - const hasContent = Object.keys(parsed.env).length > 0; - if (!hasContent) return; - - setUseCommonConfig(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); - } - }, [ - initialData, - isLoading, - commonConfigSnippet, - envValue, - envStringToObj, - envObjToString, - applySnippetToEnv, - onEnvChange, - parseSnippetEnv, - ]); - - // 处理通用配置开关 - const handleCommonConfigToggle = useCallback( - (checked: boolean) => { - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error) { - setCommonConfigError(parsed.error); - setUseCommonConfig(false); - return; - } - if (Object.keys(parsed.env).length === 0) { - setCommonConfigError(t("geminiConfig.noCommonConfigToApply")); - setUseCommonConfig(false); - return; - } - - const currentEnv = envStringToObj(envValue); - const updatedEnvObj = checked - ? applySnippetToEnv(currentEnv, parsed.env) - : removeSnippetFromEnv(currentEnv, parsed.env); - - setCommonConfigError(""); - setUseCommonConfig(checked); - - isUpdatingFromCommonConfig.current = true; - onEnvChange(envObjToString(updatedEnvObj)); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - }, - [ - applySnippetToEnv, - commonConfigSnippet, - envObjToString, - envStringToObj, - envValue, - onEnvChange, - parseSnippetEnv, - removeSnippetFromEnv, - t, - ], - ); - - // 处理通用配置片段变化 - const handleCommonConfigSnippetChange = useCallback( - (value: string) => { - const previousSnippet = commonConfigSnippet; - setCommonConfigSnippetState(value); - - if (!value.trim()) { - setCommonConfigError(""); - // 保存到 config.json(清空) - configApi.setCommonConfigSnippet("gemini", "").catch((error) => { - console.error("保存 Gemini 通用配置失败:", error); - setCommonConfigError( - t("geminiConfig.saveFailed", { error: String(error) }), - ); - }); - - if (useCommonConfig) { - const parsed = parseSnippetEnv(previousSnippet); - if (!parsed.error && Object.keys(parsed.env).length > 0) { - const currentEnv = envStringToObj(envValue); - const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env); - onEnvChange(envObjToString(updatedEnv)); - } - setUseCommonConfig(false); - } - return; - } - - // 校验 JSON 格式 - const parsed = parseSnippetEnv(value); - if (parsed.error) { - setCommonConfigError(parsed.error); - return; - } - - setCommonConfigError(""); - configApi.setCommonConfigSnippet("gemini", value).catch((error) => { - console.error("保存 Gemini 通用配置失败:", error); - setCommonConfigError( - t("geminiConfig.saveFailed", { error: String(error) }), - ); - }); - - // 若当前启用通用配置,需要替换为最新片段 - if (useCommonConfig) { - const prevParsed = parseSnippetEnv(previousSnippet); - const prevEnv = prevParsed.error ? {} : prevParsed.env; - const nextEnv = parsed.env; - const currentEnv = envStringToObj(envValue); - - const withoutOld = - Object.keys(prevEnv).length > 0 - ? removeSnippetFromEnv(currentEnv, prevEnv) - : currentEnv; - const withNew = - Object.keys(nextEnv).length > 0 - ? applySnippetToEnv(withoutOld, nextEnv) - : withoutOld; - - isUpdatingFromCommonConfig.current = true; - onEnvChange(envObjToString(withNew)); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } - }, - [ - applySnippetToEnv, - commonConfigSnippet, - envObjToString, - envStringToObj, - envValue, - onEnvChange, - parseSnippetEnv, - removeSnippetFromEnv, - t, - useCommonConfig, - ], - ); - - // 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) - useEffect(() => { - if (isUpdatingFromCommonConfig.current || isLoading) { - return; - } - const parsed = parseSnippetEnv(commonConfigSnippet); - if (parsed.error) return; - const envObj = envStringToObj(envValue); - setUseCommonConfig( - hasEnvCommonConfigSnippet(envObj, parsed.env as Record), - ); - }, [ - envValue, - commonConfigSnippet, - envStringToObj, - hasEnvCommonConfigSnippet, - isLoading, - parseSnippetEnv, - ]); - - // 从编辑器当前内容提取通用配置片段 - const handleExtract = useCallback(async () => { - setIsExtracting(true); - setCommonConfigError(""); - - try { - const extracted = await configApi.extractCommonConfigSnippet("gemini", { - settingsConfig: JSON.stringify({ - env: envStringToObj(envValue), - }), - }); - - if (!extracted || extracted === "{}") { - setCommonConfigError(t("geminiConfig.extractNoCommonConfig")); - return; - } - - // 验证 JSON 格式 - const parsed = parseSnippetEnv(extracted); - if (parsed.error) { - setCommonConfigError(t("geminiConfig.extractedConfigInvalid")); - return; - } - - // 更新片段状态 - setCommonConfigSnippetState(extracted); - - // 保存到后端 - await configApi.setCommonConfigSnippet("gemini", extracted); - } catch (error) { - console.error("提取 Gemini 通用配置失败:", error); - setCommonConfigError( - t("geminiConfig.extractFailed", { error: String(error) }), - ); - } finally { - setIsExtracting(false); - } - }, [envStringToObj, envValue, parseSnippetEnv, t]); - - return { - useCommonConfig, - commonConfigSnippet, - commonConfigError, - isLoading, - isExtracting, - handleCommonConfigToggle, - handleCommonConfigSnippetChange, - handleExtract, - }; -} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 48833b75c..a4b25a343 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -51,15 +51,7 @@ }, "claudeConfig": { "configLabel": "Claude Code settings.json (JSON) *", - "writeCommonConfig": "Write Common Config", - "editCommonConfig": "Edit Common Config", - "editCommonConfigTitle": "Edit Common Config Snippet", - "commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked", "fullSettingsHint": "Full Claude Code settings.json content", - "extractFromCurrent": "Extract from Editor", - "extractNoCommonConfig": "No common config available to extract from editor", - "extractFailed": "Extract failed: {{error}}", - "saveFailed": "Save failed: {{error}}", "hideAttribution": "Hide AI Attribution", "alwaysThinking": "Extended Thinking", "enableTeammates": "Teammates Mode" @@ -125,11 +117,7 @@ "notes": "Notes", "notesPlaceholder": "e.g., Company dedicated account", "configJson": "Config JSON", - "writeCommonConfig": "Write common config", - "editCommonConfigButton": "Edit common config", "configJsonHint": "Please fill in complete Claude Code configuration", - "editCommonConfigTitle": "Edit common config snippet", - "editCommonConfigHint": "Common config snippet will be merged into all providers that enable it", "addProvider": "Add Provider", "sortUpdated": "Sort order updated", "usageSaved": "Usage query configuration saved", @@ -738,33 +726,15 @@ "authJsonHint": "Codex auth.json configuration content", "configToml": "config.toml (TOML)", "configTomlHint": "Codex config.toml configuration content", - "writeCommonConfig": "Write Common Config", - "editCommonConfig": "Edit Common Config", - "editCommonConfigTitle": "Edit Codex Common Config Snippet", - "commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked", - "apiUrlLabel": "API Request URL", - "extractFromCurrent": "Extract from Editor", - "extractNoCommonConfig": "No common config available to extract from editor", - "extractFailed": "Extract failed: {{error}}", - "saveFailed": "Save failed: {{error}}" + "apiUrlLabel": "API Request URL" }, "geminiConfig": { "envFile": "Environment Variables (.env)", "envFileHint": "Configure Gemini environment variables in .env format", "configJson": "Configuration File (config.json)", "configJsonHint": "Configure Gemini extended parameters in JSON format (optional)", - "writeCommonConfig": "Write Common Config", - "editCommonConfig": "Edit Common Config", - "editCommonConfigTitle": "Edit Gemini Common Config Snippet", - "commonConfigHint": "This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)", - "extractFromCurrent": "Extract from Editor", - "extractNoCommonConfig": "No common config available to extract from editor", - "extractFailed": "Extract failed: {{error}}", - "saveFailed": "Save failed: {{error}}", "extractedConfigInvalid": "Extracted config format is invalid", "invalidJsonFormat": "Common config snippet format error (must be valid JSON)", - "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", "configMergeFailed": "Config merge failed: {{error}}", "configReplaceFailed": "Config replace failed: {{error}}" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 391142f12..87a288ee9 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -51,15 +51,7 @@ }, "claudeConfig": { "configLabel": "Claude Code settings.json (JSON) *", - "writeCommonConfig": "共通設定を書き込む", - "editCommonConfig": "共通設定を編集", - "editCommonConfigTitle": "共通設定スニペットを編集", - "commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます", "fullSettingsHint": "Claude Code の settings.json 全文", - "extractFromCurrent": "編集内容から抽出", - "extractNoCommonConfig": "編集内容から抽出できる共通設定がありません", - "extractFailed": "抽出に失敗しました: {{error}}", - "saveFailed": "保存に失敗しました: {{error}}", "hideAttribution": "AI署名を非表示", "alwaysThinking": "拡張思考", "enableTeammates": "Teammates モード" @@ -125,11 +117,7 @@ "notes": "メモ", "notesPlaceholder": "例: 会社用アカウント", "configJson": "Config JSON", - "writeCommonConfig": "共通設定を書き込む", - "editCommonConfigButton": "共通設定を編集", "configJsonHint": "Claude Code の設定をすべて入力してください", - "editCommonConfigTitle": "共通設定スニペットを編集", - "editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます", "addProvider": "プロバイダーを追加", "sortUpdated": "並び順を更新しました", "usageSaved": "利用状況の設定を保存しました", @@ -738,33 +726,15 @@ "authJsonHint": "Codex の auth.json 設定内容", "configToml": "config.toml (TOML)", "configTomlHint": "Codex の config.toml 設定内容", - "writeCommonConfig": "共通設定を書き込む", - "editCommonConfig": "共通設定を編集", - "editCommonConfigTitle": "Codex 共通設定スニペットを編集", - "commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます", - "apiUrlLabel": "API リクエスト URL", - "extractFromCurrent": "編集内容から抽出", - "extractNoCommonConfig": "編集内容から抽出できる共通設定がありません", - "extractFailed": "抽出に失敗しました: {{error}}", - "saveFailed": "保存に失敗しました: {{error}}" + "apiUrlLabel": "API リクエスト URL" }, "geminiConfig": { "envFile": "環境変数 (.env)", "envFileHint": ".env 形式で Gemini の環境変数を設定", "configJson": "設定ファイル (config.json)", "configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)", - "writeCommonConfig": "共通設定を書き込む", - "editCommonConfig": "共通設定を編集", - "editCommonConfigTitle": "Gemini 共通設定スニペットを編集", - "commonConfigHint": "このスニペットは Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)", - "extractFromCurrent": "編集内容から抽出", - "extractNoCommonConfig": "編集内容から抽出できる共通設定がありません", - "extractFailed": "抽出に失敗しました: {{error}}", - "saveFailed": "保存に失敗しました: {{error}}", "extractedConfigInvalid": "抽出した設定のフォーマットが不正です", "invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)", - "commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})", - "commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります", "noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません", "configMergeFailed": "設定のマージに失敗しました: {{error}}", "configReplaceFailed": "設定の置換に失敗しました: {{error}}" diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 512234c33..f4ffa5444 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -51,15 +51,7 @@ }, "claudeConfig": { "configLabel": "Claude Code 配置 (JSON) *", - "writeCommonConfig": "写入通用配置", - "editCommonConfig": "编辑通用配置", - "editCommonConfigTitle": "编辑通用配置片段", - "commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中", "fullSettingsHint": "完整的 Claude Code settings.json 配置内容", - "extractFromCurrent": "从编辑内容提取", - "extractNoCommonConfig": "当前编辑内容没有可提取的通用配置", - "extractFailed": "提取失败: {{error}}", - "saveFailed": "保存失败: {{error}}", "hideAttribution": "隐藏 AI 署名", "alwaysThinking": "扩展思考", "enableTeammates": "Teammates 模式" @@ -125,11 +117,7 @@ "notes": "备注", "notesPlaceholder": "例如:公司专用账号", "configJson": "配置 JSON", - "writeCommonConfig": "写入通用配置", - "editCommonConfigButton": "编辑通用配置", "configJsonHint": "请填写完整的 Claude Code 配置", - "editCommonConfigTitle": "编辑通用配置片段", - "editCommonConfigHint": "通用配置片段将合并到所有启用它的供应商配置中", "addProvider": "添加供应商", "sortUpdated": "排序已更新", "usageSaved": "用量查询配置已保存", @@ -738,33 +726,15 @@ "authJsonHint": "Codex auth.json 配置内容", "configToml": "config.toml (TOML)", "configTomlHint": "Codex config.toml 配置内容", - "writeCommonConfig": "写入通用配置", - "editCommonConfig": "编辑通用配置", - "editCommonConfigTitle": "编辑 Codex 通用配置片段", - "commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾", - "apiUrlLabel": "API 请求地址", - "extractFromCurrent": "从编辑内容提取", - "extractNoCommonConfig": "当前编辑内容没有可提取的通用配置", - "extractFailed": "提取失败: {{error}}", - "saveFailed": "保存失败: {{error}}" + "apiUrlLabel": "API 请求地址" }, "geminiConfig": { "envFile": "环境变量 (.env)", "envFileHint": "使用 .env 格式配置 Gemini 环境变量", "configJson": "配置文件 (config.json)", "configJsonHint": "使用 JSON 格式配置 Gemini 扩展参数(可选)", - "writeCommonConfig": "写入通用配置", - "editCommonConfig": "编辑通用配置", - "editCommonConfigTitle": "编辑 Gemini 通用配置片段", - "commonConfigHint": "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)", - "extractFromCurrent": "从编辑内容提取", - "extractNoCommonConfig": "当前编辑内容没有可提取的通用配置", - "extractFailed": "提取失败: {{error}}", - "saveFailed": "保存失败: {{error}}", "extractedConfigInvalid": "提取的配置格式错误", "invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)", - "commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})", - "commonConfigInvalidValues": "通用配置片段的值必须是字符串", "noCommonConfigToApply": "通用配置片段为空或没有可写入的内容", "configMergeFailed": "配置合并失败: {{error}}", "configReplaceFailed": "配置替换失败: {{error}}" diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 1fbbb30cf..c4be88862 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -3,27 +3,6 @@ import { invoke } from "@tauri-apps/api/core"; export type AppType = "claude" | "codex" | "gemini" | "omo" | "omo_slim"; -/** - * 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet) - * @returns 通用配置片段(JSON 字符串),如果不存在则返回 null - * @deprecated 使用 getCommonConfigSnippet('claude') 替代 - */ -export async function getClaudeCommonConfigSnippet(): Promise { - return invoke("get_claude_common_config_snippet"); -} - -/** - * 设置 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) @@ -47,31 +26,3 @@ export async function setCommonConfigSnippet( ): Promise { return invoke("set_common_config_snippet", { appType, snippet }); } - -/** - * 提取通用配置片段 - * - * 默认读取当前激活供应商的配置;若传入 `options.settingsConfig`,则从编辑器当前内容提取。 - * 会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。 - * - * @param appType - 应用类型(claude/codex/gemini) - * @param options - 可选:提取来源 - * @returns 提取的通用配置片段(JSON/TOML 字符串) - */ -export type ExtractCommonConfigSnippetOptions = { - settingsConfig?: string; -}; - -export async function extractCommonConfigSnippet( - appType: Exclude, - options?: ExtractCommonConfigSnippetOptions, -): Promise { - const args: Record = { appType }; - const settingsConfig = options?.settingsConfig; - - if (typeof settingsConfig === "string" && settingsConfig.trim()) { - args.settingsConfig = settingsConfig; - } - - return invoke("extract_common_config_snippet", args); -} diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 6658a2333..8f73ec3bf 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -3,86 +3,6 @@ import type { TemplateValueConfig } from "../config/claudeProviderPresets"; import { normalizeQuotes } from "@/utils/textNormalization"; -const isPlainObject = (value: unknown): value is Record => { - return Object.prototype.toString.call(value) === "[object Object]"; -}; - -const deepMerge = ( - target: Record, - source: Record, -): Record => { - Object.entries(source).forEach(([key, value]) => { - if (isPlainObject(value)) { - if (!isPlainObject(target[key])) { - target[key] = {}; - } - deepMerge(target[key], value); - } else { - // 直接覆盖非对象字段(数组/基础类型) - target[key] = value; - } - }); - return target; -}; - -const deepRemove = ( - target: Record, - source: Record, -) => { - Object.entries(source).forEach(([key, value]) => { - if (!(key in target)) return; - - if (isPlainObject(value) && isPlainObject(target[key])) { - // 只移除完全匹配的嵌套属性 - deepRemove(target[key], value); - if (Object.keys(target[key]).length === 0) { - delete target[key]; - } - } else if (isSubset(target[key], value)) { - // 只有当值完全匹配时才删除 - delete target[key]; - } - }); -}; - -const isSubset = (target: any, source: any): boolean => { - if (isPlainObject(source)) { - if (!isPlainObject(target)) return false; - return Object.entries(source).every(([key, value]) => - isSubset(target[key], value), - ); - } - - if (Array.isArray(source)) { - if (!Array.isArray(target) || target.length !== source.length) return false; - return source.every((item, index) => isSubset(target[index], item)); - } - - return target === source; -}; - -// 深拷贝函数 -const deepClone = (obj: T): T => { - if (obj === null || typeof obj !== "object") return obj; - if (obj instanceof Date) return new Date(obj.getTime()) as T; - if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T; - if (obj instanceof Object) { - const clonedObj = {} as T; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - clonedObj[key] = deepClone(obj[key]); - } - } - return clonedObj; - } - return obj; -}; - -export interface UpdateCommonConfigResult { - updatedConfig: string; - error?: string; -} - // 验证JSON配置格式 export const validateJsonConfig = ( value: string, @@ -102,69 +22,6 @@ export const validateJsonConfig = ( } }; -// 将通用配置片段写入/移除 settingsConfig -export const updateCommonConfigSnippet = ( - jsonString: string, - snippetString: string, - enabled: boolean, -): UpdateCommonConfigResult => { - let config: Record; - try { - config = jsonString ? JSON.parse(jsonString) : {}; - } catch (err) { - return { - updatedConfig: jsonString, - error: "配置 JSON 解析失败,无法写入通用配置", - }; - } - - if (!snippetString.trim()) { - return { - updatedConfig: JSON.stringify(config, null, 2), - }; - } - - // 使用统一的验证函数 - const snippetError = validateJsonConfig(snippetString, "通用配置片段"); - if (snippetError) { - return { - updatedConfig: JSON.stringify(config, null, 2), - error: snippetError, - }; - } - - const snippet = JSON.parse(snippetString) as Record; - - if (enabled) { - const merged = deepMerge(deepClone(config), snippet); - return { - updatedConfig: JSON.stringify(merged, null, 2), - }; - } - - const cloned = deepClone(config); - deepRemove(cloned, snippet); - return { - updatedConfig: JSON.stringify(cloned, null, 2), - }; -}; - -// 检查当前配置是否已包含通用配置片段 -export const hasCommonConfigSnippet = ( - jsonString: string, - snippetString: string, -): boolean => { - try { - if (!snippetString.trim()) return false; - const config = jsonString ? JSON.parse(jsonString) : {}; - const snippet = JSON.parse(snippetString); - if (!isPlainObject(snippet)) return false; - return isSubset(config, snippet); - } catch (err) { - return false; - } -}; - // 读取配置中的 API Key(支持 Claude, Codex, Gemini) export const getApiKeyFromConfig = ( jsonString: string, @@ -329,85 +186,6 @@ export const setApiKeyInConfig = ( } }; -// ========== TOML Config Utilities ========== - -export interface UpdateTomlCommonConfigResult { - updatedConfig: string; - error?: string; -} - -// 保存之前的通用配置片段,用于替换操作 -let previousCommonSnippet = ""; - -// 将通用配置片段写入/移除 TOML 配置 -export const updateTomlCommonConfigSnippet = ( - tomlString: string, - snippetString: string, - enabled: boolean, -): UpdateTomlCommonConfigResult => { - if (!snippetString.trim()) { - // 如果片段为空,直接返回原始配置 - return { - updatedConfig: tomlString, - }; - } - - if (enabled) { - // 添加通用配置 - // 先移除旧的通用配置(如果有) - let updatedConfig = tomlString; - if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) { - updatedConfig = tomlString.replace(previousCommonSnippet, ""); - } - - // 在文件末尾添加新的通用配置 - // 确保有适当的换行 - const needsNewline = updatedConfig && !updatedConfig.endsWith("\n"); - updatedConfig = - updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString; - - // 保存当前通用配置片段 - previousCommonSnippet = snippetString; - - return { - updatedConfig: updatedConfig.trim() + "\n", - }; - } else { - // 移除通用配置 - if (tomlString.includes(snippetString)) { - const updatedConfig = tomlString.replace(snippetString, ""); - // 清理多余的空行 - const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim(); - - // 清空保存的状态 - previousCommonSnippet = ""; - - return { - updatedConfig: cleaned ? cleaned + "\n" : "", - }; - } - return { - updatedConfig: tomlString, - }; - } -}; - -// 检查 TOML 配置是否已包含通用配置片段 -export const hasTomlCommonConfigSnippet = ( - tomlString: string, - snippetString: string, -): boolean => { - if (!snippetString.trim()) return false; - - // 简单检查配置是否包含片段内容 - // 去除空白字符后比较,避免格式差异影响 - const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim(); - - return normalizeWhitespace(tomlString).includes( - normalizeWhitespace(snippetString), - ); -}; - // ========== Codex base_url utils ========== // 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)