From 859f413756839c8acbe63089f4dc5e10409a0fbd Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 27 Feb 2026 23:12:34 +0800 Subject: [PATCH] revert: restore full config overwrite + Common Config Snippet (revert 992dda5c) Revert the partial key-field merging refactoring introduced in 992dda5c, along with two dependent commits (24fa8a18, 87604b18) that referenced the now-removed ClaudeQuickToggles component. The whitelist-based partial merge approach had critical issues: - Non-whitelisted custom fields were lost during provider switching - Backfill permanently stripped non-key fields from the database - Whitelist required constant maintenance to track upstream changes This restores the proven "full config overwrite + Common Config Snippet" architecture where each provider stores its complete configuration and shared settings are managed via a separate snippet mechanism. Reverted commits: - 24fa8a18: context-aware JSON editor hint + hide quick toggles - 87604b18: hide ClaudeQuickToggles when creating - 992dda5c: partial key-field merging refactoring Restored: - Full config snapshot write (write_live_snapshot) for Claude/Codex/Gemini - Full config backfill (settings_config = live_config) - Common Config Snippet UI and backend commands - 6 frontend components/hooks for common config editing - configApi barrel export and DB snippet methods Removed: - ClaudeQuickToggles component - write_live_partial / backfill_key_fields / patch_claude_live - All KEY_FIELDS constants --- src-tauri/src/app_config.rs | 60 +++ src-tauri/src/commands/config.rs | 136 +++++ src-tauri/src/commands/provider.rs | 28 +- src-tauri/src/database/dao/settings.rs | 25 + src-tauri/src/database/migration.rs | 33 ++ src-tauri/src/database/tests.rs | 4 + src-tauri/src/lib.rs | 6 +- src-tauri/src/provider.rs | 5 - src-tauri/src/services/config.rs | 150 ++++++ src-tauri/src/services/provider/live.rs | 435 +--------------- src-tauri/src/services/provider/mod.rs | 286 ++++++++++- src-tauri/src/services/proxy.rs | 4 +- src-tauri/tests/import_export_sync.rs | 272 +++++++++- src-tauri/tests/provider_commands.rs | 35 +- src-tauri/tests/provider_service.rs | 26 +- .../providers/EditProviderDialog.tsx | 1 - .../providers/forms/ClaudeFormFields.tsx | 47 +- .../providers/forms/ClaudeQuickToggles.tsx | 134 ----- .../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 | 202 ++++---- src/components/providers/forms/hooks/index.ts | 3 + .../providers/forms/hooks/useApiKeyState.ts | 14 +- .../forms/hooks/useCodexCommonConfig.ts | 312 ++++++++++++ .../forms/hooks/useCommonConfigSnippet.ts | 335 +++++++++++++ .../forms/hooks/useGeminiCommonConfig.ts | 469 ++++++++++++++++++ src/config/claudeProviderPresets.ts | 4 +- src/i18n/locales/en.json | 37 +- src/i18n/locales/ja.json | 37 +- src/i18n/locales/zh.json | 37 +- src/lib/api/config.ts | 77 +++ src/lib/api/index.ts | 1 + src/types.ts | 9 - src/utils/providerConfigUtils.ts | 234 ++++++++- 39 files changed, 3285 insertions(+), 857 deletions(-) delete mode 100644 src/components/providers/forms/ClaudeQuickToggles.tsx create mode 100644 src/components/providers/forms/CodexCommonConfigModal.tsx create mode 100644 src/components/providers/forms/CommonConfigEditor.tsx create mode 100644 src/components/providers/forms/GeminiCommonConfigModal.tsx create mode 100644 src/components/providers/forms/hooks/useCodexCommonConfig.ts create mode 100644 src/components/providers/forms/hooks/useCommonConfigSnippet.ts create mode 100644 src/components/providers/forms/hooks/useGeminiCommonConfig.ts create mode 100644 src/lib/api/config.ts diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index f2bb99eef..18e3fbec8 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -352,6 +352,49 @@ 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 { @@ -369,6 +412,12 @@ 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 { @@ -390,6 +439,8 @@ impl Default for MultiAppConfig { mcp: McpRoot::default(), prompts: PromptRoot::default(), skills: SkillStore::default(), + common_config_snippets: CommonConfigSnippets::default(), + claude_common_config_snippet: None, } } } @@ -483,6 +534,15 @@ 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 54c996695..8edf15617 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt; use crate::app_config::AppType; use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; +use crate::settings; #[tauri::command] pub async fn get_claude_config_status() -> Result { @@ -15,6 +16,18 @@ pub async fn get_claude_config_status() -> Result { use std::str::FromStr; +fn invalid_json_format_error(error: serde_json::Error) -> String { + let lang = settings::get_settings() + .language + .unwrap_or_else(|| "zh".to_string()); + + match lang.as_str() { + "en" => format!("Invalid JSON format: {error}"), + "ja" => format!("JSON形式が無効です: {error}"), + _ => format!("无效的 JSON 格式: {error}"), + } +} + #[tauri::command] pub async fn get_config_status(app: String) -> Result { match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -150,3 +163,126 @@ 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, + state: tauri::State<'_, crate::store::AppState>, +) -> Result, String> { + state + .db + .get_config_snippet(&app_type) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn set_common_config_snippet( + app_type: String, + snippet: String, + state: tauri::State<'_, crate::store::AppState>, +) -> Result<(), String> { + if !snippet.trim().is_empty() { + match app_type.as_str() { + "claude" | "gemini" | "omo" | "omo-slim" => { + serde_json::from_str::(&snippet) + .map_err(invalid_json_format_error)?; + } + "codex" => {} + _ => {} + } + } + + let value = if snippet.trim().is_empty() { + None + } else { + Some(snippet) + }; + + state + .db + .set_config_snippet(&app_type, value) + .map_err(|e| e.to_string())?; + + if app_type == "omo" + && state + .db + .get_current_omo_provider("opencode", "omo") + .map_err(|e| e.to_string())? + .is_some() + { + crate::services::OmoService::write_config_to_file( + state.inner(), + &crate::services::omo::STANDARD, + ) + .map_err(|e| e.to_string())?; + } + if app_type == "omo-slim" + && state + .db + .get_current_omo_provider("opencode", "omo-slim") + .map_err(|e| e.to_string())? + .is_some() + { + crate::services::OmoService::write_config_to_file( + state.inner(), + &crate::services::omo::SLIM, + ) + .map_err(|e| e.to_string())?; + } + 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 590acd7c7..ef9c1bb0e 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -97,7 +97,27 @@ pub fn switch_provider( } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { - let imported = ProviderService::import_default_config(state, app_type)?; + 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)); + } + _ => {} + } + } + } Ok(imported) } @@ -167,12 +187,6 @@ pub fn read_live_provider_settings(app: String) -> Result Result { - ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?; - Ok(true) -} - #[tauri::command] pub async fn test_api_endpoints( urls: Vec, diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 0b51a62e7..e39aa7b31 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -38,6 +38,31 @@ impl Database { Ok(()) } + // --- 通用配置片段 (Common Config Snippet) --- + + /// 获取通用配置片段 + pub fn get_config_snippet(&self, app_type: &str) -> Result, AppError> { + self.get_setting(&format!("common_config_{app_type}")) + } + + /// 设置通用配置片段 + pub fn set_config_snippet( + &self, + app_type: &str, + snippet: Option, + ) -> Result<(), AppError> { + let key = format!("common_config_{app_type}"); + if let Some(value) = snippet { + self.set_setting(&key, &value) + } else { + // 如果为 None 则删除 + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM settings WHERE key = ?1", params![key]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } + // --- 全局出站代理 --- /// 全局代理 URL 的存储键名 diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs index f42431587..3c52e7127 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -58,6 +58,9 @@ impl Database { // 4. 迁移 Skills Self::migrate_skills(tx, config)?; + // 5. 迁移 Common Config + Self::migrate_common_config(tx, config)?; + Ok(()) } @@ -209,4 +212,34 @@ 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 e76dd7d6b..8e37b3d60 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -513,6 +513,8 @@ 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 @@ -561,6 +563,8 @@ 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 b0330ec5c..cea4be170 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -823,8 +823,12 @@ 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::patch_claude_live_settings, commands::get_settings, commands::save_settings, commands::get_rectifier_config, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 0581e7461..cbeee21d0 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -235,11 +235,6 @@ pub struct ProviderMeta { /// - "openai_chat": OpenAI Chat Completions 格式,需要转换 #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")] pub api_format: Option, - /// Claude 认证字段名(仅 Claude 供应商使用) - /// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商 - /// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key - #[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")] - pub api_key_field: Option, } impl ProviderManager { diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index d2c8da0c5..d354cccb1 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -1,5 +1,9 @@ +use super::provider::{sanitize_claude_settings_for_live, ProviderService}; +use crate::app_config::{AppType, MultiAppConfig}; use crate::error::AppError; +use crate::provider::Provider; use chrono::Utc; +use serde_json::Value; use std::fs; use std::path::Path; @@ -78,4 +82,150 @@ impl ConfigService { Ok(()) } + + /// 同步当前供应商到对应的 live 配置。 + pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> { + Self::sync_current_provider_for_app(config, &AppType::Claude)?; + Self::sync_current_provider_for_app(config, &AppType::Codex)?; + Self::sync_current_provider_for_app(config, &AppType::Gemini)?; + Ok(()) + } + + fn sync_current_provider_for_app( + config: &mut MultiAppConfig, + app_type: &AppType, + ) -> Result<(), AppError> { + let (current_id, provider) = { + let manager = match config.get_manager(app_type) { + Some(manager) => manager, + None => return Ok(()), + }; + + if manager.current.is_empty() { + return Ok(()); + } + + let current_id = manager.current.clone(); + let provider = match manager.providers.get(¤t_id) { + Some(provider) => provider.clone(), + None => { + log::warn!( + "当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步" + ); + return Ok(()); + } + }; + (current_id, provider) + }; + + match app_type { + AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?, + AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, + AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, + AppType::OpenCode => { + // OpenCode uses additive mode, no live sync needed + // OpenCode providers are managed directly in the config file + } + AppType::OpenClaw => { + // OpenClaw uses additive mode, no live sync needed + // OpenClaw providers are managed directly in the config file + } + } + + Ok(()) + } + + fn sync_codex_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + let settings = provider.settings_config.as_object().ok_or_else(|| { + AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象")) + })?; + let auth = settings.get("auth").ok_or_else(|| { + AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段")) + })?; + if !auth.is_object() { + return Err(AppError::Config(format!( + "供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象" + ))); + } + let cfg_text = settings.get("config").and_then(Value::as_str); + + crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; + // 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用 + // sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空 + // MCP 的启用/禁用应通过 McpService::toggle_app 进行 + + let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + if let Some(target) = manager.providers.get_mut(provider_id) { + if let Some(obj) = target.settings_config.as_object_mut() { + obj.insert( + "config".to_string(), + serde_json::Value::String(cfg_text_after), + ); + } + } + } + + Ok(()) + } + + fn sync_claude_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + use crate::config::{read_json_file, write_json_file}; + + let settings_path = crate::config::get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let settings = sanitize_claude_settings_for_live(&provider.settings_config); + write_json_file(&settings_path, &settings)?; + + let live_after = read_json_file::(&settings_path)?; + if let Some(manager) = config.get_manager_mut(&AppType::Claude) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + + Ok(()) + } + + fn sync_gemini_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + use crate::gemini_config::{env_to_json, read_gemini_env}; + + ProviderService::write_gemini_live(provider)?; + + // 读回实际写入的内容并更新到配置中(包含 settings.json) + let live_after_env = read_gemini_env()?; + let settings_path = crate::gemini_config::get_gemini_settings_path(); + let live_after_config = if settings_path.exists() { + crate::config::read_json_file(&settings_path)? + } else { + serde_json::json!({}) + }; + let mut live_after = env_to_json(&live_after_env); + if let Some(obj) = live_after.as_object_mut() { + obj.insert("config".to_string(), live_after_config); + } + + if let Some(manager) = config.get_manager_mut(&AppType::Gemini) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + + Ok(()) + } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 0f815c963..da4e15fa6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -237,439 +237,6 @@ 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. -/// When adding a new field here, also update backfill_claude_key_fields(). -const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[ - // --- API auth & endpoint --- - "ANTHROPIC_BASE_URL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_API_KEY", - // --- Model selection --- - "ANTHROPIC_MODEL", - "ANTHROPIC_REASONING_MODEL", - "ANTHROPIC_SMALL_FAST_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "CLAUDE_CODE_SUBAGENT_MODEL", - // --- AWS Bedrock --- - "CLAUDE_CODE_USE_BEDROCK", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", - "AWS_REGION", - "AWS_PROFILE", - "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", - // --- Google Vertex AI --- - "CLAUDE_CODE_USE_VERTEX", - "ANTHROPIC_VERTEX_PROJECT_ID", - "CLOUD_ML_REGION", - // --- Microsoft Foundry --- - "CLAUDE_CODE_USE_FOUNDRY", - // --- Provider behavior --- - "CLAUDE_CODE_MAX_OUTPUT_TOKENS", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", - "API_TIMEOUT_MS", - "DISABLE_PROMPT_CACHING", -]; - -/// Claude top-level key fields (legacy + modern format). -/// When adding a new field here, also update backfill_claude_key_fields(). -const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[ - "apiBaseUrl", // legacy - "primaryModel", // legacy - "smallFastModel", // legacy - "model", // modern - "apiKey", // Bedrock API Key auth -]; - -/// Codex TOML key fields. -/// When adding a new field here, also update backfill_codex_key_fields(). -const CODEX_KEY_TOP_LEVEL: &[&str] = &[ - "model_provider", - "model", - "model_reasoning_effort", - "review_model", - "plan_mode_reasoning_effort", -]; - -/// Gemini env-level key fields. -/// When adding a new field here, also update backfill_gemini_key_fields(). -const GEMINI_KEY_ENV_FIELDS: &[&str] = &[ - "GOOGLE_GEMINI_BASE_URL", - "GEMINI_API_KEY", - "GEMINI_MODEL", - "GOOGLE_API_KEY", -]; - -// ============================================================================ -// 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), - } -} - -/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json. -/// Used for user-level preferences (attribution, thinking, etc.) that are -/// independent of the active provider. -pub fn patch_claude_live(patch: Value) -> Result<(), AppError> { - let path = get_claude_settings_path(); - let mut live = if path.exists() { - read_json_file(&path).unwrap_or_else(|_| json!({})) - } else { - json!({}) - }; - json_merge_patch(&mut live, &patch); - let settings = sanitize_claude_settings_for_live(&live); - write_json_file(&path, &settings)?; - Ok(()) -} - -/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites. -fn json_merge_patch(target: &mut Value, patch: &Value) { - if let Some(patch_obj) = patch.as_object() { - if !target.is_object() { - *target = json!({}); - } - let target_obj = target.as_object_mut().unwrap(); - for (key, value) in patch_obj { - if value.is_null() { - target_obj.remove(key); - } else if value.is_object() { - let entry = target_obj.entry(key.clone()).or_insert(json!({})); - json_merge_patch(entry, value); - // Clean up empty container objects - if entry.as_object().is_some_and(|o| o.is_empty()) { - target_obj.remove(key); - } - } else { - target_obj.insert(key.clone(), value.clone()); - } - } - } -} - -/// 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. @@ -719,7 +286,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_partial(&app_type, provider)?; + write_live_snapshot(&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 59f4b8536..d3931f82d 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -27,12 +27,11 @@ pub use live::{ // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; -pub(crate) use live::write_live_partial; +pub(crate) use live::write_live_snapshot; // Internal re-exports use live::{ - backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live, - write_live_snapshot, + remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, }; use usage::validate_usage_script; @@ -85,6 +84,47 @@ 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 { @@ -152,7 +192,7 @@ impl ProviderService { state .db .set_current_provider(app_type.as_str(), &provider.id)?; - write_live_partial(&app_type, &provider)?; + write_live_snapshot(&app_type, &provider)?; } Ok(true) @@ -233,7 +273,7 @@ impl ProviderService { ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; } else { - write_live_partial(&app_type, &provider)?; + write_live_snapshot(&app_type, &provider)?; // Sync MCP McpService::sync_all_enabled(state)?; } @@ -520,9 +560,7 @@ 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() { - // Only extract key fields from live config for backfill - current_provider.settings_config = - backfill_key_fields(&app_type, &live_config); + current_provider.settings_config = live_config; if let Err(e) = state.db.save_provider(app_type.as_str(), ¤t_provider) { @@ -546,8 +584,11 @@ impl ProviderService { state.db.set_current_provider(app_type.as_str(), id)?; } - // Sync to live (partial merge: only key fields, preserving user settings) - write_live_partial(&app_type, provider)?; + // Sync to live (write_gemini_live handles security flag internally for Gemini) + write_live_snapshot(&app_type, provider)?; + + // Sync MCP + McpService::sync_all_enabled(state)?; Ok(result) } @@ -557,6 +598,222 @@ 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. @@ -569,11 +826,6 @@ impl ProviderService { read_live_settings(app_type) } - /// Patch Claude live settings directly (user-level preferences) - pub fn patch_claude_live(patch: Value) -> Result<(), AppError> { - live::patch_claude_live(patch) - } - /// Get custom endpoints list (re-export) pub fn get_custom_endpoints( state: &AppState, @@ -669,6 +921,10 @@ impl ProviderService { .await } + pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { + write_gemini_live(provider) + } + fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { match app_type { AppType::Claude => { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 3dc0412dd..5325f4af1 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -8,7 +8,7 @@ use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; -use crate::services::provider::write_live_partial; +use crate::services::provider::write_live_snapshot; use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; @@ -1266,7 +1266,7 @@ impl ProxyService { return Ok(false); }; - write_live_partial(app_type, provider) + write_live_snapshot(app_type, provider) .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; Ok(true) diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index c9738c7f8..24a1667fb 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -2,7 +2,10 @@ use serde_json::json; use std::fs; use std::path::PathBuf; -use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider}; +use cc_switch_lib::{ + get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig, + Provider, ProviderMeta, +}; #[path = "support.rs"] mod support; @@ -10,6 +13,132 @@ use support::{ create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex, }; +#[test] +fn sync_claude_provider_writes_live_settings() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + let provider_config = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "test-key", + "ANTHROPIC_BASE_URL": "https://api.test" + }, + "ui": { + "displayName": "Test Provider" + } + }); + + let provider = Provider::with_id( + "prov-1".to_string(), + "Test Claude".to_string(), + provider_config.clone(), + None, + ); + + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.providers.insert("prov-1".to_string(), provider); + manager.current = "prov-1".to_string(); + + ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings"); + + let settings_path = get_claude_settings_path(); + assert!( + settings_path.exists(), + "live settings should be written to {}", + settings_path.display() + ); + + let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file"); + assert_eq!(live_value, provider_config); + + // 确认 SSOT 中的供应商也同步了最新内容 + let updated = config + .get_manager(&AppType::Claude) + .and_then(|m| m.providers.get("prov-1")) + .expect("provider in config"); + assert_eq!(updated.settings_config, provider_config); + + // 额外确认写入位置位于测试 HOME 下 + assert!( + settings_path.starts_with(home), + "settings path {settings_path:?} should reside under test HOME {home:?}" + ); +} + +#[test] +fn sync_codex_provider_writes_auth_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + + // 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发 + // 此测试仅验证 auth.json 和 config.toml 基础配置的写入 + + let provider_config = json!({ + "auth": { + "OPENAI_API_KEY": "codex-key" + }, + "config": r#"base_url = "https://codex.test""# + }); + + let provider = Provider::with_id( + "codex-1".to_string(), + "Codex Test".to_string(), + provider_config.clone(), + None, + ); + + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert("codex-1".to_string(), provider); + manager.current = "codex-1".to_string(); + + ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live"); + + let auth_path = cc_switch_lib::get_codex_auth_path(); + let config_path = cc_switch_lib::get_codex_config_path(); + + assert!( + auth_path.exists(), + "auth.json should exist at {}", + auth_path.display() + ); + assert!( + config_path.exists(), + "config.toml should exist at {}", + config_path.display() + ); + + let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth"); + assert_eq!( + auth_value, + provider_config.get("auth").cloned().expect("auth object") + ); + + let toml_text = fs::read_to_string(&config_path).expect("read config.toml"); + // 验证基础配置正确写入 + assert!( + toml_text.contains("base_url"), + "config.toml should contain base_url from provider config" + ); + + // 当前供应商应同步最新 config 文本 + let manager = config.get_manager(&AppType::Codex).expect("codex manager"); + let synced = manager.providers.get("codex-1").expect("codex provider"); + let synced_cfg = synced + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + assert_eq!(synced_cfg, toml_text); +} + #[test] fn sync_enabled_to_codex_writes_enabled_servers() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -209,6 +338,46 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() { } } +#[test] +fn sync_codex_provider_missing_auth_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + let provider = Provider::with_id( + "codex-missing-auth".to_string(), + "No Auth".to_string(), + json!({ + "config": "model = \"test\"" + }), + None, + ); + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert(provider.id.clone(), provider); + manager.current = "codex-missing-auth".to_string(); + + let err = ConfigService::sync_current_providers_to_live(&mut config) + .expect_err("sync should fail when auth missing"); + match err { + cc_switch_lib::AppError::Config(msg) => { + assert!(msg.contains("auth"), "error message should mention auth"); + } + other => panic!("unexpected error variant: {other:?}"), + } + + // 确认未产生任何 live 配置文件 + assert!( + !cc_switch_lib::get_codex_auth_path().exists(), + "auth.json should not be created on failure" + ); + assert!( + !cc_switch_lib::get_codex_config_path().exists(), + "config.toml should not be created on failure" + ); +} + #[test] fn write_codex_live_atomic_persists_auth_and_config() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -647,6 +816,107 @@ fn create_backup_retains_only_latest_entries() { ); } +#[test] +fn sync_gemini_packycode_sets_security_selected_type() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "packy-1".to_string(); + manager.providers.insert( + "packy-1".to_string(), + Provider::with_id( + "packy-1".to_string(), + "PackyCode".to_string(), + json!({ + "env": { + "GEMINI_API_KEY": "pk-key", + "GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com" + } + }), + Some("https://www.packyapi.com".to_string()), + ), + ); + } + + ConfigService::sync_current_providers_to_live(&mut config) + .expect("syncing gemini live should succeed"); + + // security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json + let gemini_settings = home.join(".gemini").join("settings.json"); + assert!( + gemini_settings.exists(), + "Gemini settings.json should exist at {}", + gemini_settings.display() + ); + + let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json"); + assert_eq!( + value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("gemini-api-key"), + "syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings" + ); +} + +#[test] +fn sync_gemini_google_official_sets_oauth_security() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "google-official".to_string(); + let mut provider = Provider::with_id( + "google-official".to_string(), + "Google".to_string(), + json!({ + "env": {} + }), + Some("https://ai.google.dev".to_string()), + ); + provider.meta = Some(ProviderMeta { + partner_promotion_key: Some("google-official".to_string()), + ..ProviderMeta::default() + }); + manager + .providers + .insert("google-official".to_string(), provider); + } + + ConfigService::sync_current_providers_to_live(&mut config) + .expect("syncing google official gemini should succeed"); + + // security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json + let gemini_settings = home.join(".gemini").join("settings.json"); + assert!( + gemini_settings.exists(), + "Gemini settings should exist at {}", + gemini_settings.display() + ); + let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings"); + let gemini_value: serde_json::Value = + serde_json::from_str(&gemini_raw).expect("parse gemini settings json"); + assert_eq!( + gemini_value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("oauth-personal"), + "Gemini settings should record oauth-personal for Google Official" + ); +} + #[test] fn export_sql_writes_to_target_path() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 148c00db6..328848926 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -100,12 +100,9 @@ command = "say" ); let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml"); - // With partial merge, only key fields (model, provider, model_providers) are - // merged into config.toml. The existing MCP section should be preserved. - // MCP sync from DB is handled separately (at startup or explicit sync). assert!( - config_text.contains("mcp_servers.legacy"), - "config.toml should preserve existing MCP servers after partial merge" + config_text.contains("mcp_servers.echo-server"), + "config.toml should contain synced MCP servers" ); let current_id = app_state @@ -129,9 +126,12 @@ command = "say" .get("config") .and_then(|v| v.as_str()) .unwrap_or_default(); - // 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. + // 供应商配置应该包含在 live 文件中 + // 注意:live 文件还会包含 MCP 同步后的内容 + assert!( + config_text.contains("mcp_servers.latest"), + "live file should contain provider's original config" + ); assert!( new_config_text.contains("mcp_servers.latest"), "provider snapshot should contain provider's original config" @@ -268,22 +268,11 @@ fn switch_provider_updates_claude_live_and_state() { let legacy_provider = providers .get("old-provider") .expect("legacy provider still exists"); - // 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. + // 回填机制:切换前会将 live 配置回填到当前供应商 + // 这保护了用户在 live 文件中的手动修改 assert_eq!( - 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" + legacy_provider.settings_config, legacy_live, + "previous provider should be backfilled with live config" ); 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 ad664253b..6288bddd0 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -112,12 +112,9 @@ command = "say" let config_text = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); - // With partial merge, only key fields (model, provider, model_providers) are - // merged into config.toml. The existing MCP section should be preserved. - // MCP sync from DB is handled separately (at startup or explicit sync). assert!( - config_text.contains("mcp_servers.legacy"), - "config.toml should preserve existing MCP servers after partial merge" + config_text.contains("mcp_servers.echo-server"), + "config.toml should contain synced MCP servers" ); let current_id = state @@ -146,6 +143,11 @@ command = "say" new_config_text.contains("mcp_servers.latest"), "provider config should contain original MCP servers" ); + // live 文件额外包含同步的 MCP 服务器 + assert!( + config_text.contains("mcp_servers.echo-server"), + "live config should include synced MCP servers" + ); let legacy = providers .get("old-provider") @@ -412,19 +414,9 @@ 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 - .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" + legacy_provider.settings_config, legacy_live, + "previous provider should receive backfilled live config" ); } diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index bf7853235..672848134 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -192,7 +192,6 @@ export function EditProviderDialog({ onCancel={() => onOpenChange(false)} initialData={initialData} showButtons={false} - isCurrent={liveSettings !== null} /> ); diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index f9ac424c3..21149c919 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -10,11 +10,7 @@ import { } from "@/components/ui/select"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField } from "./shared"; -import type { - ProviderCategory, - ClaudeApiFormat, - ClaudeApiKeyField, -} from "@/types"; +import type { ProviderCategory, ClaudeApiFormat } from "@/types"; import type { TemplateValueConfig } from "@/config/claudeProviderPresets"; interface EndpointCandidate { @@ -72,10 +68,6 @@ interface ClaudeFormFieldsProps { // API Format (for third-party providers that use OpenAI Chat Completions format) apiFormat: ClaudeApiFormat; onApiFormatChange: (format: ClaudeApiFormat) => void; - - // Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY) - apiKeyField: ClaudeApiKeyField; - onApiKeyFieldChange: (field: ClaudeApiKeyField) => void; } export function ClaudeFormFields({ @@ -110,8 +102,6 @@ export function ClaudeFormFields({ speedTestEndpoints, apiFormat, onApiFormatChange, - apiKeyField, - onApiKeyFieldChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); @@ -229,41 +219,6 @@ export function ClaudeFormFields({ )} - {/* 认证字段选择(仅非官方供应商显示) */} - {shouldShowModelSelector && ( -
- - {t("providerForm.authField", { defaultValue: "认证字段" })} - - -

- {t("providerForm.authFieldHint", { - defaultValue: - "大多数第三方供应商使用 Auth Token;少数供应商需要 API Key", - })} -

-
- )} - {/* 模型选择器 */} {shouldShowModelSelector && (
diff --git a/src/components/providers/forms/ClaudeQuickToggles.tsx b/src/components/providers/forms/ClaudeQuickToggles.tsx deleted file mode 100644 index 87a0d2423..000000000 --- a/src/components/providers/forms/ClaudeQuickToggles.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { invoke } from "@tauri-apps/api/core"; -import { Checkbox } from "@/components/ui/checkbox"; - -type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates"; - -interface ClaudeQuickTogglesProps { - /** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */ - onPatchApplied?: (patch: Record) => void; -} - -const defaultStates: Record = { - hideAttribution: false, - alwaysThinking: false, - enableTeammates: false, -}; - -function deriveStates( - cfg: Record, -): Record { - const env = cfg?.env as Record | undefined; - const attr = cfg?.attribution as Record | undefined; - return { - hideAttribution: attr?.commit === "" && attr?.pr === "", - alwaysThinking: cfg?.alwaysThinkingEnabled === true, - enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1", - }; -} - -/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */ -function jsonMergePatch( - target: Record, - patch: Record, -) { - for (const [key, value] of Object.entries(patch)) { - if (value === null || value === undefined) { - delete target[key]; - } else if (typeof value === "object" && !Array.isArray(value)) { - if ( - typeof target[key] !== "object" || - target[key] === null || - Array.isArray(target[key]) - ) { - target[key] = {}; - } - jsonMergePatch( - target[key] as Record, - value as Record, - ); - if (Object.keys(target[key] as Record).length === 0) { - delete target[key]; - } - } else { - target[key] = value; - } - } -} - -export { jsonMergePatch }; - -export function ClaudeQuickToggles({ - onPatchApplied, -}: ClaudeQuickTogglesProps) { - const { t } = useTranslation(); - const [states, setStates] = useState(defaultStates); - - const readLive = useCallback(async () => { - try { - const cfg = await invoke>( - "read_live_provider_settings", - { app: "claude" }, - ); - setStates(deriveStates(cfg)); - } catch { - // Live file missing or unreadable — show all unchecked - } - }, []); - - useEffect(() => { - readLive(); - }, [readLive]); - - const toggle = useCallback( - async (key: ToggleKey) => { - let patch: Record; - if (key === "hideAttribution") { - patch = states.hideAttribution - ? { attribution: null } - : { attribution: { commit: "", pr: "" } }; - } else if (key === "alwaysThinking") { - patch = states.alwaysThinking - ? { alwaysThinkingEnabled: null } - : { alwaysThinkingEnabled: true }; - } else { - patch = states.enableTeammates - ? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } } - : { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } }; - } - - // Optimistic update - setStates((prev) => ({ ...prev, [key]: !prev[key] })); - - try { - await invoke("patch_claude_live_settings", { patch }); - onPatchApplied?.(patch); - } catch { - // Revert on failure - readLive(); - } - }, - [states, readLive, onPatchApplied], - ); - - return ( -
- {( - [ - ["hideAttribution", "claudeConfig.hideAttribution"], - ["alwaysThinking", "claudeConfig.alwaysThinking"], - ["enableTeammates", "claudeConfig.enableTeammates"], - ] as const - ).map(([key, i18nKey]) => ( - - ))} -
- ); -} diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx new file mode 100644 index 000000000..e8ddb8664 --- /dev/null +++ b/src/components/providers/forms/CodexCommonConfigModal.tsx @@ -0,0 +1,107 @@ +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 cd65d8d01..42ba3cae3 100644 --- a/src/components/providers/forms/CodexConfigEditor.tsx +++ b/src/components/providers/forms/CodexConfigEditor.tsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections"; +import { CodexCommonConfigModal } from "./CodexCommonConfigModal"; interface CodexConfigEditorProps { authValue: string; @@ -12,9 +13,23 @@ interface CodexConfigEditorProps { onAuthBlur?: () => void; + useCommonConfig: boolean; + + onCommonConfigToggle: (checked: boolean) => void; + + commonConfigSnippet: string; + + onCommonConfigSnippetChange: (value: string) => void; + + commonConfigError: string; + authError: string; - configError: string; + configError: string; // config.toml 错误提示 + + onExtract?: () => void; + + isExtracting?: boolean; } const CodexConfigEditor: React.FC = ({ @@ -23,9 +38,25 @@ 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 */} @@ -40,8 +71,23 @@ 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 83971f277..99ada4273 100644 --- a/src/components/providers/forms/CodexConfigSections.tsx +++ b/src/components/providers/forms/CodexConfigSections.tsx @@ -78,6 +78,10 @@ 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; } @@ -87,6 +91,10 @@ interface CodexConfigSectionProps { export const CodexConfigSection: React.FC = ({ value, onChange, + useCommonConfig, + onCommonConfigToggle, + onEditCommonConfig, + commonConfigError, configError, }) => { const { t } = useTranslation(); @@ -109,12 +117,40 @@ 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 new file mode 100644 index 000000000..c7413217c --- /dev/null +++ b/src/components/providers/forms/GeminiCommonConfigModal.tsx @@ -0,0 +1,106 @@ +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 985570ac1..2518b051f 100644 --- a/src/components/providers/forms/GeminiConfigEditor.tsx +++ b/src/components/providers/forms/GeminiConfigEditor.tsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections"; +import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal"; interface GeminiConfigEditorProps { envValue: string; @@ -7,8 +8,15 @@ 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 = ({ @@ -17,9 +25,25 @@ 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 */} @@ -28,6 +52,10 @@ const GeminiConfigEditor: React.FC = ({ onChange={onEnvChange} onBlur={onEnvBlur} error={envError} + useCommonConfig={useCommonConfig} + onCommonConfigToggle={onCommonConfigToggle} + onEditCommonConfig={() => setIsCommonConfigModalOpen(true)} + commonConfigError={commonConfigError} /> {/* Config JSON Section */} @@ -36,6 +64,17 @@ 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 2999fc309..999f986dc 100644 --- a/src/components/providers/forms/GeminiConfigSections.tsx +++ b/src/components/providers/forms/GeminiConfigSections.tsx @@ -7,6 +7,10 @@ interface GeminiEnvSectionProps { onChange: (value: string) => void; onBlur?: () => void; error?: string; + useCommonConfig: boolean; + onCommonConfigToggle: (checked: boolean) => void; + onEditCommonConfig: () => void; + commonConfigError?: string; } /** @@ -17,6 +21,10 @@ export const GeminiEnvSection: React.FC = ({ onChange, onBlur, error, + useCommonConfig, + onCommonConfigToggle, + onEditCommonConfig, + commonConfigError, }) => { const { t } = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(false); @@ -45,12 +53,44 @@ export const GeminiEnvSection: React.FC = ({ return (
- +
+ + + +
+ +
+ +
+ + {commonConfigError && ( +

+ {commonConfigError} +

+ )} (() => { - if (appId !== "claude") return "anthropic"; - return initialData?.meta?.apiFormat ?? "anthropic"; - }); - - const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => { - setLocalApiFormat(format); - }, []); - - const [localApiKeyField, setLocalApiKeyField] = useState( - () => { - if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN"; - if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField; - try { - const config = initialData?.settingsConfig; - if ( - config?.env && - (config.env as Record).ANTHROPIC_API_KEY !== - undefined - ) - return "ANTHROPIC_API_KEY"; - } catch {} - return "ANTHROPIC_AUTH_TOKEN"; - }, - ); - - const handleApiKeyFieldChange = useCallback( - (field: ClaudeApiKeyField) => { - setLocalApiKeyField(field); - try { - const config = JSON.parse(form.getValues("settingsConfig") || "{}") as { - env?: Record; - }; - const env = (config.env ?? {}) as Record; - const oldField = - field === "ANTHROPIC_API_KEY" - ? "ANTHROPIC_AUTH_TOKEN" - : "ANTHROPIC_API_KEY"; - if (oldField in env) { - env[field] = env[oldField]; - delete env[oldField]; - config.env = env; - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); - } - } catch {} - }, - [form], - ); - const { apiKey, handleApiKeyChange, @@ -296,7 +247,6 @@ export function ProviderForm({ selectedPresetId, category, appType: appId, - apiKeyField: appId === "claude" ? localApiKeyField : undefined, }); const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({ @@ -320,6 +270,15 @@ export function ProviderForm({ onConfigChange: (config) => form.setValue("settingsConfig", config), }); + const [localApiFormat, setLocalApiFormat] = useState(() => { + if (appId !== "claude") return "anthropic"; + return initialData?.meta?.apiFormat ?? "anthropic"; + }); + + const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => { + setLocalApiFormat(format); + }, []); + const { codexAuth, codexConfig, @@ -417,6 +376,37 @@ export function ProviderForm({ onConfigChange: (config) => 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, @@ -432,6 +422,7 @@ export function ProviderForm({ handleGeminiConfigChange, resetGeminiConfig, envStringToObj, + envObjToString, } = useGeminiConfigState({ initialData: appId === "gemini" ? initialData : undefined, }); @@ -482,6 +473,23 @@ 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 { @@ -520,6 +528,8 @@ 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(); @@ -813,10 +823,6 @@ export function ProviderForm({ appId === "claude" && category !== "official" ? localApiFormat : undefined, - apiKeyField: - appId === "claude" && category !== "official" - ? localApiKeyField - : undefined, }; onSubmit(payload); @@ -1055,12 +1061,6 @@ export function ProviderForm({ setLocalApiFormat("anthropic"); } - if (preset.apiKeyField) { - setLocalApiKeyField(preset.apiKeyField); - } else { - setLocalApiKeyField("ANTHROPIC_AUTH_TOKEN"); - } - form.reset({ name: preset.name, websiteUrl: preset.websiteUrl ?? "", @@ -1266,8 +1266,6 @@ export function ProviderForm({ speedTestEndpoints={speedTestEndpoints} apiFormat={localApiFormat} onApiFormatChange={handleApiFormatChange} - apiKeyField={localApiKeyField} - onApiKeyFieldChange={handleApiKeyFieldChange} /> )} @@ -1394,8 +1392,15 @@ 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} @@ -1406,8 +1411,17 @@ 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} @@ -1477,48 +1491,20 @@ export function ProviderForm({ ) : ( <> -
- - {isEditMode && isCurrent && ( - { - try { - const cfg = JSON.parse( - form.getValues("settingsConfig") || "{}", - ); - jsonMergePatch(cfg, patch); - form.setValue( - "settingsConfig", - JSON.stringify(cfg, null, 2), - ); - } catch { - // invalid JSON in editor — skip mirror - } - }} - /> - )} - form.setValue("settingsConfig", value)} - placeholder={`{ - "env": { - "ANTHROPIC_AUTH_TOKEN": "your-api-key-here" - } -}`} - rows={14} - showValidation={true} - language="json" - /> -

- {t( - isCurrent - ? "claudeConfig.fullSettingsHint" - : "claudeConfig.fragmentSettingsHint", - )} -

-
+ 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} + /> {settingsConfigErrorField} )} diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 10f374106..760904339 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -6,9 +6,12 @@ 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/useApiKeyState.ts b/src/components/providers/forms/hooks/useApiKeyState.ts index 8b4c87626..9101d2e35 100644 --- a/src/components/providers/forms/hooks/useApiKeyState.ts +++ b/src/components/providers/forms/hooks/useApiKeyState.ts @@ -12,7 +12,6 @@ interface UseApiKeyStateProps { selectedPresetId: string | null; category?: ProviderCategory; appType?: string; - apiKeyField?: string; } /** @@ -25,7 +24,6 @@ export function useApiKeyState({ selectedPresetId, category, appType, - apiKeyField, }: UseApiKeyStateProps) { const [apiKey, setApiKey] = useState(() => { if (initialConfig) { @@ -60,7 +58,7 @@ export function useApiKeyState({ initialConfig || "{}", key.trim(), { - // 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段 + // 最佳实践:仅在“新增模式”且“非官方类别”时补齐缺失字段 // - 新增模式:selectedPresetId !== null // - 非官方类别:category !== undefined && category !== "official" // - 官方类别:不创建字段(UI 也会禁用输入框) @@ -70,20 +68,12 @@ export function useApiKeyState({ category !== undefined && category !== "official", appType, - apiKeyField, }, ); onConfigChange(configString); }, - [ - initialConfig, - selectedPresetId, - category, - appType, - apiKeyField, - onConfigChange, - ], + [initialConfig, selectedPresetId, category, appType, onConfigChange], ); const showApiKey = useCallback( diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts new file mode 100644 index 000000000..849908c7d --- /dev/null +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -0,0 +1,312 @@ +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: unknown) => { + 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: unknown) => { + 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 new file mode 100644 index 000000000..80bf37ff1 --- /dev/null +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -0,0 +1,335 @@ +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: unknown) => { + 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: unknown) => { + 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 new file mode 100644 index 000000000..71f761250 --- /dev/null +++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts @@ -0,0 +1,469 @@ +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: unknown) => { + 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: unknown) => { + 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/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 569b6085a..0fdf6698c 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -316,10 +316,12 @@ export const providerPresets: ProviderPreset[] = [ name: "AiHubMix", websiteUrl: "https://aihubmix.com", apiKeyUrl: "https://aihubmix.com", + // 说明:该供应商使用 ANTHROPIC_API_KEY(而非 ANTHROPIC_AUTH_TOKEN) + apiKeyField: "ANTHROPIC_API_KEY", settingsConfig: { env: { ANTHROPIC_BASE_URL: "https://aihubmix.com", - ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_API_KEY: "", }, }, // 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8984d9f9c..6ed487f3d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -51,8 +51,15 @@ }, "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", - "fragmentSettingsHint": "Config snippet for this provider; will be written to settings.json when activated", + "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" @@ -118,7 +125,11 @@ "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", @@ -669,10 +680,6 @@ "apiFormatHint": "Select the input format for the provider's API", "apiFormatAnthropic": "Anthropic Messages (Native)", "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)", - "authField": "Auth Field", - "authFieldAuthToken": "Auth Token (Default)", - "authFieldApiKey": "API Key", - "authFieldHint": "Most third-party providers use Auth Token; a few require API Key", "anthropicDefaultHaikuModel": "Default Haiku Model", "anthropicDefaultSonnetModel": "Default Sonnet Model", "anthropicDefaultOpusModel": "Default Opus Model", @@ -744,15 +751,33 @@ "authJsonHint": "Codex auth.json configuration content", "configToml": "config.toml (TOML)", "configTomlHint": "Codex config.toml configuration content", - "apiUrlLabel": "API Request URL" + "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}}" }, "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 7da0b0e87..0b985a535 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -51,8 +51,15 @@ }, "claudeConfig": { "configLabel": "Claude Code settings.json (JSON) *", + "writeCommonConfig": "共通設定を書き込む", + "editCommonConfig": "共通設定を編集", + "editCommonConfigTitle": "共通設定スニペットを編集", + "commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます", "fullSettingsHint": "Claude Code の settings.json 全文", - "fragmentSettingsHint": "このプロバイダーの設定スニペット。有効化時に settings.json に書き込まれます", + "extractFromCurrent": "編集内容から抽出", + "extractNoCommonConfig": "編集内容から抽出できる共通設定がありません", + "extractFailed": "抽出に失敗しました: {{error}}", + "saveFailed": "保存に失敗しました: {{error}}", "hideAttribution": "AI署名を非表示", "alwaysThinking": "拡張思考", "enableTeammates": "Teammates モード" @@ -118,7 +125,11 @@ "notes": "メモ", "notesPlaceholder": "例: 会社用アカウント", "configJson": "Config JSON", + "writeCommonConfig": "共通設定を書き込む", + "editCommonConfigButton": "共通設定を編集", "configJsonHint": "Claude Code の設定をすべて入力してください", + "editCommonConfigTitle": "共通設定スニペットを編集", + "editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます", "addProvider": "プロバイダーを追加", "sortUpdated": "並び順を更新しました", "usageSaved": "利用状況の設定を保存しました", @@ -669,10 +680,6 @@ "apiFormatHint": "プロバイダー API の入力フォーマットを選択", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", "apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)", - "authField": "認証フィールド", - "authFieldAuthToken": "Auth Token(デフォルト)", - "authFieldApiKey": "API Key", - "authFieldHint": "ほとんどのサードパーティプロバイダーは Auth Token を使用します。一部は API Key が必要です", "anthropicDefaultHaikuModel": "既定 Haiku モデル", "anthropicDefaultSonnetModel": "既定 Sonnet モデル", "anthropicDefaultOpusModel": "既定 Opus モデル", @@ -744,15 +751,33 @@ "authJsonHint": "Codex の auth.json 設定内容", "configToml": "config.toml (TOML)", "configTomlHint": "Codex の config.toml 設定内容", - "apiUrlLabel": "API リクエスト URL" + "writeCommonConfig": "共通設定を書き込む", + "editCommonConfig": "共通設定を編集", + "editCommonConfigTitle": "Codex 共通設定スニペットを編集", + "commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます", + "apiUrlLabel": "API リクエスト URL", + "extractFromCurrent": "編集内容から抽出", + "extractNoCommonConfig": "編集内容から抽出できる共通設定がありません", + "extractFailed": "抽出に失敗しました: {{error}}", + "saveFailed": "保存に失敗しました: {{error}}" }, "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 0f58f920a..d4351bdf6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -51,8 +51,15 @@ }, "claudeConfig": { "configLabel": "Claude Code 配置 (JSON) *", + "writeCommonConfig": "写入通用配置", + "editCommonConfig": "编辑通用配置", + "editCommonConfigTitle": "编辑通用配置片段", + "commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中", "fullSettingsHint": "完整的 Claude Code settings.json 配置内容", - "fragmentSettingsHint": "此供应商的配置片段,激活后将写入 settings.json", + "extractFromCurrent": "从编辑内容提取", + "extractNoCommonConfig": "当前编辑内容没有可提取的通用配置", + "extractFailed": "提取失败: {{error}}", + "saveFailed": "保存失败: {{error}}", "hideAttribution": "隐藏 AI 署名", "alwaysThinking": "扩展思考", "enableTeammates": "Teammates 模式" @@ -118,7 +125,11 @@ "notes": "备注", "notesPlaceholder": "例如:公司专用账号", "configJson": "配置 JSON", + "writeCommonConfig": "写入通用配置", + "editCommonConfigButton": "编辑通用配置", "configJsonHint": "请填写完整的 Claude Code 配置", + "editCommonConfigTitle": "编辑通用配置片段", + "editCommonConfigHint": "通用配置片段将合并到所有启用它的供应商配置中", "addProvider": "添加供应商", "sortUpdated": "排序已更新", "usageSaved": "用量查询配置已保存", @@ -669,10 +680,6 @@ "apiFormatHint": "选择供应商 API 的输入格式", "apiFormatAnthropic": "Anthropic Messages (原生)", "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", - "authField": "认证字段", - "authFieldAuthToken": "Auth Token (默认)", - "authFieldApiKey": "API Key", - "authFieldHint": "大多数第三方供应商使用 Auth Token;少数供应商需要 API Key", "anthropicDefaultHaikuModel": "Haiku 默认模型", "anthropicDefaultSonnetModel": "Sonnet 默认模型", "anthropicDefaultOpusModel": "Opus 默认模型", @@ -744,15 +751,33 @@ "authJsonHint": "Codex auth.json 配置内容", "configToml": "config.toml (TOML)", "configTomlHint": "Codex config.toml 配置内容", - "apiUrlLabel": "API 请求地址" + "writeCommonConfig": "写入通用配置", + "editCommonConfig": "编辑通用配置", + "editCommonConfigTitle": "编辑 Codex 通用配置片段", + "commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾", + "apiUrlLabel": "API 请求地址", + "extractFromCurrent": "从编辑内容提取", + "extractNoCommonConfig": "当前编辑内容没有可提取的通用配置", + "extractFailed": "提取失败: {{error}}", + "saveFailed": "保存失败: {{error}}" }, "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 new file mode 100644 index 000000000..1fbbb30cf --- /dev/null +++ b/src/lib/api/config.ts @@ -0,0 +1,77 @@ +// 配置相关 API +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) + * @returns 通用配置片段(原始字符串),如果不存在则返回 null + */ +export async function getCommonConfigSnippet( + appType: AppType, +): Promise { + return invoke("get_common_config_snippet", { appType }); +} + +/** + * 设置通用配置片段(统一接口) + * @param appType - 应用类型(claude/codex/gemini) + * @param snippet - 通用配置片段(原始字符串) + * @throws 如果格式无效(Claude/Gemini 验证 JSON,Codex 暂不验证) + */ +export async function setCommonConfigSnippet( + appType: AppType, + snippet: string, +): 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/lib/api/index.ts b/src/lib/api/index.ts index 1087b293f..bf00f1617 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -11,5 +11,6 @@ export { proxyApi } from "./proxy"; export { openclawApi } from "./openclaw"; export { sessionsApi } from "./sessions"; export { workspaceApi } from "./workspace"; +export * as configApi from "./config"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; diff --git a/src/types.ts b/src/types.ts index a6c769e61..eda850887 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,10 +146,6 @@ export interface ProviderMeta { // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 apiFormat?: "anthropic" | "openai_chat"; - // Claude 认证字段名(仅 Claude 供应商使用) - // - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商 - // - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key - apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY"; } // Skill 同步方式 @@ -160,11 +156,6 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy"; // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 export type ClaudeApiFormat = "anthropic" | "openai_chat"; -// Claude 认证字段类型 -// - "ANTHROPIC_AUTH_TOKEN": 大多数第三方/聚合供应商使用(默认) -// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key -export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY"; - // 主页面显示的应用配置 export interface VisibleApps { claude: boolean; diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 7d56d4025..059140888 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -3,6 +3,86 @@ 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, @@ -22,6 +102,69 @@ 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, @@ -151,13 +294,9 @@ export const hasApiKeyField = ( export const setApiKeyInConfig = ( jsonString: string, apiKey: string, - options: { - createIfMissing?: boolean; - appType?: string; - apiKeyField?: string; - } = {}, + options: { createIfMissing?: boolean; appType?: string } = {}, ): string => { - const { createIfMissing = false, appType, apiKeyField } = options; + const { createIfMissing = false, appType } = options; try { const config = JSON.parse(jsonString); @@ -197,13 +336,13 @@ export const setApiKeyInConfig = ( return JSON.stringify(config, null, 2); } - // Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 指定的字段名) + // Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段) if ("ANTHROPIC_AUTH_TOKEN" in env) { env.ANTHROPIC_AUTH_TOKEN = apiKey; } else if ("ANTHROPIC_API_KEY" in env) { env.ANTHROPIC_API_KEY = apiKey; } else if (createIfMissing) { - env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey; + env.ANTHROPIC_AUTH_TOKEN = apiKey; } else { return jsonString; } @@ -213,6 +352,85 @@ 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(支持单/双引号)