diff --git a/.gitignore b/.gitignore index e5f979d23..9b145a06d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ copilot-api .history CODEBUDDY.md .github +mainWindow.js diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 4623d4e39..2af5333f6 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -29,6 +29,7 @@ impl McpApps { AppType::OpenCode => self.opencode, AppType::OpenClaw => false, // OpenClaw doesn't support MCP AppType::Hermes => self.hermes, + AppType::ClaudeDesktop => false, } } @@ -41,6 +42,7 @@ impl McpApps { AppType::OpenCode => self.opencode = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore AppType::Hermes => self.hermes = enabled, + AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here } } @@ -96,6 +98,7 @@ impl SkillApps { AppType::OpenCode => self.opencode, AppType::Hermes => self.hermes, AppType::OpenClaw => false, // OpenClaw doesn't support Skills + AppType::ClaudeDesktop => false, } } @@ -108,6 +111,7 @@ impl SkillApps { AppType::OpenCode => self.opencode = enabled, AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore + AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync } } @@ -255,6 +259,14 @@ pub struct McpRoot { /// 旧的分应用存储(v3.6.x 及以前,保留用于迁移) #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub claude: McpConfig, + #[serde( + rename = "claude-desktop", + alias = "claudeDesktop", + alias = "claude_desktop", + default, + skip_serializing_if = "McpConfig::is_empty" + )] + pub claude_desktop: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub codex: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] @@ -277,6 +289,7 @@ impl Default for McpRoot { servers: Some(HashMap::new()), // 旧结构保持空,仅用于反序列化旧配置时的迁移 claude: McpConfig::default(), + claude_desktop: McpConfig::default(), codex: McpConfig::default(), gemini: McpConfig::default(), opencode: McpConfig::default(), @@ -298,6 +311,13 @@ pub struct PromptConfig { pub struct PromptRoot { #[serde(default)] pub claude: PromptConfig, + #[serde( + rename = "claude-desktop", + alias = "claudeDesktop", + alias = "claude_desktop", + default + )] + pub claude_desktop: PromptConfig, #[serde(default)] pub codex: PromptConfig, #[serde(default)] @@ -320,6 +340,12 @@ use crate::provider::ProviderManager; #[serde(rename_all = "lowercase")] pub enum AppType { Claude, + #[serde( + rename = "claude-desktop", + alias = "claude_desktop", + alias = "claudeDesktop" + )] + ClaudeDesktop, Codex, Gemini, OpenCode, @@ -331,6 +357,7 @@ impl AppType { pub fn as_str(&self) -> &str { match self { AppType::Claude => "claude", + AppType::ClaudeDesktop => "claude-desktop", AppType::Codex => "codex", AppType::Gemini => "gemini", AppType::OpenCode => "opencode", @@ -354,6 +381,7 @@ impl AppType { pub fn all() -> impl Iterator { [ AppType::Claude, + AppType::ClaudeDesktop, AppType::Codex, AppType::Gemini, AppType::OpenCode, @@ -371,6 +399,7 @@ impl FromStr for AppType { let normalized = s.trim().to_lowercase(); match normalized.as_str() { "claude" => Ok(AppType::Claude), + "claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop), "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), @@ -378,8 +407,8 @@ impl FromStr for AppType { "hermes" => Ok(AppType::Hermes), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"), - format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."), + format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"), + format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."), )), } } @@ -412,6 +441,7 @@ impl CommonConfigSnippets { pub fn get(&self, app: &AppType) -> Option<&String> { match app { AppType::Claude => self.claude.as_ref(), + AppType::ClaudeDesktop => None, AppType::Codex => self.codex.as_ref(), AppType::Gemini => self.gemini.as_ref(), AppType::OpenCode => self.opencode.as_ref(), @@ -424,6 +454,7 @@ impl CommonConfigSnippets { pub fn set(&mut self, app: &AppType, snippet: Option) { match app { AppType::Claude => self.claude = snippet, + AppType::ClaudeDesktop => {} AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, @@ -466,6 +497,7 @@ impl Default for MultiAppConfig { fn default() -> Self { let mut apps = HashMap::new(); apps.insert("claude".to_string(), ProviderManager::default()); + apps.insert("claude-desktop".to_string(), ProviderManager::default()); apps.insert("codex".to_string(), ProviderManager::default()); apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("opencode".to_string(), ProviderManager::default()); @@ -627,6 +659,7 @@ impl MultiAppConfig { pub fn mcp_for(&self, app: &AppType) -> &McpConfig { match app { AppType::Claude => &self.mcp.claude, + AppType::ClaudeDesktop => &self.mcp.claude_desktop, AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, @@ -639,6 +672,7 @@ impl MultiAppConfig { pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig { match app { AppType::Claude => &mut self.mcp.claude, + AppType::ClaudeDesktop => &mut self.mcp.claude_desktop, AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, AppType::OpenCode => &mut self.mcp.opencode, @@ -677,6 +711,7 @@ impl MultiAppConfig { fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result { // 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入 if !self.prompts.claude.prompts.is_empty() + || !self.prompts.claude_desktop.prompts.is_empty() || !self.prompts.codex.prompts.is_empty() || !self.prompts.gemini.prompts.is_empty() || !self.prompts.opencode.prompts.is_empty() @@ -763,6 +798,7 @@ impl MultiAppConfig { // 插入到对应的应用配置中 let prompts = match app { AppType::Claude => &mut config.prompts.claude.prompts, + AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts, AppType::Codex => &mut config.prompts.codex.prompts, AppType::Gemini => &mut config.prompts.gemini.prompts, AppType::OpenCode => &mut config.prompts.opencode.prompts, @@ -804,6 +840,7 @@ impl MultiAppConfig { ] { let old_servers = match app { AppType::Claude => &self.mcp.claude.servers, + AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here AppType::Codex => &self.mcp.codex.servers, AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, @@ -924,6 +961,23 @@ mod tests { use std::fs; use tempfile::TempDir; + #[test] + fn app_type_parses_claude_desktop_aliases() { + assert_eq!( + "claude-desktop".parse::().unwrap(), + AppType::ClaudeDesktop + ); + assert_eq!( + "claude_desktop".parse::().unwrap(), + AppType::ClaudeDesktop + ); + assert_eq!( + "claudeDesktop".parse::().unwrap(), + AppType::ClaudeDesktop + ); + assert_eq!(AppType::ClaudeDesktop.as_str(), "claude-desktop"); + } + struct TempHome { #[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期 dir: TempDir, diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs new file mode 100644 index 000000000..a94849b78 --- /dev/null +++ b/src-tauri/src/claude_desktop_config.rs @@ -0,0 +1,1339 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::{atomic_write, delete_file, get_home_dir, read_json_file, write_json_file}; +use crate::database::Database; +use crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID; +use crate::error::AppError; +use crate::provider::{ClaudeDesktopMode, Provider}; + +pub const PROFILE_ID: &str = "00000000-0000-4000-8000-000000157210"; +pub const PROFILE_NAME: &str = "CC Switch"; + +const CONFIG_FILE: &str = "claude_desktop_config.json"; +const CONFIG_LIBRARY_DIR: &str = "configLibrary"; +const GATEWAY_TOKEN_SETTING_KEY: &str = "claude_desktop_gateway_token"; +const CLAUDE_DESKTOP_PROXY_PREFIX: &str = "/claude-desktop"; +const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z"; +const ONE_M_CONTEXT_SUFFIX: &str = " [1M]"; + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeDesktopDefaultRoute { + pub route_id: &'static str, + pub env_key: &'static str, + pub display_name: &'static str, + #[serde(rename = "supports1m")] + pub supports_1m: bool, +} + +pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[ + ClaudeDesktopDefaultRoute { + route_id: "claude-sonnet-4-6", + env_key: "ANTHROPIC_DEFAULT_SONNET_MODEL", + display_name: "Sonnet", + supports_1m: true, + }, + ClaudeDesktopDefaultRoute { + route_id: "claude-opus-4-7", + env_key: "ANTHROPIC_DEFAULT_OPUS_MODEL", + display_name: "Opus", + supports_1m: true, + }, + ClaudeDesktopDefaultRoute { + route_id: "claude-haiku-4-5", + env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", + display_name: "Haiku", + supports_1m: true, + }, +]; + +#[derive(Debug, Clone)] +struct ClaudeDesktopPaths { + normal_config_path: PathBuf, + threep_config_path: PathBuf, + config_library_path: PathBuf, + profile_path: PathBuf, + meta_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectGatewayCredentials { + pub base_url: String, + pub api_key: String, +} + +#[derive(Debug, Clone)] +struct FileSnapshot { + path: PathBuf, + content: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeDesktopStatus { + pub supported: bool, + pub configured: bool, + pub applied_id: Option, + pub profile_path: Option, + pub config_library_path: Option, + pub mode: Option, + pub expected_base_url: Option, + pub actual_base_url: Option, + pub proxy_running: bool, + pub stale_raw_models: bool, + pub missing_route_mappings: bool, + pub gateway_token_configured: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedModelRoute { + pub route_id: String, + pub upstream_model: String, + pub display_name: Option, + pub supports_1m: bool, +} + +pub fn apply_provider(db: &Database, provider: &Provider) -> Result<(), AppError> { + let paths = current_platform_paths()?; + apply_provider_to_paths(db, provider, &paths) +} + +pub fn get_status(db: &Database, proxy_running: bool) -> Result { + if !is_supported_platform() { + return Ok(ClaudeDesktopStatus { + supported: false, + configured: false, + applied_id: None, + profile_path: None, + config_library_path: None, + mode: None, + expected_base_url: None, + actual_base_url: None, + proxy_running, + stale_raw_models: false, + missing_route_mappings: false, + gateway_token_configured: false, + }); + } + + let paths = current_platform_paths()?; + let applied_id = read_applied_id(&paths.meta_path); + let configured = paths.profile_path.exists() || meta_has_profile_entry(&paths.meta_path); + let profile = read_json_or_empty(&paths.profile_path).unwrap_or_else(|_| json!({})); + let actual_base_url = profile + .get("inferenceGatewayBaseUrl") + .and_then(Value::as_str) + .map(str::to_string); + let stale_raw_models = profile + .get("inferenceModels") + .and_then(Value::as_array) + .map(|models| { + models.iter().any(|item| { + item.as_str() + .or_else(|| item.get("name").and_then(Value::as_str)) + .is_some_and(|model| !is_claude_safe_model_id(model)) + }) + }) + .unwrap_or(false); + let gateway_token_configured = db + .get_setting(GATEWAY_TOKEN_SETTING_KEY) + .ok() + .flatten() + .is_some_and(|token| !token.trim().is_empty()); + let current_provider = crate::settings::get_effective_current_provider( + db, + &crate::app_config::AppType::ClaudeDesktop, + ) + .ok() + .flatten() + .and_then(|id| db.get_provider_by_id(&id, "claude-desktop").ok().flatten()); + let mode = current_provider.as_ref().map(provider_mode); + let expected_base_url = match mode { + Some(ClaudeDesktopMode::Proxy) => proxy_gateway_base_url_from_db(db).ok(), + Some(ClaudeDesktopMode::Direct) => current_provider + .as_ref() + .and_then(|provider| direct_gateway_credentials(provider).ok()) + .map(|credentials| credentials.base_url), + None => None, + }; + let missing_route_mappings = current_provider.as_ref().is_some_and(|provider| { + matches!(provider_mode(provider), ClaudeDesktopMode::Proxy) + && proxy_model_routes(provider).is_err() + }); + + Ok(ClaudeDesktopStatus { + supported: true, + configured, + applied_id, + profile_path: Some(paths.profile_path.display().to_string()), + config_library_path: Some(paths.config_library_path.display().to_string()), + mode, + expected_base_url, + actual_base_url, + proxy_running, + stale_raw_models, + missing_route_mappings, + gateway_token_configured, + }) +} + +pub fn get_config_library_path() -> Result { + Ok(current_platform_paths()?.config_library_path) +} + +pub fn default_proxy_routes() -> Vec { + DEFAULT_PROXY_ROUTES.to_vec() +} + +pub fn is_compatible_direct_provider(provider: &Provider) -> bool { + validate_direct_provider(provider).is_ok() +} + +pub fn is_official_provider(provider: &Provider) -> bool { + provider.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID +} + +pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode { + provider + .meta + .as_ref() + .and_then(|meta| meta.claude_desktop_mode.clone()) + .unwrap_or(ClaudeDesktopMode::Direct) +} + +pub fn is_claude_safe_model_id(model: &str) -> bool { + let normalized = strip_one_m_context_suffix(model).to_ascii_lowercase(); + normalized.starts_with("claude-") || normalized.starts_with("anthropic/claude-") +} + +fn strip_one_m_context_suffix(model: &str) -> String { + let trimmed = model.trim(); + let lower = trimmed.to_ascii_lowercase(); + if lower.ends_with("[1m]") { + trimmed[..trimmed.len().saturating_sub("[1M]".len())] + .trim_end() + .to_string() + } else { + trimmed.to_string() + } +} + +fn has_one_m_context_suffix(model: &str) -> bool { + model.trim().to_ascii_lowercase().ends_with("[1m]") +} + +fn desktop_model_id(model_id: &str, supports_1m: bool) -> String { + let normalized = strip_one_m_context_suffix(model_id); + if supports_1m { + format!("{normalized}{ONE_M_CONTEXT_SUFFIX}") + } else { + normalized + } +} + +fn upstream_model_id(model_id: &str, supports_1m: bool) -> String { + desktop_model_id(model_id, supports_1m) +} + +pub fn get_or_create_gateway_token(db: &Database) -> Result { + if let Some(token) = db.get_setting(GATEWAY_TOKEN_SETTING_KEY)? { + let trimmed = token.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + + let token = format!("ccs-{}", uuid::Uuid::new_v4().simple()); + db.set_setting(GATEWAY_TOKEN_SETTING_KEY, &token)?; + Ok(token) +} + +pub fn direct_gateway_credentials( + provider: &Provider, +) -> Result { + let env = provider + .settings_config + .get("env") + .and_then(Value::as_object) + .ok_or_else(|| { + AppError::localized( + "claude_desktop.provider.env_missing", + "Claude Desktop 直连供应商缺少 env 配置", + "Claude Desktop direct provider is missing env configuration", + ) + })?; + + let base_url = env + .get("ANTHROPIC_BASE_URL") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::localized( + "claude_desktop.provider.base_url_missing", + "Claude Desktop 直连供应商缺少 ANTHROPIC_BASE_URL", + "Claude Desktop direct provider is missing ANTHROPIC_BASE_URL", + ) + })? + .to_string(); + + let api_key = env + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::localized( + "claude_desktop.provider.auth_token_missing", + "Claude Desktop 直连供应商缺少 ANTHROPIC_AUTH_TOKEN(Bearer Token)", + "Claude Desktop direct provider is missing ANTHROPIC_AUTH_TOKEN (Bearer Token)", + ) + })? + .to_string(); + + Ok(DirectGatewayCredentials { base_url, api_key }) +} + +pub fn validate_direct_provider(provider: &Provider) -> Result<(), AppError> { + if is_official_provider(provider) { + return Ok(()); + } + + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "claude_desktop.provider.settings_not_object", + "Claude Desktop 直连供应商配置必须是 JSON 对象", + "Claude Desktop direct provider configuration must be a JSON object", + )); + } + + if let Some(meta) = provider.meta.as_ref() { + if let Some(api_format) = meta.api_format.as_deref() { + if !api_format.trim().is_empty() && api_format != "anthropic" { + return Err(AppError::localized( + "claude_desktop.provider.api_format_unsupported", + "Claude Desktop 第一阶段只支持原生 Anthropic Messages API", + "Claude Desktop phase 1 only supports native Anthropic Messages API", + )); + } + } + + if matches!( + meta.claude_desktop_mode.as_ref(), + Some(ClaudeDesktopMode::Proxy) + ) { + return Err(AppError::localized( + "claude_desktop.provider.mode_unsupported", + "该供应商是 Claude Desktop 本地路由模式,不能按直连模式写入", + "This Claude Desktop provider uses proxy mode and cannot be written as direct mode", + )); + } + + if matches!( + meta.provider_type.as_deref(), + Some("github_copilot") | Some("codex_oauth") + ) { + return Err(AppError::localized( + "claude_desktop.provider.type_unsupported", + "Claude Desktop 直连模式不支持需要本地代理转换的供应商", + "Claude Desktop direct mode does not support providers that require local proxy conversion", + )); + } + + if meta.is_full_url == Some(true) { + return Err(AppError::localized( + "claude_desktop.provider.full_url_unsupported", + "Claude Desktop 直连模式不支持完整 URL 端点配置", + "Claude Desktop direct mode does not support full URL endpoint configuration", + )); + } + } + + direct_inference_model_ids(provider)?; + direct_gateway_credentials(provider)?; + Ok(()) +} + +pub fn validate_proxy_provider(provider: &Provider) -> Result<(), AppError> { + if is_official_provider(provider) { + return Ok(()); + } + + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "claude_desktop.provider.settings_not_object", + "Claude Desktop 本地路由供应商配置必须是 JSON 对象", + "Claude Desktop proxy provider configuration must be a JSON object", + )); + } + + if let Some(meta) = provider.meta.as_ref() { + if matches!( + meta.provider_type.as_deref(), + Some("github_copilot") | Some("codex_oauth") + ) { + return Err(AppError::localized( + "claude_desktop.provider.type_unsupported", + "Claude Desktop 本地路由模式暂不支持 Copilot 或 Codex OAuth 供应商", + "Claude Desktop proxy mode does not support Copilot or Codex OAuth providers yet", + )); + } + + if let Some(api_format) = meta.api_format.as_deref() { + if !matches!( + api_format, + "" | "anthropic" | "openai_chat" | "openai_responses" | "gemini_native" + ) { + return Err(AppError::localized( + "claude_desktop.provider.api_format_unsupported", + format!("Claude Desktop 本地路由模式不支持 API 格式: {api_format}"), + format!("Claude Desktop proxy mode does not support API format: {api_format}"), + )); + } + } + } + + proxy_model_routes(provider)?; + + if !has_proxy_base_url_and_key(provider) { + return Err(AppError::localized( + "claude_desktop.provider.credentials_missing", + "Claude Desktop 本地路由供应商缺少 Base URL 或 API Key", + "Claude Desktop proxy provider is missing Base URL or API key", + )); + } + + Ok(()) +} + +fn has_proxy_base_url_and_key(provider: &Provider) -> bool { + let env = provider.settings_config.get("env"); + let has_base_url = env + .and_then(|value| value.get("ANTHROPIC_BASE_URL")) + .or_else(|| provider.settings_config.get("base_url")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("apiEndpoint")) + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + + let has_key = env + .and_then(|value| { + [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ] + .into_iter() + .find_map(|key| value.get(key)) + }) + .or_else(|| provider.settings_config.get("apiKey")) + .or_else(|| provider.settings_config.get("api_key")) + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + + has_base_url && has_key +} + +pub fn validate_provider(provider: &Provider) -> Result<(), AppError> { + if is_official_provider(provider) { + return Ok(()); + } + + match provider_mode(provider) { + ClaudeDesktopMode::Direct => validate_direct_provider(provider), + ClaudeDesktopMode::Proxy => validate_proxy_provider(provider), + } +} + +pub fn direct_inference_model_ids(provider: &Provider) -> Result, AppError> { + let Some(routes) = provider + .meta + .as_ref() + .map(|meta| &meta.claude_desktop_model_routes) + else { + return Ok(Vec::new()); + }; + + let mut result = Vec::new(); + for (route_id, route) in routes { + let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id); + let route_id = strip_one_m_context_suffix(route_id); + if route_id.is_empty() { + continue; + } + if !is_claude_safe_model_id(&route_id) { + return Err(AppError::localized( + "claude_desktop.provider.route_invalid", + format!("Claude Desktop 直连模型必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"), + format!("Claude Desktop direct model must use a claude-* or anthropic/claude-* name: {route_id}"), + )); + } + result.push(desktop_model_id(&route_id, supports_1m)); + } + + result.sort(); + result.dedup(); + Ok(result) +} + +pub fn proxy_model_routes(provider: &Provider) -> Result, AppError> { + let routes = provider + .meta + .as_ref() + .map(|meta| &meta.claude_desktop_model_routes) + .ok_or_else(|| { + AppError::localized( + "claude_desktop.provider.routes_missing", + "Claude Desktop 本地路由模式缺少模型路由映射", + "Claude Desktop proxy mode is missing model route mappings", + ) + })?; + + let mut result = Vec::new(); + for (route_id, route) in routes { + let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id); + let route_id = strip_one_m_context_suffix(route_id); + let upstream_model = route.model.trim(); + if route_id.is_empty() || upstream_model.is_empty() { + continue; + } + if !is_claude_safe_model_id(&route_id) { + return Err(AppError::localized( + "claude_desktop.provider.route_invalid", + format!("Claude Desktop 模型路由必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"), + format!("Claude Desktop model route must use a claude-* or anthropic/claude-* name: {route_id}"), + )); + } + result.push(ResolvedModelRoute { + route_id: desktop_model_id(&route_id, supports_1m), + upstream_model: upstream_model_id(upstream_model, supports_1m), + display_name: route.display_name.clone(), + supports_1m, + }); + } + + result.sort_by(|a, b| a.route_id.cmp(&b.route_id)); + result.dedup_by(|a, b| a.route_id == b.route_id); + + if result.is_empty() { + return Err(AppError::localized( + "claude_desktop.provider.routes_missing", + "Claude Desktop 本地路由模式至少需要一个模型路由映射", + "Claude Desktop proxy mode requires at least one model route mapping", + )); + } + + Ok(result) +} + +pub fn model_list_response(provider: &Provider) -> Result { + let routes = proxy_model_routes(provider)?; + let data: Vec = routes + .iter() + .map(|route| { + let model_id = desktop_model_id(&route.route_id, route.supports_1m); + let mut item = json!({ + "type": "model", + "id": model_id, + "display_name": route.display_name.as_deref().unwrap_or(&route.route_id), + "created_at": DEFAULT_CREATED_AT, + }); + if route.supports_1m { + item["supports1m"] = json!(true); + } + item + }) + .collect(); + let first_id = data + .first() + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + let last_id = data + .last() + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + + Ok(json!({ + "data": data, + "has_more": false, + "first_id": first_id, + "last_id": last_id, + })) +} + +pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result { + let requested = body + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .map(str::to_string) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::localized( + "claude_desktop.provider.model_missing", + "Claude Desktop 请求缺少 model 字段", + "Claude Desktop request is missing the model field", + ) + })?; + + let routes = proxy_model_routes(provider)?; + let Some(route) = routes.iter().find(|route| route.route_id == requested) else { + return Err(AppError::localized( + "claude_desktop.provider.route_unknown", + format!("Claude Desktop 模型路由未配置: {requested}"), + format!("Claude Desktop model route is not configured: {requested}"), + )); + }; + + body["model"] = json!(route.upstream_model); + Ok(body) +} + +pub fn proxy_gateway_base_url_from_db(db: &Database) -> Result { + let config = futures::executor::block_on(db.get_proxy_config())?; + Ok(format!( + "{}{}", + proxy_origin_from_parts(&config.listen_address, config.listen_port), + CLAUDE_DESKTOP_PROXY_PREFIX + )) +} + +fn apply_provider_to_paths( + db: &Database, + provider: &Provider, + paths: &ClaudeDesktopPaths, +) -> Result<(), AppError> { + if is_official_provider(provider) { + return restore_official_at_paths(paths); + } + + validate_provider(provider)?; + let snapshots = snapshot_files(paths)?; + + if let Err(err) = apply_provider_to_paths_inner(db, provider, paths) { + if let Err(rollback_err) = restore_snapshots(&snapshots) { + log::error!( + "Failed to rollback Claude Desktop config after apply error: {rollback_err}" + ); + return Err(AppError::Message(format!( + "{err}; rollback failed: {rollback_err}" + ))); + } + return Err(err); + } + + Ok(()) +} + +fn restore_official_at_paths(paths: &ClaudeDesktopPaths) -> Result<(), AppError> { + let snapshots = snapshot_files(paths)?; + + if let Err(err) = restore_official_at_paths_inner(paths) { + if let Err(rollback_err) = restore_snapshots(&snapshots) { + log::error!( + "Failed to rollback Claude Desktop config after restore error: {rollback_err}" + ); + return Err(AppError::Message(format!( + "{err}; rollback failed: {rollback_err}" + ))); + } + return Err(err); + } + + Ok(()) +} + +fn apply_provider_to_paths_inner( + db: &Database, + provider: &Provider, + paths: &ClaudeDesktopPaths, +) -> Result<(), AppError> { + let profile = match provider_mode(provider) { + ClaudeDesktopMode::Direct => { + let credentials = direct_gateway_credentials(provider)?; + let model_ids = direct_inference_model_ids(provider)?; + build_gateway_profile( + &credentials.base_url, + &credentials.api_key, + (!model_ids.is_empty()).then_some(model_ids.as_slice()), + ) + } + ClaudeDesktopMode::Proxy => { + let base_url = proxy_gateway_base_url_from_db(db)?; + let api_key = get_or_create_gateway_token(db)?; + let routes = proxy_model_routes(provider)?; + let model_ids = routes + .iter() + .map(|route| desktop_model_id(&route.route_id, route.supports_1m)) + .collect::>(); + build_gateway_profile(&base_url, &api_key, Some(model_ids.as_slice())) + } + }; + + write_deployment_mode(&paths.normal_config_path, "3p")?; + write_deployment_mode(&paths.threep_config_path, "3p")?; + write_json_file(&paths.profile_path, &profile)?; + write_meta(&paths.meta_path, Some(PROFILE_ID))?; + + Ok(()) +} + +fn restore_official_at_paths_inner(paths: &ClaudeDesktopPaths) -> Result<(), AppError> { + write_deployment_mode(&paths.normal_config_path, "1p")?; + write_deployment_mode(&paths.threep_config_path, "1p")?; + remove_cc_switch_enterprise_config(&paths.threep_config_path)?; + + if paths.profile_path.exists() { + delete_file(&paths.profile_path)?; + } + write_meta(&paths.meta_path, None)?; + + Ok(()) +} + +fn build_gateway_profile(base_url: &str, api_key: &str, model_ids: Option<&[String]>) -> Value { + let mut profile = json!({ + "disableDeploymentModeChooser": true, + "inferenceGatewayApiKey": api_key, + "inferenceGatewayAuthScheme": "bearer", + "inferenceGatewayBaseUrl": base_url, + "inferenceProvider": "gateway" + }); + + if let Some(model_ids) = model_ids { + profile["inferenceModels"] = Value::Array( + model_ids + .iter() + .map(|model_id| Value::String(model_id.clone())) + .collect(), + ); + } + + profile +} + +fn read_json_or_empty(path: &Path) -> Result { + let value = if path.exists() { + read_json_file(path)? + } else { + json!({}) + }; + + if value.is_object() { + Ok(value) + } else { + Ok(json!({})) + } +} + +fn snapshot_files(paths: &ClaudeDesktopPaths) -> Result, AppError> { + [ + &paths.normal_config_path, + &paths.threep_config_path, + &paths.profile_path, + &paths.meta_path, + ] + .into_iter() + .map(|path| { + let content = if path.exists() { + Some(fs::read(path).map_err(|e| AppError::io(path, e))?) + } else { + None + }; + Ok(FileSnapshot { + path: path.clone(), + content, + }) + }) + .collect() +} + +fn restore_snapshots(snapshots: &[FileSnapshot]) -> Result<(), AppError> { + for snapshot in snapshots { + match &snapshot.content { + Some(content) => { + if let Some(parent) = snapshot.path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + atomic_write(&snapshot.path, content)?; + } + None => { + delete_file(&snapshot.path)?; + } + } + } + Ok(()) +} + +fn write_deployment_mode(path: &Path, mode: &str) -> Result<(), AppError> { + let mut value = read_json_or_empty(path)?; + if !value.is_object() { + value = json!({}); + } + if let Some(obj) = value.as_object_mut() { + obj.insert( + "deploymentMode".to_string(), + Value::String(mode.to_string()), + ); + } + write_json_file(path, &value) +} + +fn remove_cc_switch_enterprise_config(path: &Path) -> Result<(), AppError> { + if !path.exists() { + return Ok(()); + } + + let mut value = read_json_or_empty(path)?; + let Some(obj) = value.as_object_mut() else { + return Ok(()); + }; + let Some(enterprise) = obj + .get_mut("enterpriseConfig") + .and_then(Value::as_object_mut) + else { + return Ok(()); + }; + + for key in [ + "disableDeploymentModeChooser", + "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", + "inferenceGatewayBaseUrl", + "inferenceProvider", + ] { + enterprise.remove(key); + } + + if enterprise.is_empty() { + obj.remove("enterpriseConfig"); + } + + write_json_file(path, &value) +} + +fn write_meta(path: &Path, applied_profile_id: Option<&str>) -> Result<(), AppError> { + let mut value = read_json_or_empty(path)?; + if !value.is_object() { + value = json!({}); + } + + let obj = value.as_object_mut().expect("just normalized to object"); + let mut entries = obj + .get("entries") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + entries.retain(|entry| entry.get("id").and_then(Value::as_str) != Some(PROFILE_ID)); + + match applied_profile_id { + Some(id) => { + entries.push(json!({ + "id": PROFILE_ID, + "name": PROFILE_NAME + })); + obj.insert("appliedId".to_string(), Value::String(id.to_string())); + } + None => { + let should_clear_applied = obj + .get("appliedId") + .and_then(Value::as_str) + .is_some_and(|id| id == PROFILE_ID); + if should_clear_applied { + if let Some(next_id) = entries + .iter() + .find_map(|entry| entry.get("id").and_then(Value::as_str)) + { + obj.insert("appliedId".to_string(), Value::String(next_id.to_string())); + } else { + obj.remove("appliedId"); + } + } + } + } + + obj.insert("entries".to_string(), Value::Array(entries)); + write_json_file(path, &value) +} + +fn read_applied_id(path: &Path) -> Option { + read_json_or_empty(path).ok().and_then(|value| { + value + .get("appliedId") + .and_then(Value::as_str) + .map(str::to_string) + }) +} + +fn meta_has_profile_entry(path: &Path) -> bool { + read_json_or_empty(path) + .ok() + .and_then(|value| value.get("entries").and_then(Value::as_array).cloned()) + .is_some_and(|entries| { + entries + .iter() + .any(|entry| entry.get("id").and_then(Value::as_str) == Some(PROFILE_ID)) + }) +} + +fn is_supported_platform() -> bool { + cfg!(any(target_os = "macos", windows)) +} + +fn current_platform_paths() -> Result { + #[cfg(target_os = "macos")] + { + return Ok(macos_paths_from_home(&get_home_dir())); + } + + #[cfg(windows)] + { + let local_app_data = windows_local_app_data_dir(); + return Ok(windows_paths_from_local_app_data(&local_app_data)); + } + + #[cfg(not(any(target_os = "macos", windows)))] + { + Err(unsupported_platform_error()) + } +} + +#[cfg(target_os = "macos")] +fn macos_paths_from_home(home: &Path) -> ClaudeDesktopPaths { + let app_support = home.join("Library").join("Application Support"); + paths_from_dirs(app_support.join("Claude"), app_support.join("Claude-3p")) +} + +#[cfg(windows)] +fn windows_local_app_data_dir() -> PathBuf { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| get_home_dir().join("AppData").join("Local")) +} + +#[cfg(windows)] +fn windows_paths_from_local_app_data(local_app_data: &Path) -> ClaudeDesktopPaths { + let normal_dir = pick_windows_claude_dir(local_app_data, false) + .unwrap_or_else(|| local_app_data.join("Claude")); + let threep_dir = pick_windows_claude_dir(local_app_data, true) + .unwrap_or_else(|| local_app_data.join("Claude-3p")); + paths_from_dirs(normal_dir, threep_dir) +} + +#[cfg(windows)] +fn pick_windows_claude_dir(local_app_data: &Path, threep: bool) -> Option { + let exact_name = if threep { "Claude-3p" } else { "Claude" }; + let exact = local_app_data.join(exact_name); + if exact.exists() { + return Some(exact); + } + + let mut candidates: Vec = std::fs::read_dir(local_app_data) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .filter(|path| { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + let starts = name.starts_with("Claude"); + let is_threep = name.contains("-3p"); + starts && is_threep == threep + }) + .collect(); + candidates.sort(); + candidates.into_iter().next() +} + +fn paths_from_dirs(normal_dir: PathBuf, threep_dir: PathBuf) -> ClaudeDesktopPaths { + let config_library_path = threep_dir.join(CONFIG_LIBRARY_DIR); + let profile_path = config_library_path.join(format!("{PROFILE_ID}.json")); + let meta_path = config_library_path.join("_meta.json"); + + ClaudeDesktopPaths { + normal_config_path: normal_dir.join(CONFIG_FILE), + threep_config_path: threep_dir.join(CONFIG_FILE), + config_library_path, + profile_path, + meta_path, + } +} + +fn proxy_origin_from_parts(listen_address: &str, listen_port: u16) -> String { + let connect_host = match listen_address { + "0.0.0.0" => "127.0.0.1", + "::" => "::1", + value => value, + }; + let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') { + format!("[{connect_host}]") + } else { + connect_host.to_string() + }; + + format!("http://{}:{}", connect_host_for_url, listen_port) +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn unsupported_platform_error() -> AppError { + AppError::localized( + "claude_desktop.unsupported_platform", + "当前平台暂不支持 Claude Desktop 3P 配置。第一阶段仅支持 macOS 和 Windows。", + "Claude Desktop 3P configuration is not supported on this platform yet. Phase 1 only supports macOS and Windows.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::provider::{ClaudeDesktopModelRoute, ProviderMeta}; + use serde_json::json; + use tempfile::TempDir; + + fn test_paths(home: &Path) -> ClaudeDesktopPaths { + paths_from_dirs( + home.join("Library") + .join("Application Support") + .join("Claude"), + home.join("Library") + .join("Application Support") + .join("Claude-3p"), + ) + } + + fn test_db() -> Database { + Database::memory().expect("memory db") + } + + fn direct_provider(id: &str) -> Provider { + let mut provider = Provider::with_id( + id.to_string(), + "Direct".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://gateway.example.com", + "ANTHROPIC_AUTH_TOKEN": "test-token", + "ANTHROPIC_MODEL": "ignored-by-desktop" + } + }), + Some("https://example.com".to_string()), + ); + provider.meta = Some(ProviderMeta { + api_format: Some("anthropic".to_string()), + ..Default::default() + }); + provider + } + + fn official_provider() -> Provider { + let mut provider = Provider::with_id( + CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID.to_string(), + "Claude Desktop Official".to_string(), + json!({"env": {}}), + Some("https://claude.ai/download".to_string()), + ); + provider.category = Some("official".to_string()); + provider + } + + fn proxy_provider(id: &str) -> Provider { + let mut provider = direct_provider(id); + provider.name = "Proxy".to_string(); + provider.meta = Some(ProviderMeta { + claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), + api_format: Some("openai_chat".to_string()), + claude_desktop_model_routes: std::collections::HashMap::from([( + "claude-sonnet-4-6".to_string(), + ClaudeDesktopModelRoute { + model: "kimi-k2".to_string(), + display_name: Some("Kimi".to_string()), + supports_1m: Some(true), + }, + )]), + ..Default::default() + }); + provider + } + + fn direct_provider_with_models(id: &str) -> Provider { + let mut provider = direct_provider(id); + provider.meta = Some(ProviderMeta { + claude_desktop_mode: Some(ClaudeDesktopMode::Direct), + api_format: Some("anthropic".to_string()), + claude_desktop_model_routes: std::collections::HashMap::from([( + "claude-deepseek-chat".to_string(), + ClaudeDesktopModelRoute { + model: "claude-deepseek-chat".to_string(), + display_name: Some("DeepSeek".to_string()), + supports_1m: Some(true), + }, + )]), + ..Default::default() + }); + provider + } + + #[test] + fn claude_desktop_apply_writes_3p_profile_and_meta() { + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + let provider = direct_provider("direct"); + let db = test_db(); + + apply_provider_to_paths(&db, &provider, &paths).expect("apply provider"); + + let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); + let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); + let profile: Value = read_json_file(&paths.profile_path).expect("read profile"); + let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); + + assert_eq!(normal["deploymentMode"], json!("3p")); + assert_eq!(threep["deploymentMode"], json!("3p")); + assert_eq!(profile["inferenceProvider"], json!("gateway")); + assert_eq!( + profile["inferenceGatewayBaseUrl"], + json!("https://gateway.example.com") + ); + assert_eq!(profile["inferenceGatewayApiKey"], json!("test-token")); + assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer")); + assert_eq!(profile["disableDeploymentModeChooser"], json!(true)); + assert!(profile.get("inferenceModels").is_none()); + assert_eq!(meta["appliedId"], json!(PROFILE_ID)); + assert!(meta["entries"] + .as_array() + .expect("entries") + .iter() + .any(|entry| entry["id"] == json!(PROFILE_ID) && entry["name"] == json!(PROFILE_NAME))); + } + + #[test] + fn claude_desktop_direct_can_write_optional_safe_model_ids() { + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + let provider = direct_provider_with_models("direct-models"); + let db = test_db(); + + apply_provider_to_paths(&db, &provider, &paths).expect("apply provider"); + + let profile: Value = read_json_file(&paths.profile_path).expect("read profile"); + assert_eq!( + profile["inferenceGatewayBaseUrl"], + json!("https://gateway.example.com") + ); + assert_eq!( + profile["inferenceModels"], + json!(["claude-deepseek-chat [1M]"]) + ); + } + + #[test] + fn claude_desktop_proxy_apply_writes_local_gateway_profile_with_safe_models() { + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + let provider = proxy_provider("proxy"); + let db = test_db(); + + apply_provider_to_paths(&db, &provider, &paths).expect("apply proxy provider"); + + let profile: Value = read_json_file(&paths.profile_path).expect("read profile"); + assert_eq!( + profile["inferenceGatewayBaseUrl"], + json!("http://127.0.0.1:15721/claude-desktop") + ); + assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer")); + assert_ne!(profile["inferenceGatewayApiKey"], json!("test-token")); + assert!(profile["inferenceGatewayApiKey"] + .as_str() + .expect("gateway token") + .starts_with("ccs-")); + assert_eq!( + profile["inferenceModels"], + json!(["claude-sonnet-4-6 [1M]"]) + ); + assert!(!profile.to_string().contains("kimi-k2")); + } + + #[test] + fn claude_desktop_proxy_maps_known_route_and_rejects_unknown_route() { + let provider = proxy_provider("proxy"); + + let mapped = map_proxy_request_model( + json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}), + &provider, + ) + .expect("map route"); + assert_eq!(mapped["model"], json!("kimi-k2 [1M]")); + + let models = model_list_response(&provider).expect("model list"); + assert_eq!(models["data"][0]["id"], json!("claude-sonnet-4-6 [1M]")); + + let err = map_proxy_request_model(json!({"model": "claude-opus-4-7"}), &provider) + .expect_err("unknown route should fail"); + assert!(err.to_string().contains("claude-opus-4-7")); + } + + #[test] + fn claude_desktop_one_m_suffix_normalization_is_case_and_space_tolerant() { + assert!(is_claude_safe_model_id("claude-sonnet-4-6 [1m]")); + assert!(is_claude_safe_model_id(" claude-sonnet-4-6 [1M] ")); + assert_eq!( + strip_one_m_context_suffix(" claude-sonnet-4-6 [1m] "), + "claude-sonnet-4-6" + ); + assert_eq!( + desktop_model_id(" claude-sonnet-4-6 [1m] ", true), + "claude-sonnet-4-6 [1M]" + ); + } + + #[test] + fn claude_desktop_apply_rolls_back_when_profile_write_fails() { + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + let provider = direct_provider("direct"); + let db = test_db(); + + write_json_file( + &paths.normal_config_path, + &json!({"deploymentMode": "1p", "normal": true}), + ) + .expect("write normal"); + write_json_file( + &paths.threep_config_path, + &json!({"deploymentMode": "1p", "threep": true}), + ) + .expect("write 3p"); + fs::write(&paths.config_library_path, "not a directory").expect("block profile parent"); + + apply_provider_to_paths(&db, &provider, &paths).expect_err("apply should fail"); + + let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); + let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); + + assert_eq!(normal, json!({"deploymentMode": "1p", "normal": true})); + assert_eq!(threep, json!({"deploymentMode": "1p", "threep": true})); + assert!(!paths.profile_path.exists()); + } + + #[test] + fn claude_desktop_write_meta_recovers_non_object_meta_file() { + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + if let Some(parent) = paths.meta_path.parent() { + fs::create_dir_all(parent).expect("create parent"); + } + fs::write(&paths.meta_path, "[]").expect("write invalid meta shape"); + + write_meta(&paths.meta_path, Some(PROFILE_ID)).expect("write meta"); + + let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); + assert_eq!(meta["appliedId"], json!(PROFILE_ID)); + assert!(meta["entries"].as_array().is_some()); + } + + #[test] + fn claude_desktop_restore_switches_to_1p_and_removes_cc_switch_profile() { + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + let provider = direct_provider("direct"); + let db = test_db(); + + apply_provider_to_paths(&db, &provider, &paths).expect("apply provider"); + restore_official_at_paths(&paths).expect("restore official"); + + let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); + let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); + let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); + + assert_eq!(normal["deploymentMode"], json!("1p")); + assert_eq!(threep["deploymentMode"], json!("1p")); + assert!(!paths.profile_path.exists()); + assert!(meta.get("appliedId").is_none()); + assert!(!meta["entries"] + .as_array() + .expect("entries") + .iter() + .any(|entry| entry["id"] == json!(PROFILE_ID))); + } + + #[test] + fn claude_desktop_official_provider_restores_1p_mode() { + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + let direct = direct_provider("direct"); + let db = test_db(); + + apply_provider_to_paths(&db, &direct, &paths).expect("apply direct provider"); + apply_provider_to_paths(&db, &official_provider(), &paths) + .expect("restore official provider"); + + let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); + let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); + let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); + + assert_eq!(normal["deploymentMode"], json!("1p")); + assert_eq!(threep["deploymentMode"], json!("1p")); + assert!(!paths.profile_path.exists()); + assert!(meta.get("appliedId").is_none()); + } + + #[test] + fn claude_desktop_compatibility_filters_non_direct_providers() { + let direct = direct_provider("direct"); + assert!(is_compatible_direct_provider(&direct)); + + let claude_official = Provider::with_id( + "claude-official".to_string(), + "Claude Official".to_string(), + json!({"env": {}}), + Some("https://www.anthropic.com/claude-code".to_string()), + ); + assert!(!is_compatible_direct_provider(&claude_official)); + + let mut openai_format = direct_provider("openai"); + openai_format.meta = Some(ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() + }); + assert!(!is_compatible_direct_provider(&openai_format)); + + let mut copilot = direct_provider("copilot"); + copilot.meta = Some(ProviderMeta { + provider_type: Some("github_copilot".to_string()), + ..Default::default() + }); + assert!(!is_compatible_direct_provider(&copilot)); + + let mut full_url = direct_provider("full_url"); + full_url.meta = Some(ProviderMeta { + is_full_url: Some(true), + ..Default::default() + }); + assert!(!is_compatible_direct_provider(&full_url)); + + let missing_bearer = Provider::with_id( + "x-api-key".to_string(), + "x-api-key".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://gateway.example.com", + "ANTHROPIC_API_KEY": "sk-ant" + } + }), + None, + ); + assert!(!is_compatible_direct_provider(&missing_bearer)); + } +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 48fd89e24..05f6875ea 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -1,6 +1,6 @@ #![allow(non_snake_case)] -use tauri::AppHandle; +use tauri::{AppHandle, State}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; @@ -8,6 +8,7 @@ use crate::app_config::AppType; use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; use crate::settings; +use crate::store::AppState; #[tauri::command] pub async fn get_claude_config_status() -> Result { @@ -62,9 +63,23 @@ fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), S } #[tauri::command] -pub async fn get_config_status(app: String) -> Result { +pub async fn get_config_status( + state: State<'_, AppState>, + app: String, +) -> Result { match AppType::from_str(&app).map_err(|e| e.to_string())? { AppType::Claude => Ok(config::get_claude_config_status()), + AppType::ClaudeDesktop => { + let status = crate::claude_desktop_config::get_status( + state.db.as_ref(), + state.proxy_service.is_running().await, + ) + .map_err(|e| e.to_string())?; + Ok(ConfigStatus { + exists: status.configured, + path: status.config_library_path.unwrap_or_default(), + }) + } AppType::Codex => { let auth_path = codex_config::get_codex_auth_path(); let exists = auth_path.exists(); @@ -122,6 +137,9 @@ pub async fn get_claude_code_config_path() -> Result { pub async fn get_config_dir(app: String) -> Result { let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { AppType::Claude => config::get_claude_config_dir(), + AppType::ClaudeDesktop => { + crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())? + } AppType::Codex => codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), @@ -136,6 +154,9 @@ pub async fn get_config_dir(app: String) -> Result { pub async fn open_config_folder(handle: AppHandle, app: String) -> Result { let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { AppType::Claude => config::get_claude_config_dir(), + AppType::ClaudeDesktop => { + crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())? + } AppType::Codex => codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 40a27c80c..ddcfcd709 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -784,7 +784,7 @@ fn extract_env_vars_from_config( // 处理 base_url: 根据应用类型添加对应的环境变量 let base_url_key = match app_type { - AppType::Claude => Some("ANTHROPIC_BASE_URL"), + AppType::Claude | AppType::ClaudeDesktop => Some("ANTHROPIC_BASE_URL"), AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"), _ => None, }; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 30db8f7ff..5fceb49c8 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -4,7 +4,7 @@ use tauri::{Emitter, State}; use crate::app_config::AppType; use crate::commands::copilot::CopilotAuthState; use crate::error::AppError; -use crate::provider::Provider; +use crate::provider::{ClaudeDesktopMode, Provider}; use crate::services::{ EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult, }; @@ -150,6 +150,154 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result< import_default_config_internal(&state, app_type).map_err(Into::into) } +#[tauri::command] +pub async fn get_claude_desktop_status( + state: State<'_, AppState>, +) -> Result { + let proxy_running = state.proxy_service.is_running().await; + crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_claude_desktop_default_routes( +) -> Vec { + crate::claude_desktop_config::default_proxy_routes() +} + +#[tauri::command] +pub fn import_claude_desktop_providers_from_claude( + state: State<'_, AppState>, +) -> Result { + let claude_providers = state + .db + .get_all_providers(AppType::Claude.as_str()) + .map_err(|e| e.to_string())?; + let existing_ids = state + .db + .get_provider_ids(AppType::ClaudeDesktop.as_str()) + .map_err(|e| e.to_string())?; + + let mut imported = 0usize; + for provider in claude_providers.values() { + if existing_ids.contains(&provider.id) { + continue; + } + + if matches!( + provider + .meta + .as_ref() + .and_then(|meta| meta.provider_type.as_deref()), + Some("github_copilot") | Some("codex_oauth") + ) { + continue; + } + + let mut desktop_provider = provider.clone(); + desktop_provider.in_failover_queue = false; + let meta = desktop_provider.meta.get_or_insert_with(Default::default); + + if crate::claude_desktop_config::is_compatible_direct_provider(provider) + && claude_provider_models_are_claude_safe(provider) + { + meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct); + } else if let Some(routes) = suggested_claude_desktop_routes(provider) { + meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy); + meta.claude_desktop_model_routes = routes; + } else { + continue; + } + + state + .db + .save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider) + .map_err(|e| e.to_string())?; + imported += 1; + } + + Ok(imported) +} + +fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool { + let Some(env) = provider + .settings_config + .get("env") + .and_then(|value| value.as_object()) + else { + return true; + }; + + [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + ] + .into_iter() + .filter_map(|key| env.get(key).and_then(|value| value.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .all(crate::claude_desktop_config::is_claude_safe_model_id) +} + +fn suggested_claude_desktop_routes( + provider: &Provider, +) -> Option> { + let env = provider + .settings_config + .get("env") + .and_then(|value| value.as_object())?; + let mut routes = std::collections::HashMap::new(); + + fn add_route( + routes: &mut std::collections::HashMap, + env: &serde_json::Map, + route_id: &str, + env_key: &str, + display_name: &str, + ) { + if let Some(model) = env + .get(env_key) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + routes.insert( + route_id.to_string(), + crate::provider::ClaudeDesktopModelRoute { + model: model.to_string(), + display_name: Some(display_name.to_string()), + supports_1m: Some(true), + }, + ); + } + } + + for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES { + add_route( + &mut routes, + env, + spec.route_id, + spec.env_key, + spec.display_name, + ); + } + + let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0]; + if !routes.contains_key(primary_route.route_id) { + add_route( + &mut routes, + env, + primary_route.route_id, + "ANTHROPIC_MODEL", + primary_route.display_name, + ); + } + + (!routes.is_empty()).then_some(routes) +} + #[allow(non_snake_case)] #[tauri::command] pub async fn queryProviderUsage( diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index fbab52858..f6feaf94d 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -15,6 +15,24 @@ pub async fn start_proxy_server( state.proxy_service.start().await } +/// 停止代理服务器(仅停止服务,不恢复/清理 Live 接管状态) +#[tauri::command] +pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> { + let takeover = state.proxy_service.get_takeover_status().await?; + if takeover.claude + || takeover.codex + || takeover.gemini + || takeover.opencode + || takeover.openclaw + { + return Err( + "仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(), + ); + } + + state.proxy_service.stop().await +} + /// 停止代理服务器(恢复 Live 配置) #[tauri::command] pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> { diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 695e8d08a..feb0bfc00 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -12,6 +12,7 @@ use crate::services::skill::{ SkillsShSearchResult, }; use crate::store::AppState; +use std::str::FromStr; use std::sync::Arc; use tauri::State; @@ -20,14 +21,7 @@ pub struct SkillServiceState(pub Arc); /// 解析 app 参数为 AppType fn parse_app_type(app: &str) -> Result { - match app.to_lowercase().as_str() { - "claude" => Ok(AppType::Claude), - "codex" => Ok(AppType::Codex), - "gemini" => Ok(AppType::Gemini), - "opencode" => Ok(AppType::OpenCode), - "hermes" => Ok(AppType::Hermes), - _ => Err(format!("不支持的 app 类型: {app}")), - } + AppType::from_str(app).map_err(|e| e.to_string()) } // ========== 统一管理命令 ========== diff --git a/src-tauri/src/database/dao/providers_seed.rs b/src-tauri/src/database/dao/providers_seed.rs index 055e9d294..2babe36e2 100644 --- a/src-tauri/src/database/dao/providers_seed.rs +++ b/src-tauri/src/database/dao/providers_seed.rs @@ -10,6 +10,8 @@ use crate::app_config::AppType; +pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official"; + /// 单条官方供应商种子定义。 pub(crate) struct OfficialProviderSeed { pub id: &'static str, @@ -22,7 +24,7 @@ pub(crate) struct OfficialProviderSeed { pub settings_config_json: &'static str, } -/// Claude / Codex / Gemini 三个应用的官方预设。 +/// Claude / Claude Desktop / Codex / Gemini 的官方预设。 /// /// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。 pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[ @@ -36,6 +38,16 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[ // 空 env 让用户走 Claude CLI 默认认证流程 settings_config_json: r#"{"env":{}}"#, }, + OfficialProviderSeed { + id: CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, + app_type: AppType::ClaudeDesktop, + name: "Claude Desktop Official", + website_url: "https://claude.ai/download", + icon: "anthropic", + icon_color: "#D4915D", + // 空 env 只是占位;切换该 provider 时会恢复 Claude Desktop 1P 模式 + settings_config_json: r#"{"env":{}}"#, + }, OfficialProviderSeed { id: "codex-official", app_type: AppType::Codex, @@ -64,3 +76,19 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[ pub(crate) fn is_official_seed_id(id: &str) -> bool { OFFICIAL_SEEDS.iter().any(|seed| seed.id == id) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn official_seeds_include_claude_desktop() { + let seed = OFFICIAL_SEEDS + .iter() + .find(|seed| seed.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID) + .expect("claude desktop official seed"); + + assert_eq!(seed.app_type, AppType::ClaudeDesktop); + assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID)); + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 1c93ce982..47c08695f 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -32,6 +32,7 @@ mod schema; mod tests; // DAO 类型导出供外部使用 +pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID; pub use dao::FailoverQueueItem; use crate::config::get_app_config_dir; diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 2ae90a013..d7ba9aed5 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -5,7 +5,7 @@ use super::utils::{decode_base64_param, infer_homepage_from_endpoint}; use super::DeepLinkImportRequest; use crate::error::AppError; -use crate::provider::{Provider, ProviderMeta, UsageScript}; +use crate::provider::{ClaudeDesktopMode, Provider, ProviderMeta, UsageScript}; use crate::services::ProviderService; use crate::store::AppState; use crate::AppType; @@ -142,7 +142,7 @@ pub(crate) fn build_provider_from_request( request: &DeepLinkImportRequest, ) -> Result { let settings_config = match app_type { - AppType::Claude => build_claude_settings(request), + AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request), AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), @@ -151,7 +151,11 @@ pub(crate) fn build_provider_from_request( }; // Build usage script configuration if provided - let meta = build_provider_meta(request)?; + let mut meta = build_provider_meta(request)?; + if matches!(app_type, AppType::ClaudeDesktop) { + meta.get_or_insert_with(ProviderMeta::default) + .claude_desktop_mode = Some(ClaudeDesktopMode::Direct); + } let provider = Provider { id: String::new(), // Will be generated by caller diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33d0f99b1..f0c8fd1ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod app_config; mod app_store; mod auto_launch; +mod claude_desktop_config; mod claude_mcp; mod claude_plugin; mod codex_config; @@ -1051,6 +1052,9 @@ pub fn run() { commands::remove_provider_from_live_config, commands::switch_provider, commands::import_default_config, + commands::get_claude_desktop_status, + commands::get_claude_desktop_default_routes, + commands::import_claude_desktop_providers_from_claude, commands::get_claude_config_status, commands::get_config_status, commands::get_claude_code_config_path, @@ -1192,6 +1196,7 @@ pub fn run() { commands::get_auto_launch_status, // Proxy server management commands::start_proxy_server, + commands::stop_proxy_server, commands::stop_proxy_with_restore, commands::get_proxy_takeover_status, commands::set_proxy_takeover_for_app, diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 8b22e76fb..70f350dc8 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -10,6 +10,14 @@ use crate::opencode_config::get_opencode_dir; /// 返回指定应用所使用的提示词文件路径。 pub fn prompt_file_path(app: &AppType) -> Result { + if matches!(app, AppType::ClaudeDesktop) { + return Err(AppError::localized( + "claude_desktop.prompts_unsupported", + "Claude Desktop 暂不支持 Prompts", + "Claude Desktop does not support Prompts", + )); + } + let base_dir: PathBuf = match app { AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?, AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, @@ -17,6 +25,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::OpenCode => get_opencode_dir(), AppType::OpenClaw => get_openclaw_dir(), AppType::Hermes => crate::hermes_config::get_hermes_dir(), + AppType::ClaudeDesktop => unreachable!("handled above"), }; let filename = match app { @@ -24,6 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md", + AppType::ClaudeDesktop => unreachable!("handled above"), }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 60c591fe7..1a589a6d1 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -216,6 +216,28 @@ pub struct AuthBinding { pub account_id: Option, } +/// Claude Desktop 3P 写入模式。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ClaudeDesktopMode { + Direct, + Proxy, +} + +/// Claude Desktop 本地路由模式下暴露给 Desktop 的安全模型路由。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeDesktopModelRoute { + /// 真实上游模型名,只保存在 CC Switch 内部,不写入 Claude Desktop profile。 + pub model: String, + /// Desktop /v1/models 中显示的名称。 + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Claude Desktop 3P 识别的 1M 上下文能力标记。 + #[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")] + pub supports_1m: Option, +} + /// 供应商元数据 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderMeta { @@ -228,6 +250,16 @@ pub struct ProviderMeta { skip_serializing_if = "Option::is_none" )] pub common_config_enabled: Option, + /// Claude Desktop 3P 写入模式:direct(直连)或 proxy(预留) + #[serde(rename = "claudeDesktopMode", skip_serializing_if = "Option::is_none")] + pub claude_desktop_mode: Option, + /// Claude Desktop proxy 模式的模型路由映射:Claude-safe route -> upstream model。 + #[serde( + default, + rename = "claudeDesktopModelRoutes", + skip_serializing_if = "HashMap::is_empty" + )] + pub claude_desktop_model_routes: HashMap, /// 用量查询脚本配置 #[serde(skip_serializing_if = "Option::is_none")] pub usage_script: Option, diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index d803277ef..861c985a0 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -195,6 +195,7 @@ impl RequestForwarder { // 转发请求(每个 Provider 只尝试一次,重试由客户端控制) match self .forward( + app_type, provider, endpoint, &provider_body, @@ -325,6 +326,7 @@ impl RequestForwarder { // 使用同一供应商重试(不计入熔断器) match self .forward( + app_type, provider, endpoint, &provider_body, @@ -524,6 +526,7 @@ impl RequestForwarder { // 使用同一供应商重试(不计入熔断器) match self .forward( + app_type, provider, endpoint, &provider_body, @@ -763,6 +766,7 @@ impl RequestForwarder { /// 转发单个请求(使用适配器) async fn forward( &self, + app_type: &AppType, provider: &Provider, endpoint: &str, body: &Value, @@ -780,8 +784,16 @@ impl RequestForwarder { .unwrap_or(false); // 应用模型映射(独立于格式转换) - let (mapped_body, _original_model, _mapped_model) = - super::model_mapper::apply_model_mapping(body.clone(), provider); + // Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route + // 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。 + let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) { + crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider) + .map_err(|e| ProxyError::InvalidRequest(e.to_string()))? + } else { + let (mapped_body, _original_model, _mapped_model) = + super::model_mapper::apply_model_mapping(body.clone(), provider); + mapped_body + }; // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) let mut mapped_body = normalize_thinking_type(mapped_body); diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index ca5632c71..55cb91601 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -69,6 +69,49 @@ pub async fn get_status(State(state): State) -> Result, request: axum::extract::Request, +) -> Result { + handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await +} + +pub async fn handle_claude_desktop_messages( + State(state): State, + request: axum::extract::Request, +) -> Result { + validate_claude_desktop_gateway_auth(&state, request.headers())?; + handle_messages_for_app( + state, + request, + AppType::ClaudeDesktop, + "Claude Desktop", + "claude-desktop", + Some("/claude-desktop"), + ) + .await +} + +pub async fn handle_claude_desktop_models( + State(state): State, + headers: axum::http::HeaderMap, +) -> Result, ProxyError> { + validate_claude_desktop_gateway_auth(&state, &headers)?; + let providers = state + .provider_router + .select_providers("claude-desktop") + .await + .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; + let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?; + let response = crate::claude_desktop_config::model_list_response(provider) + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + Ok(Json(response)) +} + +async fn handle_messages_for_app( + state: ProxyState, + request: axum::extract::Request, + app_type: AppType, + tag: &'static str, + app_type_str: &'static str, + strip_prefix: Option<&'static str>, ) -> Result { let (parts, body) = request.into_parts(); let uri = parts.uri; @@ -83,12 +126,15 @@ pub async fn handle_messages( .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?; let mut ctx = - RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?; + RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?; - let endpoint = uri + let raw_endpoint = uri .path_and_query() .map(|path_and_query| path_and_query.as_str()) .unwrap_or(uri.path()); + let endpoint = strip_prefix + .and_then(|prefix| raw_endpoint.strip_prefix(prefix)) + .unwrap_or(raw_endpoint); let is_stream = body .get("stream") @@ -99,7 +145,7 @@ pub async fn handle_messages( let forwarder = ctx.create_forwarder(&state); let result = match forwarder .forward_with_retry( - &AppType::Claude, + &app_type, endpoint, body.clone(), headers, @@ -127,7 +173,7 @@ pub async fn handle_messages( let response = result.response; // 检查是否需要格式转换(OpenRouter 等中转服务) - let adapter = get_adapter(&AppType::Claude); + let adapter = get_adapter(&app_type); let needs_transform = adapter.needs_transform(&ctx.provider); // Claude 特有:格式转换处理 @@ -140,6 +186,33 @@ pub async fn handle_messages( process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await } +fn validate_claude_desktop_gateway_auth( + state: &ProxyState, + headers: &axum::http::HeaderMap, +) -> Result<(), ProxyError> { + let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref()) + .map_err(|e| ProxyError::AuthError(e.to_string()))?; + let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else { + return Err(ProxyError::AuthError( + "Claude Desktop gateway 缺少 Authorization 头".to_string(), + )); + }; + let value = value + .to_str() + .map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?; + let token = value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer ")) + .unwrap_or("") + .trim(); + if token != expected { + return Err(ProxyError::AuthError( + "Claude Desktop gateway token 无效".to_string(), + )); + } + Ok(()) +} + /// Claude 格式转换处理(独有逻辑) /// /// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换 diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index fbd06036a..b4751a281 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -380,6 +380,16 @@ impl ClaudeAdapter { log::debug!("[Claude] 使用 OPENAI_API_KEY"); return Some(key.to_string()); } + // Gemini Native key + if let Some(key) = env + .get("GEMINI_API_KEY") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + log::debug!("[Claude] 使用 GEMINI_API_KEY"); + return Some(key.to_string()); + } } // 尝试直接获取 @@ -904,6 +914,27 @@ mod tests { assert_eq!(auth.strategy, AuthStrategy::Bearer); } + #[test] + fn test_extract_auth_gemini_api_key() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta", + "GEMINI_API_KEY": "gemini-test-key" + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "gemini-test-key"); + assert_eq!(auth.strategy, AuthStrategy::Google); + } + #[test] fn test_extract_auth_claude_auth_mode() { let adapter = ClaudeAdapter::new(); diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index ec98d462f..174268fdb 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -105,7 +105,7 @@ impl ProviderType { #[allow(dead_code)] pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self { match app_type { - AppType::Claude => { + AppType::Claude | AppType::ClaudeDesktop => { if get_claude_api_format(provider) == "gemini_native" { let adapter = ClaudeAdapter::new(); return match adapter.extract_auth(provider).map(|auth| auth.strategy) { @@ -226,7 +226,7 @@ impl std::str::FromStr for ProviderType { /// 根据 AppType 获取对应的适配器 pub fn get_adapter(app_type: &AppType) -> Box { match app_type { - AppType::Claude => Box::new(ClaudeAdapter::new()), + AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()), AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 1360aae03..475069a98 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -285,6 +285,15 @@ impl ProxyServer { // Claude API (支持带前缀和不带前缀两种格式) .route("/v1/messages", post(handlers::handle_messages)) .route("/claude/v1/messages", post(handlers::handle_messages)) + // Claude Desktop 3P 本地 gateway(独立 provider namespace) + .route( + "/claude-desktop/v1/models", + get(handlers::handle_claude_desktop_models), + ) + .route( + "/claude-desktop/v1/messages", + post(handlers::handle_claude_desktop_messages), + ) // OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀) .route("/chat/completions", post(handlers::handle_chat_completions)) .route( diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index ec282f372..a56ef57d5 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -121,6 +121,9 @@ impl ConfigService { match app_type { AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?, AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, + AppType::ClaudeDesktop => { + // Claude Desktop 3P profiles are managed by claude_desktop_config. + } AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, AppType::OpenCode => { // OpenCode uses additive mode, no live sync needed diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 78f92c029..ed07c56fe 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -112,6 +112,9 @@ impl McpService { AppType::Claude => { mcp::sync_single_server_to_claude(&Default::default(), &server.id, &server.server)?; } + AppType::ClaudeDesktop => { + log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping"); + } AppType::Codex => { // Codex uses TOML format, must use the correct function mcp::sync_single_server_to_codex(&Default::default(), &server.id, &server.server)?; @@ -154,6 +157,9 @@ impl McpService { fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> { match app { AppType::Claude => mcp::remove_server_from_claude(id)?, + AppType::ClaudeDesktop => { + log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping"); + } AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => { @@ -175,7 +181,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::OpenClaw) { + if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) { continue; } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index ad90dd3f1..0086b2bb6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -349,7 +349,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: } _ => false, }, - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false, } } @@ -419,7 +419,9 @@ pub(crate) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()), + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { + Ok(settings.clone()) + } } } @@ -474,7 +476,9 @@ fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()), + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { + Ok(settings.clone()) + } } } @@ -513,6 +517,16 @@ pub(crate) fn write_live_with_common_config( effective_provider.settings_config = build_effective_settings_with_common_config(db, app_type, provider)?; + if matches!(app_type, AppType::ClaudeDesktop) { + crate::claude_desktop_config::apply_provider(db, &effective_provider)?; + log::info!( + "Claude Desktop 3P profile '{}' written for provider '{}'", + crate::claude_desktop_config::PROFILE_ID, + effective_provider.id + ); + return Ok(()); + } + write_live_snapshot(app_type, &effective_provider) } @@ -699,6 +713,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re let settings = sanitize_claude_settings_for_live(&provider.settings_config); write_json_file(&path, &settings)?; } + AppType::ClaudeDesktop => { + return Err(AppError::localized( + "claude_desktop.live.requires_db_context", + "Claude Desktop 配置写入需要通过供应商切换流程执行", + "Claude Desktop configuration must be written through the provider switch flow", + )); + } AppType::Codex => { let obj = provider .settings_config @@ -953,6 +974,11 @@ pub fn read_live_settings(app_type: AppType) -> Result { } read_json_file(&path) } + AppType::ClaudeDesktop => Err(AppError::localized( + "claude_desktop.live.read_unsupported", + "Claude Desktop 3P 配置不支持作为通用 live 配置导入,请使用“从 Claude 导入兼容供应商”。", + "Claude Desktop 3P configuration cannot be imported as a generic live config. Use 'Import compatible providers from Claude' instead.", + )), AppType::Gemini => { use crate::gemini_config::{ env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env, @@ -1078,6 +1104,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + return Err(AppError::localized( + "claude_desktop.import_unsupported", + "Claude Desktop 3P 配置不能通过通用导入读取,请使用“从 Claude 导入兼容供应商”。", + "Claude Desktop 3P config cannot be imported through the generic import flow. Use 'Import compatible providers from Claude' instead.", + )); + } AppType::Gemini => { use crate::gemini_config::{ env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env, diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 20c2516c2..64c6120b0 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1397,6 +1397,10 @@ impl ProviderService { return Self::switch_normal(state, app_type, id, &providers); } + if matches!(app_type, AppType::ClaudeDesktop) { + return Self::switch_normal(state, app_type, id, &providers); + } + // Check if proxy takeover mode is active AND proxy server is actually running // Both conditions must be true to use hot-switch mode // Use blocking wait since this is a sync function @@ -1730,6 +1734,7 @@ impl ProviderService { match app_type { AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), + AppType::ClaudeDesktop => Ok(String::new()), 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), @@ -1745,6 +1750,7 @@ impl ProviderService { ) -> Result { match app_type { AppType::Claude => Self::extract_claude_common_config(settings_config), + AppType::ClaudeDesktop => Ok(String::new()), 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), @@ -2056,6 +2062,9 @@ impl ProviderService { )); } } + AppType::ClaudeDesktop => { + crate::claude_desktop_config::validate_provider(provider)?; + } AppType::Codex => { let settings = provider.settings_config.as_object().ok_or_else(|| { AppError::localized( @@ -2190,6 +2199,11 @@ impl ProviderService { Ok((api_key, base_url)) } + AppType::ClaudeDesktop => { + let credentials = + crate::claude_desktop_config::direct_gateway_credentials(provider)?; + Ok((credentials.api_key, credentials.base_url)) + } AppType::Codex => { let auth = provider .settings_config diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 2b7d2c414..bafbac56d 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -466,7 +466,7 @@ impl ProxyService { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features return Err("该应用不支持代理功能".to_string()); } @@ -683,7 +683,7 @@ impl ProxyService { } } } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features, skip silently } } @@ -864,7 +864,7 @@ impl ProxyService { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features return Err("该应用不支持代理功能".to_string()); } @@ -1008,7 +1008,7 @@ impl ProxyService { self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features return Err("该应用不支持代理功能".to_string()); } @@ -1061,7 +1061,7 @@ impl ProxyService { let _ = self.write_gemini_live(&live_config); } } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features, skip silently } } @@ -1101,7 +1101,7 @@ impl ProxyService { log::info!("Gemini Live 配置已恢复"); } } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features, skip silently } } @@ -1192,7 +1192,7 @@ impl ProxyService { AppType::Claude => self.write_claude_live(config), AppType::Codex => self.write_codex_live(config), AppType::Gemini => self.write_gemini_live(config), - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features Err("该应用不支持代理功能".to_string()) } @@ -1213,7 +1213,7 @@ impl ProxyService { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy takeover false } @@ -1256,7 +1256,7 @@ impl ProxyService { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { // These apps don't support proxy features Ok(()) } @@ -1520,7 +1520,7 @@ impl ProxyService { serde_json::to_string(&env_backup) .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))? } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => { return Err(format!("未知的应用类型: {app_type}")); } }; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 7c2515ebf..5cdb33076 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -509,6 +509,7 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::ClaudeDesktop => {} AppType::Codex => { if let Some(custom) = crate::settings::get_codex_override_dir() { return Ok(custom.join("skills")); @@ -545,6 +546,7 @@ impl SkillService { Ok(match app { AppType::Claude => home.join(".claude").join("skills"), + AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"), AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), @@ -1581,6 +1583,10 @@ impl SkillService { /// - Symlink: 仅使用 symlink /// - Copy: 仅使用文件复制 pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> { + if matches!(app, AppType::ClaudeDesktop) { + return Ok(()); + } + let ssot_dir = Self::get_ssot_dir()?; let source = ssot_dir.join(directory); @@ -1686,6 +1692,10 @@ impl SkillService { /// 从应用目录删除 Skill(支持 symlink 和真实目录) pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> { + if matches!(app, AppType::ClaudeDesktop) { + return Ok(()); + } + let app_dir = Self::get_app_skills_dir(app)?; let skill_path = app_dir.join(directory); @@ -1699,6 +1709,10 @@ impl SkillService { /// 同步所有已启用的 Skills 到指定应用 pub fn sync_to_app(db: &Arc, app: &AppType) -> Result<()> { + if matches!(app, AppType::ClaudeDesktop) { + return Ok(()); + } + let skills = db.get_all_installed_skills()?; let ssot_dir = Self::get_ssot_dir()?; let app_dir = Self::get_app_skills_dir(app)?; diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 7b87a01fa..9acfb51d4 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -215,7 +215,11 @@ impl StreamCheckService { return Self::check_once_without_adapter(app_type, provider, config, start).await; } - let adapter = get_adapter(app_type); + let adapter: Box = if matches!(app_type, AppType::ClaudeDesktop) { + Box::new(ClaudeAdapter::new()) + } else { + get_adapter(app_type) + }; let base_url = match base_url_override { Some(base_url) => base_url, @@ -236,7 +240,7 @@ impl StreamCheckService { let test_prompt = &config.test_prompt; let result = match app_type { - AppType::Claude => { + AppType::Claude | AppType::ClaudeDesktop => { Self::check_claude_stream( &client, &base_url, @@ -1361,8 +1365,10 @@ impl StreamCheckService { config: &StreamCheckConfig, ) -> String { match app_type { - AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL") - .unwrap_or_else(|| config.claude_model.clone()), + AppType::Claude | AppType::ClaudeDesktop => { + Self::extract_env_model(provider, "ANTHROPIC_MODEL") + .unwrap_or_else(|| config.claude_model.clone()) + } AppType::Codex => { Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone()) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 704ab71eb..161e7bc6a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -28,6 +28,13 @@ fn default_true() -> bool { pub struct VisibleApps { #[serde(default = "default_true")] pub claude: bool, + #[serde( + rename = "claude-desktop", + alias = "claudeDesktop", + alias = "claude_desktop", + default = "default_true" + )] + pub claude_desktop: bool, #[serde(default = "default_true")] pub codex: bool, #[serde(default = "default_true")] @@ -44,6 +51,7 @@ impl Default for VisibleApps { fn default() -> Self { Self { claude: true, + claude_desktop: true, codex: true, gemini: true, opencode: true, @@ -58,6 +66,7 @@ impl VisibleApps { pub fn is_visible(&self, app: &AppType) -> bool { match app { AppType::Claude => self.claude, + AppType::ClaudeDesktop => self.claude_desktop, AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, @@ -242,6 +251,9 @@ pub struct AppSettings { /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, + /// 当前 Claude Desktop 供应商 ID(本地存储,优先于数据库 is_current) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_claude_desktop: Option, /// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_codex: Option, @@ -326,6 +338,7 @@ impl Default for AppSettings { openclaw_config_dir: None, hermes_config_dir: None, current_provider_claude: None, + current_provider_claude_desktop: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, @@ -613,6 +626,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { let settings = settings_store().read().ok()?; match app_type { AppType::Claude => settings.current_provider_claude.clone(), + AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(), AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), @@ -629,6 +643,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), let id_owned = id.map(|s| s.to_string()); mutate_settings(|settings| match app_type { AppType::Claude => settings.current_provider_claude = id_owned.clone(), + AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(), AppType::Codex => settings.current_provider_codex = id_owned.clone(), AppType::Gemini => settings.current_provider_gemini = id_owned.clone(), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(), @@ -768,3 +783,40 @@ pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppErro } }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_config::AppType; + + #[test] + fn visible_apps_old_settings_default_claude_desktop_visible() { + let visible: VisibleApps = serde_json::from_value(serde_json::json!({ + "claude": true, + "codex": true, + "gemini": true, + "opencode": true, + "openclaw": true, + "hermes": true + })) + .expect("visible apps"); + + assert!(visible.is_visible(&AppType::ClaudeDesktop)); + } + + #[test] + fn visible_apps_accepts_claude_desktop_aliases() { + let visible: VisibleApps = serde_json::from_value(serde_json::json!({ + "claude": true, + "claudeDesktop": false, + "codex": true, + "gemini": true, + "opencode": true, + "openclaw": true, + "hermes": true + })) + .expect("visible apps"); + + assert!(!visible.is_visible(&AppType::ClaudeDesktop)); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index b727e35ca..b96ce839b 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -429,13 +429,9 @@ fn handle_provider_click( .db .set_proxy_flags_sync(app_type_str, proxy_enabled, false)?; - // 切换供应商 - crate::commands::switch_provider( - app_state.clone(), - app_type_str.to_string(), - provider_id.to_string(), - ) - .map_err(AppError::Message)?; + // 切换供应商。需要本地路由的供应商也不在这里自动启动代理, + // 由用户在页面/设置中手动开启。 + crate::services::ProviderService::switch(app_state.inner(), app_type.clone(), provider_id)?; // 更新托盘菜单 if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { diff --git a/src/App.tsx b/src/App.tsx index c91b1983a..9ffac348c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,6 +65,7 @@ import { SettingsPage } from "@/components/settings/SettingsPage"; import { UpdateBadge } from "@/components/UpdateBadge"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import { ProxyToggle } from "@/components/proxy/ProxyToggle"; +import { ClaudeDesktopRouteToggle } from "@/components/proxy/ClaudeDesktopRouteToggle"; import { FailoverToggle } from "@/components/proxy/FailoverToggle"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; @@ -117,6 +118,7 @@ const HEADER_HEIGHT = 64; // px const STORAGE_KEY = "cc-switch-last-app"; const VALID_APPS: AppId[] = [ "claude", + "claude-desktop", "codex", "gemini", "opencode", @@ -179,6 +181,7 @@ function App() { const contentTopOffset = dragBarHeight + HEADER_HEIGHT; const visibleApps: VisibleApps = settingsData?.visibleApps ?? { claude: true, + "claude-desktop": true, codex: true, gemini: true, opencode: true, @@ -188,6 +191,7 @@ function App() { const getFirstVisibleApp = (): AppId => { if (visibleApps.claude) return "claude"; + if (visibleApps["claude-desktop"]) return "claude-desktop"; if (visibleApps.codex) return "codex"; if (visibleApps.gemini) return "gemini"; if (visibleApps.opencode) return "opencode"; @@ -269,7 +273,7 @@ function App() { currentView === "openclawAgents"); const { data: openclawHealthWarnings = [] } = useOpenClawHealth(isOpenClawView); - const hasSkillsSupport = true; + const hasSkillsSupport = activeApp !== "claude-desktop"; const hasSessionSupport = activeApp === "claude" || activeApp === "codex" || @@ -1241,12 +1245,17 @@ function App() { className="flex shrink-0 items-center gap-1.5" style={{ WebkitAppRegion: "no-drag" } as any} > - {settingsData?.enableLocalProxy && ( - - )} - {settingsData?.enableFailoverToggle && ( - + {activeApp === "claude-desktop" ? ( + + ) : ( + settingsData?.enableLocalProxy && ( + + ) )} + {activeApp !== "claude-desktop" && + settingsData?.enableFailoverToggle && ( + + )} )}
- + {activeApp !== "claude-desktop" && ( + + )} - + {activeApp !== "claude-desktop" && ( + + )} )} diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index a10900248..f8c2dfafd 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -12,6 +12,7 @@ interface AppSwitcherProps { const ALL_APPS: AppId[] = [ "claude", + "claude-desktop", "codex", "gemini", "opencode", @@ -34,6 +35,7 @@ export function AppSwitcher({ const iconSize = 20; const appIconName: Record = { claude: "claude", + "claude-desktop": "claude", codex: "openai", gemini: "gemini", opencode: "opencode", @@ -42,6 +44,7 @@ export function AppSwitcher({ }; const appDisplayName: Record = { claude: "Claude", + "claude-desktop": "Claude Desktop", codex: "Codex", gemini: "Gemini", opencode: "OpenCode", diff --git a/src/components/common/AppCountBar.tsx b/src/components/common/AppCountBar.tsx index 4e80998ab..d4371b5fc 100644 --- a/src/components/common/AppCountBar.tsx +++ b/src/components/common/AppCountBar.tsx @@ -5,7 +5,7 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig"; interface AppCountBarProps { totalLabel: string; - counts: Record; + counts: Partial>; appIds?: AppId[]; } @@ -27,7 +27,7 @@ export const AppCountBar: React.FC = ({ className={APP_ICON_MAP[app].badgeClass} > {APP_ICON_MAP[app].label}: - {counts[app]} + {counts[app] ?? 0} ))}
diff --git a/src/components/common/AppToggleGroup.tsx b/src/components/common/AppToggleGroup.tsx index 928670ab7..c41eb1a12 100644 --- a/src/components/common/AppToggleGroup.tsx +++ b/src/components/common/AppToggleGroup.tsx @@ -8,7 +8,7 @@ import type { AppId } from "@/lib/api/types"; import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig"; interface AppToggleGroupProps { - apps: Record; + apps: Partial>; onToggle: (app: AppId, enabled: boolean) => void; appIds?: AppId[]; } diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index d83355a3e..754e9d954 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -58,6 +58,7 @@ const UnifiedMcpPanel = React.forwardRef< const enabledCounts = useMemo(() => { const counts = { claude: 0, + "claude-desktop": 0, codex: 0, gemini: 0, opencode: 0, diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 0a9c86f94..391670cae 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -32,6 +32,7 @@ const PromptFormModal: React.FC = ({ const appName = t(`apps.${appId}`); const filenameMap: Record, string> = { claude: "CLAUDE.md", + "claude-desktop": "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", opencode: "AGENTS.md", diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx index cdd80d0d8..1486dff06 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -26,6 +26,7 @@ const PromptFormPanel: React.FC = ({ const appName = t(`apps.${appId}`); const filenameMap: Record = { claude: "CLAUDE.md", + "claude-desktop": "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", opencode: "AGENTS.md", diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 049672715..209469b9d 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -62,6 +62,10 @@ interface ProviderCardProps { /** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */ function isOfficialProvider(provider: Provider, appId: AppId): boolean { + if (provider.category === "official") { + return true; + } + const config = provider.settingsConfig as Record; if (appId === "claude") { const baseUrl = config?.env?.ANTHROPIC_BASE_URL; @@ -318,6 +322,19 @@ export function ProviderCard({ )} + {appId === "claude-desktop" && + provider.category !== "official" && ( + + {provider.meta?.claudeDesktopMode === "proxy" + ? t("claudeDesktop.modeProxy", { + defaultValue: "模型映射", + }) + : t("claudeDesktop.modeDirect", { + defaultValue: "直连", + })} + + )} + {isProxyRunning && isInFailoverQueue && health && ( - {t("provider.importCurrent")} + {appId === "claude-desktop" + ? t("provider.importFromClaude", { + defaultValue: "从 Claude 导入兼容供应商", + }) + : t("provider.importCurrent")} )} {onCreate && ( diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index c1a96388b..a24a4cc66 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -13,7 +13,7 @@ import { type CSSProperties, } from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { Search, X } from "lucide-react"; +import { AlertTriangle, Download, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -195,6 +195,12 @@ export function ProviderList({ const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false); const [pendingTestProvider, setPendingTestProvider] = useState(null); + const { data: claudeDesktopStatus } = useQuery({ + queryKey: ["claudeDesktopStatus"], + queryFn: () => providersApi.getClaudeDesktopStatus(), + enabled: appId === "claude-desktop", + refetchInterval: appId === "claude-desktop" ? 5000 : false, + }); // Query settings for streamCheckConfirmed flag const { data: settings } = useQuery({ @@ -247,11 +253,18 @@ export function ProviderList({ const count = await providersApi.importHermesFromLive(); return count > 0; } + if (appId === "claude-desktop") { + const count = await providersApi.importClaudeDesktopFromClaude(); + return count > 0; + } return providersApi.importDefault(appId); }, onSuccess: (imported) => { if (imported) { queryClient.invalidateQueries({ queryKey: ["providers", appId] }); + if (appId === "claude-desktop") { + queryClient.invalidateQueries({ queryKey: ["claudeDesktopStatus"] }); + } toast.success(t("provider.importCurrentDescription")); } else { toast.info(t("provider.noProviders")); @@ -301,6 +314,74 @@ export function ProviderList({ }); }, [searchTerm, sortedProviders]); + const claudeDesktopStatusMessages = useMemo(() => { + if (appId !== "claude-desktop" || !claudeDesktopStatus) return []; + + const messages: string[] = []; + if (!claudeDesktopStatus.supported) { + messages.push( + t("claudeDesktop.statusUnsupported", { + defaultValue: "当前平台暂不支持 Claude Desktop 3P 配置写入。", + }), + ); + return messages; + } + + if (claudeDesktopStatus.staleRawModels) { + messages.push( + t("claudeDesktop.statusStaleRawModels", { + defaultValue: + "Claude Desktop profile 中存在非 claude-* 模型名,新版 Claude Desktop 可能拒绝加载;重新切换当前供应商可修复。", + }), + ); + } + if (claudeDesktopStatus.missingRouteMappings) { + messages.push( + t("claudeDesktop.statusMissingRouteMappings", { + defaultValue: + "当前供应商启用了模型映射,但没有有效路由;请编辑供应商并补全至少一个模型映射。", + }), + ); + } + if ( + claudeDesktopStatus.mode === "proxy" && + !claudeDesktopStatus.proxyRunning + ) { + messages.push( + t("claudeDesktop.statusProxyStopped", { + defaultValue: + "当前供应商需要 CC Switch 本地路由,但本地路由未运行;开启右上角路由开关后再重启 Claude Desktop。", + }), + ); + } + if ( + claudeDesktopStatus.mode === "proxy" && + !claudeDesktopStatus.gatewayTokenConfigured + ) { + messages.push( + t("claudeDesktop.statusGatewayTokenMissing", { + defaultValue: + "当前本地路由 token 尚未生成;重新切换该供应商会写入新的本地 token。", + }), + ); + } + + const expected = claudeDesktopStatus.expectedBaseUrl?.replace(/\/+$/, ""); + const actual = claudeDesktopStatus.actualBaseUrl?.replace(/\/+$/, ""); + if (expected && actual && expected !== actual) { + messages.push( + t("claudeDesktop.statusBaseUrlMismatch", { + expected, + actual, + defaultValue: + "Claude Desktop profile 指向的地址与当前供应商不一致;当前为 {{actual}},应为 {{expected}}。重新切换当前供应商可修复。", + }), + ); + } + + return messages; + }, [appId, claudeDesktopStatus, t]); + if (isLoading) { return (
@@ -400,6 +481,36 @@ export function ProviderList({ return (
+ {appId === "claude-desktop" && ( +
+ +
+ )} + {claudeDesktopStatusMessages.length > 0 && ( +
+
+ + {t("claudeDesktop.statusTitle", { + defaultValue: "Claude Desktop 配置需要检查", + })} +
+
    + {claudeDesktopStatusMessages.map((message) => ( +
  • {message}
  • + ))} +
+
+ )} {isSearchOpen && ( Promise | void; + onCancel: () => void; + onSubmittingChange?: (isSubmitting: boolean) => void; + initialData?: { + name?: string; + websiteUrl?: string; + notes?: string; + settingsConfig?: Record; + category?: ProviderCategory; + meta?: ProviderMeta; + icon?: string; + iconColor?: string; + }; + showButtons?: boolean; +} + +type RouteRow = { + route: string; + model: string; + displayName: string; + supports1m: boolean; +}; + +function envString( + settingsConfig: Record | undefined, + key: string, +) { + const env = settingsConfig?.env; + if (!env || typeof env !== "object") return ""; + const value = (env as Record)[key]; + return typeof value === "string" ? value : ""; +} + +function clonePlainRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return { ...(value as Record) }; +} + +function initialRouteRows( + routes: Record | undefined, +): RouteRow[] { + return Object.entries(routes ?? {}).map(([route, value]) => ({ + route, + model: value.model ?? "", + displayName: value.displayName ?? "", + supports1m: value.supports1m ?? false, + })); +} + +function isClaudeSafeRoute(route: string) { + const normalized = route.trim().toLowerCase(); + return ( + normalized.startsWith("claude-") || + normalized.startsWith("anthropic/claude-") + ); +} + +function defaultRouteRows( + defaults: ClaudeDesktopDefaultRoute[], + defaultModel: string, +): RouteRow[] { + return defaults.map((route, index) => ({ + route: route.routeId, + model: index === 0 ? defaultModel : "", + displayName: route.displayName, + supports1m: route.supports1m, + })); +} + +function nextRouteRow(current: RouteRow[], defaults: RouteRow[]): RouteRow { + return ( + defaults.find( + (route) => !current.some((existing) => existing.route === route.route), + ) ?? { + route: "", + model: "", + displayName: "", + supports1m: true, + } + ); +} + +export function ClaudeDesktopProviderForm({ + submitLabel, + onSubmit, + onCancel, + onSubmittingChange, + initialData, + showButtons = true, +}: ClaudeDesktopProviderFormProps) { + const { t } = useTranslation(); + const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct"; + const [mode, setMode] = useState<"direct" | "proxy">(initialMode); + const needsModelMapping = mode === "proxy"; + const [apiFormat, setApiFormat] = useState( + initialData?.meta?.apiFormat ?? "anthropic", + ); + const [baseUrl, setBaseUrl] = useState( + envString(initialData?.settingsConfig, "ANTHROPIC_BASE_URL"), + ); + const [apiKey, setApiKey] = useState( + envString(initialData?.settingsConfig, "ANTHROPIC_AUTH_TOKEN") || + envString(initialData?.settingsConfig, "ANTHROPIC_API_KEY"), + ); + const [routes, setRoutes] = useState(() => + initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes), + ); + const didSeedDefaultRoutes = useRef( + Object.keys(initialData?.meta?.claudeDesktopModelRoutes ?? {}).length > 0, + ); + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + const [directModelsExpanded, setDirectModelsExpanded] = useState( + initialMode === "direct" && + Object.keys(initialData?.meta?.claudeDesktopModelRoutes ?? {}).length > 0, + ); + const { data: defaultRoutes = [] } = useQuery({ + queryKey: ["claudeDesktopDefaultRoutes"], + queryFn: () => providersApi.getClaudeDesktopDefaultRoutes(), + }); + const defaultProxyRouteRows = useMemo( + () => + defaultRouteRows( + defaultRoutes, + envString(initialData?.settingsConfig, "ANTHROPIC_MODEL"), + ), + [defaultRoutes, initialData?.settingsConfig], + ); + + const defaultValues: ProviderFormData = useMemo( + () => ({ + name: initialData?.name ?? "", + websiteUrl: initialData?.websiteUrl ?? "", + notes: initialData?.notes ?? "", + settingsConfig: JSON.stringify( + initialData?.settingsConfig ?? { env: {} }, + null, + 2, + ), + icon: initialData?.icon ?? "", + iconColor: initialData?.iconColor ?? "", + }), + [initialData], + ); + + const form = useForm({ + resolver: zodResolver(providerSchema), + defaultValues, + mode: "onSubmit", + }); + + useEffect(() => { + onSubmittingChange?.(form.formState.isSubmitting || isFetchingModels); + }, [form.formState.isSubmitting, isFetchingModels, onSubmittingChange]); + + const updateRoute = (index: number, patch: Partial) => { + setRoutes((current) => + current.map((row, i) => (i === index ? { ...row, ...patch } : row)), + ); + }; + + const handleModelMappingChange = (checked: boolean) => { + setMode(checked ? "proxy" : "direct"); + if (checked) { + setRoutes((current) => { + if (current.length > 0 || defaultProxyRouteRows.length === 0) { + return current; + } + didSeedDefaultRoutes.current = true; + return defaultProxyRouteRows; + }); + } + }; + + useEffect(() => { + if ( + didSeedDefaultRoutes.current || + mode !== "proxy" || + routes.length > 0 || + defaultProxyRouteRows.length === 0 + ) { + return; + } + + didSeedDefaultRoutes.current = true; + setRoutes(defaultProxyRouteRows); + }, [defaultProxyRouteRows, mode, routes.length]); + + const handleFetchModels = async () => { + if (!baseUrl.trim() || !apiKey.trim()) { + showFetchModelsError(null, t, { + hasBaseUrl: Boolean(baseUrl.trim()), + hasApiKey: Boolean(apiKey.trim()), + }); + return; + } + + setIsFetchingModels(true); + try { + const models = await fetchModelsForConfig(baseUrl.trim(), apiKey.trim()); + setFetchedModels(models); + toast.success( + t("providerForm.fetchModelsSuccess", { + count: models.length, + defaultValue: `已获取 ${models.length} 个模型`, + }), + ); + } catch (error) { + showFetchModelsError(error, t, { + hasBaseUrl: Boolean(baseUrl.trim()), + hasApiKey: Boolean(apiKey.trim()), + }); + } finally { + setIsFetchingModels(false); + } + }; + + const handleSubmit = async (values: ProviderFormData) => { + if (!values.name.trim()) { + toast.error( + t("providerForm.fillSupplierName", { + defaultValue: "请填写供应商名称", + }), + ); + return; + } + if (!baseUrl.trim()) { + toast.error( + t("providerForm.fetchModelsNeedEndpoint", { + defaultValue: "请先填写接口地址", + }), + ); + return; + } + if (!apiKey.trim()) { + toast.error( + t("providerForm.fetchModelsNeedApiKey", { + defaultValue: "请先填写 API Key", + }), + ); + return; + } + + const routeEntries = routes + .map((route) => ({ + ...route, + route: route.route.trim(), + model: route.model.trim(), + displayName: route.displayName.trim(), + })) + .filter((route) => route.route || route.model); + + if (mode === "proxy") { + const invalid = routeEntries.find( + (route) => + !route.route || !route.model || !isClaudeSafeRoute(route.route), + ); + if (invalid) { + toast.error( + t("claudeDesktop.routeInvalid", { + defaultValue: + "模型映射必须填写 claude-* / anthropic/claude-* 路由名和上游模型名", + }), + ); + return; + } + if (routeEntries.length === 0) { + toast.error( + t("claudeDesktop.routesRequired", { + defaultValue: "需要模型映射时至少填写一个模型路由", + }), + ); + return; + } + } else { + const invalid = routeEntries.find( + (route) => !route.route || !isClaudeSafeRoute(route.route), + ); + if (invalid) { + toast.error( + t("claudeDesktop.directModelInvalid", { + defaultValue: + "直连模型必须使用 claude-* / anthropic/claude-* 模型名", + }), + ); + return; + } + } + + const settingsConfig = clonePlainRecord(initialData?.settingsConfig); + const env = clonePlainRecord(settingsConfig.env); + settingsConfig.env = { + ...env, + ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""), + ANTHROPIC_AUTH_TOKEN: apiKey.trim(), + }; + + const routeMap = routeEntries.reduce< + Record + >((acc, route) => { + acc[route.route] = { + model: route.model || route.route, + displayName: route.displayName || undefined, + supports1m: route.supports1m || undefined, + }; + return acc; + }, {}); + + const meta: ProviderMeta = { + ...(initialData?.meta ?? {}), + claudeDesktopMode: mode, + apiFormat: mode === "proxy" ? apiFormat : "anthropic", + }; + + meta.claudeDesktopModelRoutes = routeMap; + + delete meta.endpointAutoSelect; + delete meta.providerType; + delete meta.isFullUrl; + + await onSubmit({ + ...values, + name: values.name.trim(), + websiteUrl: values.websiteUrl?.trim() ?? "", + notes: values.notes?.trim() ?? "", + settingsConfig: JSON.stringify(settingsConfig, null, 2), + meta, + }); + }; + + return ( +
+ + + +
+
+ + setBaseUrl(event.target.value)} + placeholder="https://api.example.com" + /> +
+
+ + setApiKey(event.target.value)} + type="password" + placeholder="sk-..." + /> +
+
+ +
+
+
+ +

+ {needsModelMapping + ? t("claudeDesktop.modelMappingOnHint", { + defaultValue: + "Claude Desktop 只看到 claude-* 路由名,CC Switch 本地路由会映射到真实上游模型;需要保持本地路由运行。", + }) + : t("claudeDesktop.modelMappingOffHint", { + defaultValue: + "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。", + })} +

+
+ +
+
+ + {needsModelMapping && ( +
+
+
+ + +
+
+ +
+
+ + + {fetchedModels.map((model) => ( + + +
+
+
+ +
+

+ {t("claudeDesktop.routeMapHint", { + defaultValue: + "左侧是 Claude Desktop 看到的 claude-* 模型名,右侧是真实发送给供应商的上游模型名。", + })} +

+
+ +
+ + {t("claudeDesktop.routeModelLabel", { + defaultValue: "Desktop 模型", + })} + + + {t("claudeDesktop.upstreamModelLabel", { + defaultValue: "上游模型", + })} + + + {t("claudeDesktop.displayNameLabel", { + defaultValue: "显示名", + })} + + + {t("claudeDesktop.supports1mLabel", { + defaultValue: "1M", + })} + + +
+ {routes.map((route, index) => ( +
+ + updateRoute(index, { route: event.target.value }) + } + placeholder="claude-sonnet-4-6" + /> + + updateRoute(index, { model: event.target.value }) + } + list="claude-desktop-upstream-models" + placeholder="kimi-k2 / deepseek-chat" + /> + + updateRoute(index, { displayName: event.target.value }) + } + placeholder="Sonnet" + /> + + +
+ ))} +
+ + +
+ )} + + {!needsModelMapping && ( + + + + + {!directModelsExpanded && ( +

+ {t("claudeDesktop.directModelListCollapsedHint", { + defaultValue: + "原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。", + })} +

+ )} + +
+
+

+ {t("claudeDesktop.directModelListHint", { + defaultValue: + "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。", + })} +

+ +
+ + + {fetchedModels.map((model) => ( + + + {routes.length > 0 ? ( +
+ {routes.map((route, index) => ( +
+ + updateRoute(index, { route: event.target.value }) + } + list="claude-desktop-direct-models" + placeholder="claude-deepseek-chat" + /> + + +
+ ))} +
+ ) : null} + + +
+
+
+ )} + + ( + + + + + + + )} + /> + + {showButtons && ( +
+ + +
+ )} + + + ); +} diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 881281b82..c2504c845 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -103,6 +103,7 @@ interface ClaudeFormFieldsProps { onCustomEndpointsChange?: (endpoints: string[]) => void; autoSelect: boolean; onAutoSelectChange: (checked: boolean) => void; + showEndpointTools?: boolean; // Model Selector shouldShowModelSelector: boolean; @@ -167,6 +168,7 @@ export function ClaudeFormFields({ onCustomEndpointsChange, autoSelect, onAutoSelectChange, + showEndpointTools = true, shouldShowModelSelector, claudeModel, defaultHaikuModel, @@ -464,15 +466,18 @@ export function ClaudeFormFields({ ? t("providerForm.fullUrlHintGeminiNative") : undefined } - onManageClick={() => onEndpointModalToggle(true)} - showFullUrlToggle={true} + showManageButton={showEndpointTools} + onManageClick={ + showEndpointTools ? () => onEndpointModalToggle(true) : undefined + } + showFullUrlToggle={showEndpointTools} isFullUrl={isFullUrl} onFullUrlChange={onFullUrlChange} /> )} {/* 端点测速弹窗 */} - {shouldShowSpeedTest && isEndpointModalOpen && ( + {shouldShowSpeedTest && showEndpointTools && isEndpointModalOpen && ( = { codex: 12, claude: 8, + "claude-desktop": 8, gemini: 8, opencode: 8, openclaw: 8, diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 836225ff1..08d5cf2c7 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -59,6 +59,7 @@ import { Label } from "@/components/ui/label"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; import { BasicFormFields } from "./BasicFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields"; +import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm"; import { CodexFormFields } from "./CodexFormFields"; import { GeminiFormFields } from "./GeminiFormFields"; import { OmoFormFields } from "./OmoFormFields"; @@ -115,7 +116,7 @@ type PresetEntry = { | HermesProviderPreset; }; -interface ProviderFormProps { +export interface ProviderFormProps { appId: AppId; providerId?: string; submitLabel: string; @@ -137,7 +138,15 @@ interface ProviderFormProps { showButtons?: boolean; } -export function ProviderForm({ +export function ProviderForm(props: ProviderFormProps) { + if (props.appId === "claude-desktop") { + return ; + } + + return ; +} + +function ProviderFormFull({ appId, providerId, submitLabel, @@ -149,6 +158,10 @@ export function ProviderForm({ initialData, showButtons = true, }: ProviderFormProps) { + if (appId === "claude-desktop") { + throw new Error("ProviderFormFull should not receive claude-desktop"); + } + const { t } = useTranslation(); const isEditMode = Boolean(initialData); const queryClient = useQueryClient(); @@ -1165,6 +1178,7 @@ export function ProviderForm({ ? useGeminiCommonConfigFlag : undefined, endpointAutoSelect, + claudeDesktopMode: undefined, // 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商) providerType, authBinding: isCopilotProvider @@ -1797,6 +1811,7 @@ export function ProviderForm({ } autoSelect={endpointAutoSelect} onAutoSelectChange={setEndpointAutoSelect} + showEndpointTools shouldShowModelSelector={category !== "official"} claudeModel={claudeModel} defaultHaikuModel={defaultHaikuModel} diff --git a/src/components/providers/forms/helpers/opencodeFormUtils.ts b/src/components/providers/forms/helpers/opencodeFormUtils.ts index 0a91f287a..a24361628 100644 --- a/src/components/providers/forms/helpers/opencodeFormUtils.ts +++ b/src/components/providers/forms/helpers/opencodeFormUtils.ts @@ -4,6 +4,16 @@ import type { PricingModelSourceOption } from "../ProviderAdvancedConfig"; // ── Default configs ────────────────────────────────────────────────── export const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2); +export const CLAUDE_DESKTOP_DEFAULT_CONFIG = JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: "", + ANTHROPIC_AUTH_TOKEN: "", + }, + }, + null, + 2, +); export const CODEX_DEFAULT_CONFIG = JSON.stringify( { auth: {}, config: "" }, null, diff --git a/src/components/providers/forms/hooks/useBaseUrlState.ts b/src/components/providers/forms/hooks/useBaseUrlState.ts index 2944dfdf3..05d9d7c8a 100644 --- a/src/components/providers/forms/hooks/useBaseUrlState.ts +++ b/src/components/providers/forms/hooks/useBaseUrlState.ts @@ -32,9 +32,9 @@ export function useBaseUrlState({ const [geminiBaseUrl, setGeminiBaseUrl] = useState(""); const isUpdatingRef = useRef(false); - // 从配置同步到 state(Claude) + // 从配置同步到 state(Claude / Claude Desktop) useEffect(() => { - if (appType !== "claude") return; + if (appType !== "claude" && appType !== "claude-desktop") return; // 只有 official 类别不显示 Base URL 输入框,其他类别都需要回填 if (category === "official") return; if (isUpdatingRef.current) return; diff --git a/src/components/proxy/ClaudeDesktopRouteToggle.tsx b/src/components/proxy/ClaudeDesktopRouteToggle.tsx new file mode 100644 index 000000000..cc924d0fc --- /dev/null +++ b/src/components/proxy/ClaudeDesktopRouteToggle.tsx @@ -0,0 +1,96 @@ +import { Loader2, Radio } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { Switch } from "@/components/ui/switch"; +import { useProxyStatus } from "@/hooks/useProxyStatus"; +import { cn } from "@/lib/utils"; + +interface ClaudeDesktopRouteToggleProps { + className?: string; +} + +export function ClaudeDesktopRouteToggle({ + className, +}: ClaudeDesktopRouteToggleProps) { + const { t } = useTranslation(); + const { + isRunning, + status, + takeoverStatus, + startProxyServer, + stopProxyServer, + isStarting, + isStoppingServer, + } = useProxyStatus(); + + const isBusy = isStarting || isStoppingServer; + const otherTakeoverActive = Boolean( + takeoverStatus?.claude || takeoverStatus?.codex || takeoverStatus?.gemini, + ); + const routeAddress = status?.address ?? "127.0.0.1"; + const routePort = status?.port ?? 15721; + + const handleToggle = async (checked: boolean) => { + try { + if (checked) { + await startProxyServer(); + return; + } + + if (otherTakeoverActive) { + toast.warning( + t("claudeDesktop.route.stopBlockedByTakeover", { + defaultValue: + "其它应用正在使用代理接管。请先在设置中关闭对应应用接管,再停止本地路由。", + }), + { duration: 5000 }, + ); + return; + } + + await stopProxyServer(); + } catch (error) { + console.error("[ClaudeDesktopRouteToggle] Toggle route failed:", error); + } + }; + + const tooltipText = isRunning + ? t("claudeDesktop.route.tooltip.active", { + address: routeAddress, + port: routePort, + defaultValue: `Claude Desktop 本地路由已开启 - ${routeAddress}:${routePort}`, + }) + : t("claudeDesktop.route.tooltip.inactive", { + address: routeAddress, + port: routePort, + defaultValue: `开启 Claude Desktop 本地路由,用于需要模型映射或格式转换的供应商。当前配置地址:${routeAddress}:${routePort}`, + }); + + return ( +
+ {isBusy ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx index 98f1bdd47..0029e0c47 100644 --- a/src/components/settings/AppVisibilitySettings.tsx +++ b/src/components/settings/AppVisibilitySettings.tsx @@ -17,6 +17,11 @@ const APP_CONFIG: Array<{ nameKey: string; }> = [ { id: "claude", icon: "claude", nameKey: "apps.claude" }, + { + id: "claude-desktop", + icon: "claude", + nameKey: "apps.claudeDesktop", + }, { id: "codex", icon: "openai", nameKey: "apps.codex" }, { id: "gemini", icon: "gemini", nameKey: "apps.gemini" }, { id: "opencode", icon: "opencode", nameKey: "apps.opencode" }, @@ -32,6 +37,7 @@ export function AppVisibilitySettings({ const visibleApps: VisibleApps = settings.visibleApps ?? { claude: true, + "claude-desktop": true, codex: true, gemini: true, opencode: true, @@ -114,7 +120,7 @@ function AppButton({ size="sm" variant={active ? "default" : "ghost"} className={cn( - "w-[90px] gap-1.5", + "min-w-[90px] w-auto gap-1.5 px-3", active ? "shadow-sm" : "text-muted-foreground hover:text-foreground hover:bg-muted", diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index 408a56901..5966a1268 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -6,6 +6,8 @@ import { useTranslation } from "react-i18next"; import type { AppId } from "@/lib/api"; import type { ResolvedDirectories } from "@/hooks/useSettings"; +type DirectoryAppId = Exclude; + interface DirectorySettingsProps { appConfigDir?: string; resolvedDirs: ResolvedDirectories; @@ -18,9 +20,9 @@ interface DirectorySettingsProps { opencodeDir?: string; openclawDir?: string; hermesDir?: string; - onDirectoryChange: (app: AppId, value?: string) => void; - onBrowseDirectory: (app: AppId) => Promise; - onResetDirectory: (app: AppId) => Promise; + onDirectoryChange: (app: DirectoryAppId, value?: string) => void; + onBrowseDirectory: (app: DirectoryAppId) => Promise; + onResetDirectory: (app: DirectoryAppId) => Promise; } export function DirectorySettings({ diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 215e3cbca..309c17bf9 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -115,6 +115,7 @@ const UnifiedSkillsPanel = React.forwardRef< const enabledCounts = useMemo(() => { const counts = { claude: 0, + "claude-desktop": 0, codex: 0, gemini: 0, opencode: 0, diff --git a/src/config/appConfig.tsx b/src/config/appConfig.tsx index ea4a58234..a2103a926 100644 --- a/src/config/appConfig.tsx +++ b/src/config/appConfig.tsx @@ -17,6 +17,7 @@ export interface AppConfig { export const APP_IDS: AppId[] = [ "claude", + "claude-desktop", "codex", "gemini", "opencode", @@ -45,6 +46,14 @@ export const APP_ICON_MAP: Record = { badgeClass: "bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5", }, + "claude-desktop": { + label: "Claude Desktop", + icon: , + activeClass: + "bg-amber-500/10 ring-1 ring-amber-500/20 hover:bg-amber-500/20 text-amber-700 dark:text-amber-300", + badgeClass: + "bg-amber-500/10 text-amber-700 dark:text-amber-300 hover:bg-amber-500/20 border-0 gap-1.5", + }, codex: { label: "Codex", icon: , diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index d6fff53f5..70347328a 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -5,6 +5,7 @@ import { homeDir, join } from "@tauri-apps/api/path"; import { settingsApi, type AppId } from "@/lib/api"; import type { SettingsFormState } from "./useSettingsForm"; +export type DirectoryAppId = Exclude; type AppDirectoryKey = | "claude" | "codex" @@ -26,7 +27,7 @@ export interface ResolvedDirectories { // Single source of truth for per-app directory metadata. const APP_DIRECTORY_META: Record< - AppId, + DirectoryAppId, { key: AppDirectoryKey; defaultFolder: string } > = { claude: { key: "claude", defaultFolder: ".claude" }, @@ -69,7 +70,7 @@ const computeDefaultAppConfigDir = async (): Promise => { }; const computeDefaultConfigDir = async ( - app: AppId, + app: DirectoryAppId, ): Promise => { try { const home = await homeDir(); @@ -93,11 +94,11 @@ export interface UseDirectorySettingsResult { resolvedDirs: ResolvedDirectories; isLoading: boolean; initialAppConfigDir?: string; - updateDirectory: (app: AppId, value?: string) => void; + updateDirectory: (app: DirectoryAppId, value?: string) => void; updateAppConfigDir: (value?: string) => void; - browseDirectory: (app: AppId) => Promise; + browseDirectory: (app: DirectoryAppId) => Promise; browseAppConfigDir: () => Promise; - resetDirectory: (app: AppId) => Promise; + resetDirectory: (app: DirectoryAppId) => Promise; resetAppConfigDir: () => Promise; resetAllDirectories: (overrides?: ResolvedAppDirectoryOverrides) => void; } @@ -259,14 +260,14 @@ export function useDirectorySettings({ ); const updateDirectory = useCallback( - (app: AppId, value?: string) => { + (app: DirectoryAppId, value?: string) => { updateDirectoryState(APP_DIRECTORY_META[app].key, value); }, [updateDirectoryState], ); const browseDirectory = useCallback( - async (app: AppId) => { + async (app: DirectoryAppId) => { const key = APP_DIRECTORY_META[app].key; const settingsField = DIRECTORY_KEY_TO_SETTINGS_FIELD[key]; const currentValue = @@ -310,7 +311,7 @@ export function useDirectorySettings({ }, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]); const resetDirectory = useCallback( - async (app: AppId) => { + async (app: DirectoryAppId) => { const key = APP_DIRECTORY_META[app].key; if (!defaultsRef.current[key]) { const fallback = await computeDefaultConfigDir(app); diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 0f6b4cbaf..2e02ebbf4 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -171,6 +171,13 @@ export function useProviderActions( proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", { defaultValue: "使用 OpenAI Responses 接口格式", }); + } else if ( + activeApp === "claude-desktop" && + provider.meta?.claudeDesktopMode === "proxy" + ) { + proxyRequiredReason = t("notifications.proxyReasonClaudeDesktop", { + defaultValue: "使用 Claude Desktop 本地路由模式", + }); } else if ( provider.meta?.isFullUrl && (activeApp === "claude" || activeApp === "codex") @@ -223,12 +230,22 @@ export function useProviderActions( // OpenCode/OpenClaw: show "added to config" message instead of "switched" const isMultiProviderApp = activeApp === "opencode" || activeApp === "openclaw"; - const messageKey = isMultiProviderApp - ? "notifications.addToConfigSuccess" - : "notifications.switchSuccess"; - const defaultMessage = isMultiProviderApp - ? "已添加到配置" - : "切换成功!"; + const messageKey = + activeApp === "claude-desktop" + ? provider.meta?.claudeDesktopMode === "proxy" + ? "notifications.claudeDesktopProxyRestartRequired" + : "notifications.claudeDesktopRestartRequired" + : isMultiProviderApp + ? "notifications.addToConfigSuccess" + : "notifications.switchSuccess"; + const defaultMessage = + activeApp === "claude-desktop" + ? provider.meta?.claudeDesktopMode === "proxy" + ? "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效" + : "切换成功,重启 Claude Desktop 后生效" + : isMultiProviderApp + ? "已添加到配置" + : "切换成功!"; toast.success(t(messageKey, { defaultValue: defaultMessage }), { closeButton: true, diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index d193b5972..072c9b884 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -64,6 +64,31 @@ export function useProxyStatus() { }, }); + // 停止服务器(仅停止服务,不改写/恢复其它应用接管状态) + const stopProxyServerMutation = useMutation({ + mutationFn: () => invoke("stop_proxy_server"), + onSuccess: () => { + toast.success( + t("proxy.server.stopped", { + defaultValue: "代理服务已停止", + }), + { closeButton: true }, + ); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + const detail = + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); + toast.error( + t("proxy.server.stopFailed", { + detail, + defaultValue: `停止代理服务失败: ${detail}`, + }), + ); + }, + }); + // 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置) const stopWithRestoreMutation = useMutation({ mutationFn: () => invoke("stop_proxy_with_restore"), @@ -194,6 +219,7 @@ export function useProxyStatus() { // 启动/停止(总开关) startProxyServer: startProxyServerMutation.mutateAsync, + stopProxyServer: stopProxyServerMutation.mutateAsync, stopWithRestore: stopWithRestoreMutation.mutateAsync, // 按应用接管开关 @@ -208,9 +234,11 @@ export function useProxyStatus() { // 加载状态 isStarting: startProxyServerMutation.isPending, + isStoppingServer: stopProxyServerMutation.isPending, isStopping: stopWithRestoreMutation.isPending, isPending: startProxyServerMutation.isPending || + stopProxyServerMutation.isPending || stopWithRestoreMutation.isPending || setTakeoverForAppMutation.isPending, }; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 2982eb1f9..558408900 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -2,13 +2,14 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; -import { providersApi, settingsApi, type AppId } from "@/lib/api"; +import { providersApi, settingsApi } from "@/lib/api"; import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync"; import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query"; import type { Settings } from "@/types"; import { useSettingsForm, type SettingsFormState } from "./useSettingsForm"; import { useDirectorySettings, + type DirectoryAppId, type ResolvedDirectories, } from "./useDirectorySettings"; import { useSettingsMetadata } from "./useSettingsMetadata"; @@ -28,11 +29,11 @@ export interface UseSettingsResult { resolvedDirs: ResolvedDirectories; requiresRestart: boolean; updateSettings: (updates: Partial) => void; - updateDirectory: (app: AppId, value?: string) => void; + updateDirectory: (app: DirectoryAppId, value?: string) => void; updateAppConfigDir: (value?: string) => void; - browseDirectory: (app: AppId) => Promise; + browseDirectory: (app: DirectoryAppId) => Promise; browseAppConfigDir: () => Promise; - resetDirectory: (app: AppId) => Promise; + resetDirectory: (app: DirectoryAppId) => Promise; resetAppConfigDir: () => Promise; saveSettings: ( overrides?: Partial, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a8f7a49a9..7e19442c1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -104,6 +104,7 @@ "noProvidersDescription": "If you already have a config, click \"Import Current Config\" — all data will be safely saved in a default provider", "noProvidersDescriptionSnippet": "Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers", "importCurrent": "Import Current Config", + "importFromClaude": "Import Compatible Providers from Claude", "importCurrentDescription": "Import current live config as default provider", "currentlyUsing": "Currently Using", "enable": "Enable", @@ -172,11 +173,50 @@ } } }, + "claudeDesktop": { + "mode": "Model handling", + "modeDirect": "Direct", + "modeProxy": "Model mapping", + "gatewayBaseUrl": "Gateway Base URL", + "bearerToken": "Bearer Token", + "modelMappingToggle": "Needs model mapping", + "modelMappingOffHint": "Use this when the provider already exposes and accepts claude-* / anthropic/claude-* model IDs through Anthropic Messages. Claude Desktop connects to the provider directly.", + "modelMappingOnHint": "Claude Desktop only sees claude-* route names. CC Switch local routing maps them to the real upstream models, so local routing must stay running.", + "routeMapTitle": "Model mapping", + "routeMapHint": "The left side is the claude-* model ID shown to Claude Desktop; the right side is the real upstream model sent to the provider. Checking 1M appends [1M] to both model IDs.", + "routeModelLabel": "Desktop model", + "upstreamModelLabel": "Upstream model", + "displayNameLabel": "Display name", + "supports1mLabel": "1M", + "directModelListTitle": "Manually specify Claude Desktop models (advanced, optional)", + "directModelListCollapsedHint": "Native Claude model providers usually do not need this. Claude Desktop will fetch /v1/models automatically.", + "directModelListHint": "Only fill this when the provider's /v1/models is unavailable or does not return Claude Desktop-safe claude-* model IDs. Checking 1M writes the [1M] marker after the model ID.", + "directModelInvalid": "Direct models must use claude-* / anthropic/claude-* model IDs", + "addModel": "Add model", + "addRoute": "Add route", + "routeInvalid": "Model mapping requires a claude-* / anthropic/claude-* route name and an upstream model name", + "routesRequired": "At least one model route is required when model mapping is enabled", + "statusTitle": "Claude Desktop configuration needs attention", + "statusUnsupported": "This platform does not support writing Claude Desktop 3P configuration yet.", + "statusStaleRawModels": "The Claude Desktop profile contains non-claude-* model IDs. Newer Claude Desktop versions may reject it; switch to the current provider again to repair it.", + "statusMissingRouteMappings": "The current provider has model mapping enabled but no valid routes. Edit the provider and add at least one mapping.", + "statusProxyStopped": "The current provider needs CC Switch local routing, but local routing is not running. Turn on the route switch in the top-right, then restart Claude Desktop.", + "statusGatewayTokenMissing": "The local routing token has not been generated yet. Switching to this provider again will write a new local token.", + "statusBaseUrlMismatch": "The Claude Desktop profile points to a different URL than the current provider. Current: {{actual}}; expected: {{expected}}. Switch to the current provider again to repair it.", + "route": { + "tooltip": { + "active": "Claude Desktop local routing is running - {{address}}:{{port}}", + "inactive": "Turn on Claude Desktop local routing for providers that need model mapping or format conversion. Configured address: {{address}}:{{port}}" + } + } + }, "notifications": { "providerAdded": "Provider added", "providerSaved": "Provider configuration saved", "providerDeleted": "Provider deleted successfully", "switchSuccess": "Switch successful!", + "claudeDesktopRestartRequired": "Switched successfully. Restart Claude Desktop to apply changes.", + "claudeDesktopProxyRestartRequired": "Switched successfully. Keep CC Switch running and restart Claude Desktop to apply changes.", "addToConfigSuccess": "Added to config", "removeFromConfigSuccess": "Removed from config", "switchFailedTitle": "Switch failed", @@ -661,6 +701,7 @@ }, "apps": { "claude": "Claude", + "claudeDesktop": "Claude Desktop", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cf5be2612..528e9ad1e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -104,6 +104,7 @@ "noProvidersDescription": "既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます", "noProvidersDescriptionSnippet": "API キーとリクエスト URL 以外のデータ(プラグインなど)は共通設定スニペットに保存され、プロバイダー間で共有できます", "importCurrent": "現在の設定をインポート", + "importFromClaude": "Claude から互換プロバイダーをインポート", "importCurrentDescription": "現在使用中の設定をデフォルトプロバイダーとしてインポート", "currentlyUsing": "現在使用中", "enable": "有効化", @@ -172,11 +173,50 @@ } } }, + "claudeDesktop": { + "mode": "モデル処理方式", + "modeDirect": "直結", + "modeProxy": "モデルマッピング", + "gatewayBaseUrl": "Gateway Base URL", + "bearerToken": "Bearer Token", + "modelMappingToggle": "モデルマッピングが必要", + "modelMappingOffHint": "プロバイダーが Anthropic Messages で claude-* / anthropic/claude-* のモデル ID を公開し、そのまま受け付ける場合に使います。Claude Desktop はプロバイダーへ直接接続します。", + "modelMappingOnHint": "Claude Desktop には claude-* のルート名だけを見せ、CC Switch のローカルルーティングが実際の上流モデルへマッピングします。ローカルルーティングを起動したままにしてください。", + "routeMapTitle": "モデルマッピング", + "routeMapHint": "左側は Claude Desktop に表示する claude-* モデル ID、右側はプロバイダーへ送る実際の上流モデルです。1M をオンにすると両方のモデル ID に [1M] を追加します。", + "routeModelLabel": "Desktop モデル", + "upstreamModelLabel": "上流モデル", + "displayNameLabel": "表示名", + "supports1mLabel": "1M", + "directModelListTitle": "Claude Desktop モデルを手動指定(高度・任意)", + "directModelListCollapsedHint": "ネイティブ Claude モデルのプロバイダーでは通常不要です。Claude Desktop が /v1/models を自動取得します。", + "directModelListHint": "プロバイダーの /v1/models が使えない、または Claude Desktop が認識できる claude-* モデル ID を返さない場合だけ入力してください。1M をオンにするとモデル ID の後ろに [1M] マーカーを書き込みます。", + "directModelInvalid": "直結モデルは claude-* / anthropic/claude-* のモデル ID を使う必要があります", + "addModel": "モデルを追加", + "addRoute": "ルートを追加", + "routeInvalid": "モデルマッピングには claude-* / anthropic/claude-* のルート名と上流モデル名が必要です", + "routesRequired": "モデルマッピングを有効にする場合は、少なくとも 1 つのモデルルートが必要です", + "statusTitle": "Claude Desktop 設定の確認が必要です", + "statusUnsupported": "このプラットフォームでは Claude Desktop 3P 設定の書き込みはまだサポートされていません。", + "statusStaleRawModels": "Claude Desktop profile に claude-* ではないモデル ID が含まれています。新しい Claude Desktop では拒否される可能性があります。現在のプロバイダーへ再度切り替えると修復できます。", + "statusMissingRouteMappings": "現在のプロバイダーはモデルマッピングを有効にしていますが、有効なルートがありません。プロバイダーを編集し、少なくとも 1 つのマッピングを追加してください。", + "statusProxyStopped": "現在のプロバイダーには CC Switch のローカルルーティングが必要ですが、起動していません。右上のルートスイッチをオンにしてから Claude Desktop を再起動してください。", + "statusGatewayTokenMissing": "ローカルルーティング token がまだ生成されていません。このプロバイダーへ再度切り替えると新しい token が書き込まれます。", + "statusBaseUrlMismatch": "Claude Desktop profile の URL が現在のプロバイダーと一致しません。現在: {{actual}}、期待値: {{expected}}。現在のプロバイダーへ再度切り替えると修復できます。", + "route": { + "tooltip": { + "active": "Claude Desktop ローカルルーティングは起動中です - {{address}}:{{port}}", + "inactive": "モデルマッピングや形式変換が必要なプロバイダー向けに Claude Desktop ローカルルーティングを起動します。設定アドレス: {{address}}:{{port}}" + } + } + }, "notifications": { "providerAdded": "プロバイダーを追加しました", "providerSaved": "プロバイダー設定を保存しました", "providerDeleted": "プロバイダーを削除しました", "switchSuccess": "切り替え成功!", + "claudeDesktopRestartRequired": "切り替えました。反映するには Claude Desktop を再起動してください", + "claudeDesktopProxyRestartRequired": "切り替えました。CC Switch を起動したまま Claude Desktop を再起動してください", "addToConfigSuccess": "設定に追加しました", "removeFromConfigSuccess": "設定から削除しました", "switchFailedTitle": "切り替えに失敗しました", @@ -661,6 +701,7 @@ }, "apps": { "claude": "Claude", + "claudeDesktop": "Claude Desktop", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6b8dfb73f..86d48fe9d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -104,6 +104,7 @@ "noProvidersDescription": "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商中", "noProvidersDescriptionSnippet": "除 Key 和请求地址外的数据(如插件配置)会被保存到通用配置片段,用于在不同供应商之间共享", "importCurrent": "导入当前配置", + "importFromClaude": "从 Claude 导入兼容供应商", "importCurrentDescription": "将当前正在使用的配置导入为默认供应商", "currentlyUsing": "当前使用", "enable": "启用", @@ -172,11 +173,50 @@ } } }, + "claudeDesktop": { + "mode": "模型处理方式", + "modeDirect": "直连", + "modeProxy": "模型映射", + "gatewayBaseUrl": "Gateway Base URL", + "bearerToken": "Bearer Token", + "modelMappingToggle": "需要模型映射", + "modelMappingOffHint": "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。", + "modelMappingOnHint": "Claude Desktop 只看到 claude-* 路由名,CC Switch 本地路由会映射到真实上游模型;需要保持本地路由运行。", + "routeMapTitle": "模型映射", + "routeMapHint": "左侧是 Claude Desktop 看到的 claude-* 模型名,右侧是真实发送给供应商的上游模型名;勾选 1M 会在两侧模型名后追加 [1M]。", + "routeModelLabel": "Desktop 模型", + "upstreamModelLabel": "上游模型", + "displayNameLabel": "显示名", + "supports1mLabel": "1M", + "directModelListTitle": "手动指定 Claude Desktop 模型列表(高级,可选)", + "directModelListCollapsedHint": "原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。", + "directModelListHint": "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;勾选 1M 会在模型名后写入 [1M] 标记。", + "directModelInvalid": "直连模型必须使用 claude-* / anthropic/claude-* 模型名", + "addModel": "添加模型", + "addRoute": "添加路由", + "routeInvalid": "模型映射必须填写 claude-* / anthropic/claude-* 路由名和上游模型名", + "routesRequired": "需要模型映射时至少填写一个模型路由", + "statusTitle": "Claude Desktop 配置需要检查", + "statusUnsupported": "当前平台暂不支持 Claude Desktop 3P 配置写入。", + "statusStaleRawModels": "Claude Desktop profile 中存在非 claude-* 模型名,新版 Claude Desktop 可能拒绝加载;重新切换当前供应商可修复。", + "statusMissingRouteMappings": "当前供应商启用了模型映射,但没有有效路由;请编辑供应商并补全至少一个模型映射。", + "statusProxyStopped": "当前供应商需要 CC Switch 本地路由,但本地路由未运行;开启右上角路由开关后再重启 Claude Desktop。", + "statusGatewayTokenMissing": "当前本地路由 token 尚未生成;重新切换该供应商会写入新的本地 token。", + "statusBaseUrlMismatch": "Claude Desktop profile 指向的地址与当前供应商不一致;当前为 {{actual}},应为 {{expected}}。重新切换当前供应商可修复。", + "route": { + "tooltip": { + "active": "Claude Desktop 本地路由已开启 - {{address}}:{{port}}", + "inactive": "开启 Claude Desktop 本地路由,用于需要模型映射或格式转换的供应商。当前配置地址:{{address}}:{{port}}" + } + } + }, "notifications": { "providerAdded": "供应商已添加", "providerSaved": "供应商配置已保存", "providerDeleted": "供应商删除成功", "switchSuccess": "切换成功!", + "claudeDesktopRestartRequired": "切换成功,重启 Claude Desktop 后生效", + "claudeDesktopProxyRestartRequired": "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效", "addToConfigSuccess": "已添加到配置", "removeFromConfigSuccess": "已从配置移除", "switchFailedTitle": "切换失败", @@ -661,6 +701,7 @@ }, "apps": { "claude": "Claude", + "claudeDesktop": "Claude Desktop", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index ba7bd2e77..433c5f2bd 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -25,6 +25,28 @@ export interface OpenTerminalOptions { cwd?: string; } +export interface ClaudeDesktopStatus { + supported: boolean; + configured: boolean; + appliedId?: string | null; + profilePath?: string | null; + configLibraryPath?: string | null; + mode?: "direct" | "proxy" | null; + expectedBaseUrl?: string | null; + actualBaseUrl?: string | null; + proxyRunning: boolean; + staleRawModels: boolean; + missingRouteMappings: boolean; + gatewayTokenConfigured: boolean; +} + +export interface ClaudeDesktopDefaultRoute { + routeId: string; + envKey: string; + displayName: string; + supports1m: boolean; +} + export const providersApi = { async getAll(appId: AppId): Promise> { return await invoke("get_providers", { app: appId }); @@ -74,6 +96,18 @@ export const providersApi = { return await invoke("import_default_config", { app: appId }); }, + async importClaudeDesktopFromClaude(): Promise { + return await invoke("import_claude_desktop_providers_from_claude"); + }, + + async getClaudeDesktopStatus(): Promise { + return await invoke("get_claude_desktop_status"); + }, + + async getClaudeDesktopDefaultRoutes(): Promise { + return await invoke("get_claude_desktop_default_routes"); + }, + async updateTrayMenu(): Promise { return await invoke("update_tray_menu"); }, diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 3c5263073..191ca384b 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -4,6 +4,7 @@ import type { AppId } from "@/lib/api/types"; export type AppType = | "claude" + | "claude-desktop" | "codex" | "gemini" | "opencode" @@ -13,6 +14,7 @@ export type AppType = /** Skill 应用启用状态 */ export interface SkillApps { claude: boolean; + "claude-desktop"?: boolean; codex: boolean; gemini: boolean; opencode: boolean; diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 4ec68d6b2..72b5ec28d 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,6 +1,7 @@ // 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致) export type AppId = | "claude" + | "claude-desktop" | "codex" | "gemini" | "opencode" diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 455ad56ef..51d3058dd 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -232,6 +232,12 @@ export const useSwitchProviderMutation = (appId: AppId) => { }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["providers", appId] }); + if (appId === "claude-desktop") { + await queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + await queryClient.invalidateQueries({ + queryKey: ["claudeDesktopStatus"], + }); + } // OpenCode/OpenClaw: also invalidate live provider IDs cache to update button state if (appId === "opencode") { diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index aa0dad12e..82e9a510d 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -26,6 +26,7 @@ export const settingsSchema = z.object({ // 当前供应商 ID(设备级) currentProviderClaude: z.string().optional(), + currentProviderClaudeDesktop: z.string().optional(), currentProviderCodex: z.string().optional(), currentProviderGemini: z.string().optional(), diff --git a/src/types.ts b/src/types.ts index bd3d98ebc..2791a23bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,12 +131,22 @@ export interface AuthBinding { accountId?: string; } +export interface ClaudeDesktopModelRoute { + model: string; + displayName?: string; + supports1m?: boolean; +} + // 供应商元数据(字段名与后端一致,保持 snake_case) export interface ProviderMeta { // 自定义端点:以 URL 为键,值为端点信息 custom_endpoints?: Record; // 是否在切换/同步到 live 时应用通用配置片段 commonConfigEnabled?: boolean; + // Claude Desktop 3P 配置写入模式 + claudeDesktopMode?: "direct" | "proxy"; + // Claude Desktop 本地路由模式:Claude-safe route -> upstream model + claudeDesktopModelRoutes?: Record; // 用量查询脚本配置 usage_script?: UsageScript; // 请求地址管理:测速后自动选择最佳端点 @@ -200,6 +210,7 @@ export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY"; // 主页面显示的应用配置 export interface VisibleApps { claude: boolean; + "claude-desktop": boolean; codex: boolean; gemini: boolean; opencode: boolean; @@ -304,6 +315,8 @@ export interface Settings { // ===== 当前供应商 ID(设备级)===== // 当前 Claude 供应商 ID(优先于数据库 is_current) currentProviderClaude?: string; + // 当前 Claude Desktop 供应商 ID(优先于数据库 is_current) + currentProviderClaudeDesktop?: string; // 当前 Codex 供应商 ID(优先于数据库 is_current) currentProviderCodex?: string; // 当前 Gemini 供应商 ID(优先于数据库 is_current) @@ -369,6 +382,7 @@ export interface McpServerSpec { // v3.7.0: MCP 服务器应用启用状态 export interface McpApps { claude: boolean; + "claude-desktop"?: boolean; codex: boolean; gemini: boolean; opencode: boolean; diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 601ef3b14..75f8c24b4 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -43,6 +43,7 @@ export interface ProxyServerInfo { export interface ProxyTakeoverStatus { claude: boolean; + "claude-desktop"?: boolean; codex: boolean; gemini: boolean; opencode: boolean; diff --git a/tests/msw/state.ts b/tests/msw/state.ts index 4e589d230..ac0d5bdd5 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -31,6 +31,7 @@ const createDefaultProviders = (): ProvidersByApp => ({ createdAt: Date.now() + 1, }, }, + "claude-desktop": {}, codex: { "codex-1": { id: "codex-1", @@ -71,6 +72,7 @@ const createDefaultProviders = (): ProvidersByApp => ({ const createDefaultCurrent = (): CurrentProviderState => ({ claude: "claude-1", + "claude-desktop": "", codex: "codex-1", gemini: "gemini-1", opencode: "", @@ -164,6 +166,7 @@ let mcpConfigs: McpConfigState = { }, }, }, + "claude-desktop": {}, codex: { httpServer: { id: "httpServer", @@ -231,6 +234,7 @@ export const resetProviderState = () => { }, }, }, + "claude-desktop": {}, codex: { httpServer: { id: "httpServer",