diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 18e3fbec8..f2bb99eef 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -352,49 +352,6 @@ impl FromStr for AppType { } } -/// 通用配置片段(按应用分治) -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CommonConfigSnippets { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claude: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub codex: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gemini: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opencode: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub openclaw: Option, -} - -impl CommonConfigSnippets { - /// 获取指定应用的通用配置片段 - pub fn get(&self, app: &AppType) -> Option<&String> { - match app { - AppType::Claude => self.claude.as_ref(), - AppType::Codex => self.codex.as_ref(), - AppType::Gemini => self.gemini.as_ref(), - AppType::OpenCode => self.opencode.as_ref(), - AppType::OpenClaw => self.openclaw.as_ref(), - } - } - - /// 设置指定应用的通用配置片段 - pub fn set(&mut self, app: &AppType, snippet: Option) { - match app { - AppType::Claude => self.claude = snippet, - AppType::Codex => self.codex = snippet, - AppType::Gemini => self.gemini = snippet, - AppType::OpenCode => self.opencode = snippet, - AppType::OpenClaw => self.openclaw = snippet, - } - } -} - /// 多应用配置结构(向后兼容) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultiAppConfig { @@ -412,12 +369,6 @@ pub struct MultiAppConfig { /// Claude Skills 配置 #[serde(default)] pub skills: SkillStore, - /// 通用配置片段(按应用分治) - #[serde(default)] - pub common_config_snippets: CommonConfigSnippets, - /// Claude 通用配置片段(旧字段,用于向后兼容迁移) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claude_common_config_snippet: Option, } fn default_version() -> u32 { @@ -439,8 +390,6 @@ impl Default for MultiAppConfig { mcp: McpRoot::default(), prompts: PromptRoot::default(), skills: SkillStore::default(), - common_config_snippets: CommonConfigSnippets::default(), - claude_common_config_snippet: None, } } } @@ -534,15 +483,6 @@ impl MultiAppConfig { updated = true; } - // 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude - if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() { - log::info!( - "迁移通用配置:claude_common_config_snippet → common_config_snippets.claude" - ); - config.common_config_snippets.claude = Some(old_claude_snippet); - updated = true; - } - if updated { log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置..."); config.save()?; diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 8edf15617..ca7759f4c 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -164,38 +164,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { Ok(true) } -#[tauri::command] -pub async fn get_claude_common_config_snippet( - state: tauri::State<'_, crate::store::AppState>, -) -> Result, String> { - state - .db - .get_config_snippet("claude") - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn set_claude_common_config_snippet( - snippet: String, - state: tauri::State<'_, crate::store::AppState>, -) -> Result<(), String> { - if !snippet.trim().is_empty() { - serde_json::from_str::(&snippet).map_err(invalid_json_format_error)?; - } - - let value = if snippet.trim().is_empty() { - None - } else { - Some(snippet) - }; - - state - .db - .set_config_snippet("claude", value) - .map_err(|e| e.to_string())?; - Ok(()) -} - #[tauri::command] pub async fn get_common_config_snippet( app_type: String, @@ -263,26 +231,3 @@ pub async fn set_common_config_snippet( } Ok(()) } - -#[tauri::command] -pub async fn extract_common_config_snippet( - appType: String, - settingsConfig: Option, - state: tauri::State<'_, crate::store::AppState>, -) -> Result { - let app = AppType::from_str(&appType).map_err(|e| e.to_string())?; - - if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) { - let settings: serde_json::Value = - serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?; - - return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings( - app, - &settings, - ) - .map_err(|e| e.to_string()); - } - - crate::services::provider::ProviderService::extract_common_config_snippet(&state, app) - .map_err(|e| e.to_string()) -} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index ef9c1bb0e..590acd7c7 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -97,27 +97,7 @@ pub fn switch_provider( } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { - let imported = ProviderService::import_default_config(state, app_type.clone())?; - - if imported { - // Extract common config snippet (mirrors old startup logic in lib.rs) - if state - .db - .get_config_snippet(app_type.as_str()) - .ok() - .flatten() - .is_none() - { - match ProviderService::extract_common_config_snippet(state, app_type.clone()) { - Ok(snippet) if !snippet.is_empty() && snippet != "{}" => { - let _ = state - .db - .set_config_snippet(app_type.as_str(), Some(snippet)); - } - _ => {} - } - } - } + let imported = ProviderService::import_default_config(state, app_type)?; Ok(imported) } @@ -187,6 +167,12 @@ 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/migration.rs b/src-tauri/src/database/migration.rs index 3c52e7127..f42431587 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -58,9 +58,6 @@ impl Database { // 4. 迁移 Skills Self::migrate_skills(tx, config)?; - // 5. 迁移 Common Config - Self::migrate_common_config(tx, config)?; - Ok(()) } @@ -212,34 +209,4 @@ impl Database { Ok(()) } - - /// 迁移通用配置片段 - fn migrate_common_config( - tx: &rusqlite::Transaction<'_>, - config: &MultiAppConfig, - ) -> Result<(), AppError> { - if let Some(snippet) = &config.common_config_snippets.claude { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_claude", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - if let Some(snippet) = &config.common_config_snippets.codex { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_codex", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - if let Some(snippet) = &config.common_config_snippets.gemini { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_gemini", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - - Ok(()) - } } diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 8e37b3d60..e76dd7d6b 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -513,8 +513,6 @@ fn schema_dry_run_does_not_write_to_disk() { mcp: Default::default(), prompts: Default::default(), skills: Default::default(), - common_config_snippets: Default::default(), - claude_common_config_snippet: None, }; // Dry-run should succeed without any file I/O errors @@ -563,8 +561,6 @@ fn dry_run_validates_schema_compatibility() { mcp: Default::default(), prompts: Default::default(), skills: Default::default(), - common_config_snippets: Default::default(), - claude_common_config_snippet: None, }; // Dry-run should validate the full migration path diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5b023673e..7e550fa04 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -845,12 +845,10 @@ 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 cbeee21d0..0581e7461 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -235,6 +235,11 @@ 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 d354cccb1..d2c8da0c5 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -1,9 +1,5 @@ -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; @@ -82,150 +78,4 @@ 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 da4e15fa6..882191ad7 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -237,6 +237,439 @@ 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().map_or(false, |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. @@ -286,7 +719,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { let providers = state.db.get_all_providers(app_type.as_str())?; if let Some(provider) = providers.get(¤t_id) { - write_live_snapshot(&app_type, provider)?; + write_live_partial(&app_type, provider)?; } // Note: get_effective_current_provider already validates existence, // so providers.get() should always succeed here diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7a35754a4..98c8c7a38 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -27,11 +27,12 @@ pub use live::{ // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; -pub(crate) use live::write_live_snapshot; +pub(crate) use live::write_live_partial; // Internal re-exports use live::{ - remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, + backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live, + write_live_snapshot, }; use usage::validate_usage_script; @@ -84,47 +85,6 @@ mod tests { assert_eq!(api_key, "token"); assert_eq!(base_url, "https://claude.example"); } - - #[test] - fn extract_codex_common_config_preserves_mcp_servers_base_url() { - let config_toml = r#"model_provider = "azure" -model = "gpt-4" -disable_response_storage = true - -[model_providers.azure] -name = "Azure OpenAI" -base_url = "https://azure.example/v1" -wire_api = "responses" - -[mcp_servers.my_server] -base_url = "http://localhost:8080" -"#; - - let settings = json!({ "config": config_toml }); - let extracted = ProviderService::extract_codex_common_config(&settings) - .expect("extract_codex_common_config should succeed"); - - assert!( - !extracted - .lines() - .any(|line| line.trim_start().starts_with("model_provider")), - "should remove top-level model_provider" - ); - assert!( - !extracted - .lines() - .any(|line| line.trim_start().starts_with("model =")), - "should remove top-level model" - ); - assert!( - !extracted.contains("[model_providers"), - "should remove entire model_providers table" - ); - assert!( - extracted.contains("http://localhost:8080"), - "should keep mcp_servers.* base_url" - ); - } } impl ProviderService { @@ -191,7 +151,7 @@ impl ProviderService { state .db .set_current_provider(app_type.as_str(), &provider.id)?; - write_live_snapshot(&app_type, &provider)?; + write_live_partial(&app_type, &provider)?; } Ok(true) @@ -272,7 +232,7 @@ impl ProviderService { ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; } else { - write_live_snapshot(&app_type, &provider)?; + write_live_partial(&app_type, &provider)?; // Sync MCP McpService::sync_all_enabled(state)?; } @@ -578,7 +538,9 @@ impl ProviderService { // Only backfill when switching to a different provider if let Ok(live_config) = read_live_settings(app_type.clone()) { if let Some(mut current_provider) = providers.get(¤t_id).cloned() { - current_provider.settings_config = live_config; + // Only extract key fields from live config for backfill + current_provider.settings_config = + backfill_key_fields(&app_type, &live_config); if let Err(e) = state.db.save_provider(app_type.as_str(), ¤t_provider) { @@ -602,11 +564,8 @@ impl ProviderService { state.db.set_current_provider(app_type.as_str(), id)?; } - // Sync to live (write_gemini_live handles security flag internally for Gemini) - write_live_snapshot(&app_type, provider)?; - - // Sync MCP - McpService::sync_all_enabled(state)?; + // Sync to live (partial merge: only key fields, preserving user settings) + write_live_partial(&app_type, provider)?; Ok(result) } @@ -616,222 +575,6 @@ impl ProviderService { sync_current_to_live(state) } - /// Extract common config snippet from current provider - /// - /// Extracts the current provider's configuration and removes provider-specific fields - /// (API keys, model settings, endpoints) to create a reusable common config snippet. - pub fn extract_common_config_snippet( - state: &AppState, - app_type: AppType, - ) -> Result { - // Get current provider - let current_id = Self::current(state, app_type.clone())?; - if current_id.is_empty() { - return Err(AppError::Message("No current provider".to_string())); - } - - let providers = state.db.get_all_providers(app_type.as_str())?; - let provider = providers - .get(¤t_id) - .ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?; - - match app_type { - AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), - AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), - AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), - AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), - AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), - } - } - - /// Extract common config snippet from a config value (e.g. editor content). - pub fn extract_common_config_snippet_from_settings( - app_type: AppType, - settings_config: &Value, - ) -> Result { - match app_type { - AppType::Claude => Self::extract_claude_common_config(settings_config), - AppType::Codex => Self::extract_codex_common_config(settings_config), - AppType::Gemini => Self::extract_gemini_common_config(settings_config), - AppType::OpenCode => Self::extract_opencode_common_config(settings_config), - AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), - } - } - - /// Extract common config for Claude (JSON format) - fn extract_claude_common_config(settings: &Value) -> Result { - let mut config = settings.clone(); - - // Fields to exclude from common config - const ENV_EXCLUDES: &[&str] = &[ - // Auth - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", - // Models (5 fields) - "ANTHROPIC_MODEL", - "ANTHROPIC_REASONING_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - // Endpoint - "ANTHROPIC_BASE_URL", - ]; - - const TOP_LEVEL_EXCLUDES: &[&str] = &[ - "apiBaseUrl", - // Legacy model fields - "primaryModel", - "smallFastModel", - ]; - - // Remove env fields - if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { - for key in ENV_EXCLUDES { - env.remove(*key); - } - // If env is empty after removal, remove the env object itself - if env.is_empty() { - config.as_object_mut().map(|obj| obj.remove("env")); - } - } - - // Remove top-level fields - if let Some(obj) = config.as_object_mut() { - for key in TOP_LEVEL_EXCLUDES { - obj.remove(*key); - } - } - - // Check if result is empty - if config.as_object().is_none_or(|obj| obj.is_empty()) { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&config) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - - /// Extract common config for Codex (TOML format) - fn extract_codex_common_config(settings: &Value) -> Result { - // Codex config is stored as { "auth": {...}, "config": "toml string" } - let config_toml = settings - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if config_toml.is_empty() { - return Ok(String::new()); - } - - let mut doc = config_toml - .parse::() - .map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?; - - // Remove provider-specific fields. - let root = doc.as_table_mut(); - root.remove("model"); - root.remove("model_provider"); - // Legacy/alt formats might use a top-level base_url. - root.remove("base_url"); - - // Remove entire model_providers table (provider-specific configuration) - root.remove("model_providers"); - - // Clean up multiple empty lines (keep at most one blank line). - let mut cleaned = String::new(); - let mut blank_run = 0usize; - for line in doc.to_string().lines() { - if line.trim().is_empty() { - blank_run += 1; - if blank_run <= 1 { - cleaned.push('\n'); - } - continue; - } - blank_run = 0; - cleaned.push_str(line); - cleaned.push('\n'); - } - - Ok(cleaned.trim().to_string()) - } - - /// Extract common config for Gemini (JSON format) - /// - /// Extracts `.env` values while excluding provider-specific credentials: - /// - GOOGLE_GEMINI_BASE_URL - /// - GEMINI_API_KEY - fn extract_gemini_common_config(settings: &Value) -> Result { - let env = settings.get("env").and_then(|v| v.as_object()); - - let mut snippet = serde_json::Map::new(); - if let Some(env) = env { - for (key, value) in env { - if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" { - continue; - } - let Value::String(v) = value else { - continue; - }; - let trimmed = v.trim(); - if !trimmed.is_empty() { - snippet.insert(key.to_string(), Value::String(trimmed.to_string())); - } - } - } - - if snippet.is_empty() { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&Value::Object(snippet)) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - - /// Extract common config for OpenCode (JSON format) - fn extract_opencode_common_config(settings: &Value) -> Result { - // OpenCode uses a different config structure with npm, options, models - // For common config, we exclude provider-specific fields like apiKey - let mut config = settings.clone(); - - // Remove provider-specific fields - if let Some(obj) = config.as_object_mut() { - if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) { - options.remove("apiKey"); - options.remove("baseURL"); - } - // Keep npm and models as they might be common - } - - if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&config) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - - /// Extract common config for OpenClaw (JSON format) - fn extract_openclaw_common_config(settings: &Value) -> Result { - // OpenClaw uses a different config structure with baseUrl, apiKey, api, models - // For common config, we exclude provider-specific fields like apiKey - let mut config = settings.clone(); - - // Remove provider-specific fields - if let Some(obj) = config.as_object_mut() { - obj.remove("apiKey"); - obj.remove("baseUrl"); - // Keep api and models as they might be common - } - - if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { - return Ok("{}".to_string()); - } - - serde_json::to_string_pretty(&config) - .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) - } - /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. @@ -844,6 +587,11 @@ 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, @@ -939,10 +687,6 @@ 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 5325f4af1..3dc0412dd 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -8,7 +8,7 @@ use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; -use crate::services::provider::write_live_snapshot; +use crate::services::provider::write_live_partial; use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; @@ -1266,7 +1266,7 @@ impl ProxyService { return Ok(false); }; - write_live_snapshot(app_type, provider) + write_live_partial(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 24a1667fb..c9738c7f8 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -2,10 +2,7 @@ use serde_json::json; use std::fs; use std::path::PathBuf; -use cc_switch_lib::{ - get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig, - Provider, ProviderMeta, -}; +use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider}; #[path = "support.rs"] mod support; @@ -13,132 +10,6 @@ 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"); @@ -338,46 +209,6 @@ 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"); @@ -816,107 +647,6 @@ 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 328848926..148c00db6 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -100,9 +100,12 @@ 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.echo-server"), - "config.toml should contain synced MCP servers" + config_text.contains("mcp_servers.legacy"), + "config.toml should preserve existing MCP servers after partial merge" ); let current_id = app_state @@ -126,12 +129,9 @@ command = "say" .get("config") .and_then(|v| v.as_str()) .unwrap_or_default(); - // 供应商配置应该包含在 live 文件中 - // 注意:live 文件还会包含 MCP 同步后的内容 - assert!( - config_text.contains("mcp_servers.latest"), - "live file should contain provider's original config" - ); + // With partial merge, only key fields (model_provider, model, model_providers) + // are written to the live file. MCP servers are synced separately. + // The provider's stored config should still contain mcp_servers.latest. assert!( new_config_text.contains("mcp_servers.latest"), "provider snapshot should contain provider's original config" @@ -268,11 +268,22 @@ fn switch_provider_updates_claude_live_and_state() { let legacy_provider = providers .get("old-provider") .expect("legacy provider still exists"); - // 回填机制:切换前会将 live 配置回填到当前供应商 - // 这保护了用户在 live 文件中的手动修改 + // Backfill mechanism: before switching, the live config's key fields are + // backfilled to the current provider. With partial merge, only key fields + // (auth, model, endpoint) are extracted — non-key fields like workspace + // are NOT included in the backfill. assert_eq!( - legacy_provider.settings_config, legacy_live, - "previous provider should be backfilled with live config" + legacy_provider + .settings_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("legacy-key"), + "previous provider should be backfilled with live auth key" + ); + assert!( + legacy_provider.settings_config.get("workspace").is_none(), + "backfill should NOT include non-key fields like workspace" ); let new_provider = providers.get("new-provider").expect("new provider exists"); diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 6288bddd0..ad664253b 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -112,9 +112,12 @@ 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.echo-server"), - "config.toml should contain synced MCP servers" + config_text.contains("mcp_servers.legacy"), + "config.toml should preserve existing MCP servers after partial merge" ); let current_id = state @@ -143,11 +146,6 @@ 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") @@ -414,9 +412,19 @@ fn provider_service_switch_claude_updates_live_and_state() { let legacy_provider = providers .get("old-provider") .expect("legacy provider still exists"); + // With partial merge backfill, only key fields are extracted from live config assert_eq!( - legacy_provider.settings_config, legacy_live, - "previous provider should receive backfilled live config" + legacy_provider + .settings_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("legacy-key"), + "previous provider should receive backfilled auth key" + ); + assert!( + legacy_provider.settings_config.get("workspace").is_none(), + "backfill should NOT include non-key fields like workspace" ); } diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 699f5aa01..9dd750576 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -10,7 +10,11 @@ import { } from "@/components/ui/select"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField } from "./shared"; -import type { ProviderCategory, ClaudeApiFormat } from "@/types"; +import type { + ProviderCategory, + ClaudeApiFormat, + ClaudeApiKeyField, +} from "@/types"; import type { TemplateValueConfig } from "@/config/claudeProviderPresets"; interface EndpointCandidate { @@ -68,6 +72,10 @@ 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({ @@ -102,6 +110,8 @@ export function ClaudeFormFields({ speedTestEndpoints, apiFormat, onApiFormatChange, + apiKeyField, + onApiKeyFieldChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); @@ -219,6 +229,41 @@ 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 new file mode 100644 index 000000000..87a0d2423 --- /dev/null +++ b/src/components/providers/forms/ClaudeQuickToggles.tsx @@ -0,0 +1,134 @@ +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 deleted file mode 100644 index e8ddb8664..000000000 --- a/src/components/providers/forms/CodexCommonConfigModal.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Save, Download, Loader2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { FullScreenPanel } from "@/components/common/FullScreenPanel"; -import { Button } from "@/components/ui/button"; -import JsonEditor from "@/components/JsonEditor"; - -interface CodexCommonConfigModalProps { - isOpen: boolean; - onClose: () => void; - value: string; - onChange: (value: string) => void; - error?: string; - onExtract?: () => void; - isExtracting?: boolean; -} - -/** - * CodexCommonConfigModal - Common Codex configuration editor modal - * Allows editing of common TOML configuration shared across providers - */ -export const CodexCommonConfigModal: React.FC = ({ - isOpen, - onClose, - value, - onChange, - error, - onExtract, - isExtracting, -}) => { - const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - - return ( - - {onExtract && ( - - )} - - - - } - > -
-

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

- - - - {error && ( -

{error}

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

- {commonConfigError} -

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

- {commonConfigError} -

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

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

- - {commonConfigError && ( -

- {commonConfigError} -

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

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

- - - - {error && ( -

{error}

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

- {commonConfigError} -

- )} + (() => { + 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, @@ -253,6 +300,7 @@ export function ProviderForm({ selectedPresetId, category, appType: appId, + apiKeyField: appId === "claude" ? localApiKeyField : undefined, }); const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({ @@ -276,15 +324,6 @@ 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, @@ -382,37 +421,6 @@ 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, @@ -428,7 +436,6 @@ export function ProviderForm({ handleGeminiConfigChange, resetGeminiConfig, envStringToObj, - envObjToString, } = useGeminiConfigState({ initialData: appId === "gemini" ? initialData : undefined, }); @@ -479,23 +486,6 @@ export function ProviderForm({ [originalHandleGeminiModelChange, updateGeminiEnvField], ); - const { - useCommonConfig: useGeminiCommonConfigFlag, - commonConfigSnippet: geminiCommonConfigSnippet, - commonConfigError: geminiCommonConfigError, - handleCommonConfigToggle: handleGeminiCommonConfigToggle, - handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange, - isExtracting: isGeminiExtracting, - handleExtract: handleGeminiExtract, - } = useGeminiCommonConfig({ - envValue: geminiEnv, - onEnvChange: handleGeminiEnvChange, - envStringToObj, - envObjToString, - initialData: appId === "gemini" ? initialData : undefined, - selectedPresetId: selectedPresetId ?? undefined, - }); - // ── Extracted hooks: OpenCode / OMO / OpenClaw ───────────────────── const { @@ -535,8 +525,6 @@ export function ProviderForm({ getSettingsConfig: () => form.getValues("settingsConfig"), }); - const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); - const handleSubmit = (values: ProviderFormData) => { if (appId === "claude" && templateValueEntries.length > 0) { const validation = validateTemplateValues(); @@ -829,6 +817,10 @@ export function ProviderForm({ appId === "claude" && category !== "official" ? localApiFormat : undefined, + apiKeyField: + appId === "claude" && category !== "official" + ? localApiKeyField + : undefined, }; onSubmit(payload); @@ -1066,6 +1058,12 @@ export function ProviderForm({ setLocalApiFormat("anthropic"); } + if (preset.apiKeyField) { + setLocalApiKeyField(preset.apiKeyField); + } else { + setLocalApiKeyField("ANTHROPIC_AUTH_TOKEN"); + } + form.reset({ name: preset.name, websiteUrl: preset.websiteUrl ?? "", @@ -1270,6 +1268,8 @@ export function ProviderForm({ speedTestEndpoints={speedTestEndpoints} apiFormat={localApiFormat} onApiFormatChange={handleApiFormatChange} + apiKeyField={localApiKeyField} + onApiKeyFieldChange={handleApiKeyFieldChange} /> )} @@ -1396,15 +1396,8 @@ export function ProviderForm({ configValue={codexConfig} onAuthChange={setCodexAuth} onConfigChange={handleCodexConfigChange} - useCommonConfig={useCodexCommonConfigFlag} - onCommonConfigToggle={handleCodexCommonConfigToggle} - commonConfigSnippet={codexCommonConfigSnippet} - onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange} - commonConfigError={codexCommonConfigError} authError={codexAuthError} configError={codexConfigError} - onExtract={handleCodexExtract} - isExtracting={isCodexExtracting} /> {settingsConfigErrorField} @@ -1415,17 +1408,8 @@ export function ProviderForm({ configValue={geminiConfig} onEnvChange={handleGeminiEnvChange} onConfigChange={handleGeminiConfigChange} - useCommonConfig={useGeminiCommonConfigFlag} - onCommonConfigToggle={handleGeminiCommonConfigToggle} - commonConfigSnippet={geminiCommonConfigSnippet} - onCommonConfigSnippetChange={ - handleGeminiCommonConfigSnippetChange - } - commonConfigError={geminiCommonConfigError} envError={envError} configError={geminiConfigError} - onExtract={handleGeminiExtract} - isExtracting={isGeminiExtracting} /> {settingsConfigErrorField} @@ -1499,20 +1483,42 @@ export function ProviderForm({ ) : ( <> - form.setValue("settingsConfig", value)} - useCommonConfig={useCommonConfig} - onCommonConfigToggle={handleCommonConfigToggle} - commonConfigSnippet={commonConfigSnippet} - onCommonConfigSnippetChange={handleCommonConfigSnippetChange} - commonConfigError={commonConfigError} - onEditClick={() => setIsCommonConfigModalOpen(true)} - isModalOpen={isCommonConfigModalOpen} - onModalClose={() => setIsCommonConfigModalOpen(false)} - onExtract={handleClaudeExtract} - isExtracting={isClaudeExtracting} - /> +
+ + { + 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("claudeConfig.fullSettingsHint")} +

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