mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(claude-desktop): add 3P provider switching with proxy gateway
Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
inference profile under configLibrary/, sharing _meta.json with other
launchers (Ollama-compatible) so cc-switch can coexist with them.
Two switch modes:
- direct: provider already exposes claude-* / anthropic/claude-* model
ids on Anthropic Messages, Claude Desktop connects to it directly.
- proxy: cc-switch's local proxy acts as the inference gateway,
presenting only claude-* route names to Claude Desktop and mapping
them to real upstream models. Required after Anthropic restricted
Claude Desktop to claude-family ids.
Backend:
- New module claude_desktop_config with snapshot/rollback, official seed
bypass, /claude-desktop/v1/{models,messages} routes, and a single
source of truth for default proxy routes.
- Gateway token persisted in SQLite, validated on every proxied request.
- get_claude_desktop_status surfaces drift signals (stale models,
missing routes, proxy stopped, base URL mismatch, missing token).
Frontend:
- Slim ClaudeDesktopProviderForm independent from ProviderForm,
controlled by a top-level appId guard.
- ProviderList banner consumes the status query (5s polling) and
renders actionable diagnostics.
- ClaudeDesktopRouteToggle in the header to start/stop the local
gateway without touching takeover state.
- Three-locale i18n synchronised.
This commit is contained in:
@@ -29,3 +29,4 @@ copilot-api
|
||||
.history
|
||||
CODEBUDDY.md
|
||||
.github
|
||||
mainWindow.js
|
||||
|
||||
@@ -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<Item = AppType> {
|
||||
[
|
||||
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<String>) {
|
||||
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<bool, AppError> {
|
||||
// 如果任一应用已经有提示词配置,说明用户已经在使用 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::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(
|
||||
"claude_desktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(
|
||||
"claudeDesktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(AppType::ClaudeDesktop.as_str(), "claude-desktop");
|
||||
}
|
||||
|
||||
struct TempHome {
|
||||
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
||||
dir: TempDir,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<ConfigStatus, String> {
|
||||
@@ -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<ConfigStatus, String> {
|
||||
pub async fn get_config_status(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
) -> Result<ConfigStatus, String> {
|
||||
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<String, String> {
|
||||
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
|
||||
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::ClaudeDesktopDefaultRoute> {
|
||||
crate::claude_desktop_config::default_proxy_routes()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_claude_desktop_providers_from_claude(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
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<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
|
||||
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<String, crate::provider::ClaudeDesktopModelRoute>,
|
||||
env: &serde_json::Map<String, serde_json::Value>,
|
||||
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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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<SkillService>);
|
||||
|
||||
/// 解析 app 参数为 AppType
|
||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
// ========== 统一管理命令 ==========
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Provider, AppError> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,14 @@ use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
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))
|
||||
|
||||
@@ -216,6 +216,28 @@ pub struct AuthBinding {
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Claude Desktop 3P 识别的 1M 上下文能力标记。
|
||||
#[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")]
|
||||
pub supports_1m: Option<bool>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[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<bool>,
|
||||
/// Claude Desktop 3P 写入模式:direct(直连)或 proxy(预留)
|
||||
#[serde(rename = "claudeDesktopMode", skip_serializing_if = "Option::is_none")]
|
||||
pub claude_desktop_mode: Option<ClaudeDesktopMode>,
|
||||
/// Claude Desktop proxy 模式的模型路由映射:Claude-safe route -> upstream model。
|
||||
#[serde(
|
||||
default,
|
||||
rename = "claudeDesktopModelRoutes",
|
||||
skip_serializing_if = "HashMap::is_empty"
|
||||
)]
|
||||
pub claude_desktop_model_routes: HashMap<String, ClaudeDesktopModelRoute>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -69,6 +69,49 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await
|
||||
}
|
||||
|
||||
pub async fn handle_claude_desktop_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
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<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Json<Value>, 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<axum::response::Response, ProxyError> {
|
||||
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 两种格式的转换
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<dyn ProviderAdapter> {
|
||||
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 => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Value, AppError> {
|
||||
}
|
||||
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<bool
|
||||
let _ = normalize_claude_models_in_value(&mut v);
|
||||
v
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
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,
|
||||
|
||||
@@ -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<String, AppError> {
|
||||
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
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<Database>, 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)?;
|
||||
|
||||
@@ -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<dyn ProviderAdapter> = 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())
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 当前 Claude Desktop 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_claude_desktop: Option<String>,
|
||||
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_codex: Option<String>,
|
||||
@@ -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<String> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+37
-24
@@ -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 && (
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
)}
|
||||
{settingsData?.enableFailoverToggle && (
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
{activeApp === "claude-desktop" ? (
|
||||
<ClaudeDesktopRouteToggle />
|
||||
) : (
|
||||
settingsData?.enableLocalProxy && (
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
)
|
||||
)}
|
||||
{activeApp !== "claude-desktop" &&
|
||||
settingsData?.enableFailoverToggle && (
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -1487,15 +1496,17 @@ function App() {
|
||||
>
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
{activeApp !== "claude-desktop" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1511,15 +1522,17 @@ function App() {
|
||||
>
|
||||
<History className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
{activeApp !== "claude-desktop" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -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<AppId, string> = {
|
||||
claude: "claude",
|
||||
"claude-desktop": "claude",
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
opencode: "opencode",
|
||||
@@ -42,6 +44,7 @@ export function AppSwitcher({
|
||||
};
|
||||
const appDisplayName: Record<AppId, string> = {
|
||||
claude: "Claude",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
opencode: "OpenCode",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
|
||||
interface AppCountBarProps {
|
||||
totalLabel: string;
|
||||
counts: Record<AppId, number>;
|
||||
counts: Partial<Record<AppId, number>>;
|
||||
appIds?: AppId[];
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||
className={APP_ICON_MAP[app].badgeClass}
|
||||
>
|
||||
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
|
||||
<span className="font-bold ml-1">{counts[app]}</span>
|
||||
<span className="font-bold ml-1">{counts[app] ?? 0}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AppId } from "@/lib/api/types";
|
||||
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
|
||||
interface AppToggleGroupProps {
|
||||
apps: Record<AppId, boolean>;
|
||||
apps: Partial<Record<AppId, boolean>>;
|
||||
onToggle: (app: AppId, enabled: boolean) => void;
|
||||
appIds?: AppId[];
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = {
|
||||
claude: 0,
|
||||
"claude-desktop": 0,
|
||||
codex: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
|
||||
@@ -32,6 +32,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
const appName = t(`apps.${appId}`);
|
||||
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
|
||||
claude: "CLAUDE.md",
|
||||
"claude-desktop": "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
|
||||
@@ -26,6 +26,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
const appName = t(`apps.${appId}`);
|
||||
const filenameMap: Record<AppId, string> = {
|
||||
claude: "CLAUDE.md",
|
||||
"claude-desktop": "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
|
||||
@@ -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<string, any>;
|
||||
if (appId === "claude") {
|
||||
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||
@@ -318,6 +322,19 @@ export function ProviderCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "claude-desktop" &&
|
||||
provider.category !== "official" && (
|
||||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||
{provider.meta?.claudeDesktopMode === "proxy"
|
||||
? t("claudeDesktop.modeProxy", {
|
||||
defaultValue: "模型映射",
|
||||
})
|
||||
: t("claudeDesktop.modeDirect", {
|
||||
defaultValue: "直连",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
|
||||
@@ -36,7 +36,11 @@ export function ProviderEmptyState({
|
||||
{onImport && (
|
||||
<Button onClick={onImport}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t("provider.importCurrent")}
|
||||
{appId === "claude-desktop"
|
||||
? t("provider.importFromClaude", {
|
||||
defaultValue: "从 Claude 导入兼容供应商",
|
||||
})
|
||||
: t("provider.importCurrent")}
|
||||
</Button>
|
||||
)}
|
||||
{onCreate && (
|
||||
|
||||
@@ -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<Provider | null>(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 (
|
||||
<div className="space-y-3">
|
||||
@@ -400,6 +481,36 @@ export function ProviderList({
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{appId === "claude-desktop" && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={importMutation.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t("provider.importFromClaude", {
|
||||
defaultValue: "从 Claude 导入兼容供应商",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{claudeDesktopStatusMessages.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t("claudeDesktop.statusTitle", {
|
||||
defaultValue: "Claude Desktop 配置需要检查",
|
||||
})}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1 text-xs leading-relaxed">
|
||||
{claudeDesktopStatusMessages.map((message) => (
|
||||
<li key={message}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{isSearchOpen && (
|
||||
<motion.div
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type {
|
||||
ClaudeApiFormat,
|
||||
ClaudeDesktopModelRoute,
|
||||
ProviderCategory,
|
||||
ProviderMeta,
|
||||
} from "@/types";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import {
|
||||
providersApi,
|
||||
type ClaudeDesktopDefaultRoute,
|
||||
} from "@/lib/api/providers";
|
||||
|
||||
export type ClaudeDesktopProviderFormValues = ProviderFormData & {
|
||||
presetId?: string;
|
||||
presetCategory?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
meta?: ProviderMeta;
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
};
|
||||
|
||||
export interface ClaudeDesktopProviderFormProps {
|
||||
submitLabel: string;
|
||||
onSubmit: (values: ClaudeDesktopProviderFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
onSubmittingChange?: (isSubmitting: boolean) => void;
|
||||
initialData?: {
|
||||
name?: string;
|
||||
websiteUrl?: string;
|
||||
notes?: string;
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
category?: ProviderCategory;
|
||||
meta?: ProviderMeta;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
};
|
||||
showButtons?: boolean;
|
||||
}
|
||||
|
||||
type RouteRow = {
|
||||
route: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
supports1m: boolean;
|
||||
};
|
||||
|
||||
function envString(
|
||||
settingsConfig: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
) {
|
||||
const env = settingsConfig?.env;
|
||||
if (!env || typeof env !== "object") return "";
|
||||
const value = (env as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function clonePlainRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return { ...(value as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function initialRouteRows(
|
||||
routes: Record<string, ClaudeDesktopModelRoute> | 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<ClaudeApiFormat>(
|
||||
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<RouteRow[]>(() =>
|
||||
initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes),
|
||||
);
|
||||
const didSeedDefaultRoutes = useRef(
|
||||
Object.keys(initialData?.meta?.claudeDesktopModelRoutes ?? {}).length > 0,
|
||||
);
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
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<ProviderFormData>({
|
||||
resolver: zodResolver(providerSchema),
|
||||
defaultValues,
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onSubmittingChange?.(form.formState.isSubmitting || isFetchingModels);
|
||||
}, [form.formState.isSubmitting, isFetchingModels, onSubmittingChange]);
|
||||
|
||||
const updateRoute = (index: number, patch: Partial<RouteRow>) => {
|
||||
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<string, ClaudeDesktopModelRoute>
|
||||
>((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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="provider-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<BasicFormFields form={form} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("claudeDesktop.gatewayBaseUrl", {
|
||||
defaultValue: "Gateway Base URL",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("claudeDesktop.bearerToken", {
|
||||
defaultValue: "Bearer Token",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
</Label>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{needsModelMapping
|
||||
? t("claudeDesktop.modelMappingOnHint", {
|
||||
defaultValue:
|
||||
"Claude Desktop 只看到 claude-* 路由名,CC Switch 本地路由会映射到真实上游模型;需要保持本地路由运行。",
|
||||
})
|
||||
: t("claudeDesktop.modelMappingOffHint", {
|
||||
defaultValue:
|
||||
"适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={needsModelMapping}
|
||||
onCheckedChange={handleModelMappingChange}
|
||||
aria-label={t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsModelMapping && (
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[220px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
setApiFormat(value as ClaudeApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_native">
|
||||
{t("providerForm.apiFormatGeminiNative", {
|
||||
defaultValue:
|
||||
"Gemini Native generateContent (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{t("providerForm.fetchModels", {
|
||||
defaultValue: "获取模型",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<datalist id="claude-desktop-upstream-models">
|
||||
{fetchedModels.map((model) => (
|
||||
<option key={model.id} value={model.id} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1 border-t border-border-default pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
{t("claudeDesktop.routeMapTitle", {
|
||||
defaultValue: "模型映射",
|
||||
})}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.routeMapHint", {
|
||||
defaultValue:
|
||||
"左侧是 Claude Desktop 看到的 claude-* 模型名,右侧是真实发送给供应商的上游模型名。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-cols-[1fr_1fr_140px_92px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("claudeDesktop.routeModelLabel", {
|
||||
defaultValue: "Desktop 模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.upstreamModelLabel", {
|
||||
defaultValue: "上游模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.displayNameLabel", {
|
||||
defaultValue: "显示名",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.supports1mLabel", {
|
||||
defaultValue: "1M",
|
||||
})}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
{routes.map((route, index) => (
|
||||
<div
|
||||
key={`${route.route}-${index}`}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_92px_36px]"
|
||||
>
|
||||
<Input
|
||||
value={route.route}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { route: event.target.value })
|
||||
}
|
||||
placeholder="claude-sonnet-4-6"
|
||||
/>
|
||||
<Input
|
||||
value={route.model}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { model: event.target.value })
|
||||
}
|
||||
list="claude-desktop-upstream-models"
|
||||
placeholder="kimi-k2 / deepseek-chat"
|
||||
/>
|
||||
<Input
|
||||
value={route.displayName}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { displayName: event.target.value })
|
||||
}
|
||||
placeholder="Sonnet"
|
||||
/>
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={route.supports1m}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRoute(index, { supports1m: checked === true })
|
||||
}
|
||||
/>
|
||||
1M
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setRoutes((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
nextRouteRow(current, defaultProxyRouteRows),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("claudeDesktop.addRoute", { defaultValue: "添加路由" })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!needsModelMapping && (
|
||||
<Collapsible
|
||||
open={directModelsExpanded}
|
||||
onOpenChange={setDirectModelsExpanded}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={null}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
|
||||
>
|
||||
{directModelsExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("claudeDesktop.directModelListTitle", {
|
||||
defaultValue:
|
||||
"手动指定 Claude Desktop 模型列表(高级,可选)",
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{!directModelsExpanded && (
|
||||
<p className="ml-1 mt-1 text-xs text-muted-foreground">
|
||||
{t("claudeDesktop.directModelListCollapsedHint", {
|
||||
defaultValue:
|
||||
"原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.directModelListHint", {
|
||||
defaultValue:
|
||||
"仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{t("providerForm.fetchModels", {
|
||||
defaultValue: "获取模型",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<datalist id="claude-desktop-direct-models">
|
||||
{fetchedModels.map((model) => (
|
||||
<option key={model.id} value={model.id} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{routes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{routes.map((route, index) => (
|
||||
<div
|
||||
key={`${route.route}-${index}`}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_92px_36px]"
|
||||
>
|
||||
<Input
|
||||
value={route.route}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { route: event.target.value })
|
||||
}
|
||||
list="claude-desktop-direct-models"
|
||||
placeholder="claude-deepseek-chat"
|
||||
/>
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={route.supports1m}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRoute(index, {
|
||||
supports1m: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
1M
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setRoutes((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
{
|
||||
route: "",
|
||||
model: "",
|
||||
displayName: "",
|
||||
supports1m: false,
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("claudeDesktop.addModel", { defaultValue: "添加模型" })}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormControl>
|
||||
<input type="hidden" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showButtons && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<EndpointSpeedTest
|
||||
appId="claude"
|
||||
providerId={providerId}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
||||
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||
codex: 12,
|
||||
claude: 8,
|
||||
"claude-desktop": 8,
|
||||
gemini: 8,
|
||||
opencode: 8,
|
||||
openclaw: 8,
|
||||
|
||||
@@ -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 <ClaudeDesktopProviderForm {...props} />;
|
||||
}
|
||||
|
||||
return <ProviderFormFull {...props} />;
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all",
|
||||
className,
|
||||
)}
|
||||
title={tooltipText}
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Radio
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors",
|
||||
isRunning
|
||||
? "text-emerald-500 animate-pulse"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useTranslation } from "react-i18next";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { ResolvedDirectories } from "@/hooks/useSettings";
|
||||
|
||||
type DirectoryAppId = Exclude<AppId, "claude-desktop">;
|
||||
|
||||
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<void>;
|
||||
onResetDirectory: (app: AppId) => Promise<void>;
|
||||
onDirectoryChange: (app: DirectoryAppId, value?: string) => void;
|
||||
onBrowseDirectory: (app: DirectoryAppId) => Promise<void>;
|
||||
onResetDirectory: (app: DirectoryAppId) => Promise<void>;
|
||||
}
|
||||
|
||||
export function DirectorySettings({
|
||||
|
||||
@@ -115,6 +115,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = {
|
||||
claude: 0,
|
||||
"claude-desktop": 0,
|
||||
codex: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
|
||||
@@ -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<AppId, AppConfig> = {
|
||||
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: <ClaudeIcon size={14} />,
|
||||
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: <CodexIcon size={14} />,
|
||||
|
||||
@@ -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<AppId, "claude-desktop">;
|
||||
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<string | undefined> => {
|
||||
};
|
||||
|
||||
const computeDefaultConfigDir = async (
|
||||
app: AppId,
|
||||
app: DirectoryAppId,
|
||||
): Promise<string | undefined> => {
|
||||
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<void>;
|
||||
browseDirectory: (app: DirectoryAppId) => Promise<void>;
|
||||
browseAppConfigDir: () => Promise<void>;
|
||||
resetDirectory: (app: AppId) => Promise<void>;
|
||||
resetDirectory: (app: DirectoryAppId) => Promise<void>;
|
||||
resetAppConfigDir: () => Promise<void>;
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<SettingsFormState>) => void;
|
||||
updateDirectory: (app: AppId, value?: string) => void;
|
||||
updateDirectory: (app: DirectoryAppId, value?: string) => void;
|
||||
updateAppConfigDir: (value?: string) => void;
|
||||
browseDirectory: (app: AppId) => Promise<void>;
|
||||
browseDirectory: (app: DirectoryAppId) => Promise<void>;
|
||||
browseAppConfigDir: () => Promise<void>;
|
||||
resetDirectory: (app: AppId) => Promise<void>;
|
||||
resetDirectory: (app: DirectoryAppId) => Promise<void>;
|
||||
resetAppConfigDir: () => Promise<void>;
|
||||
saveSettings: (
|
||||
overrides?: Partial<SettingsFormState>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<string, Provider>> {
|
||||
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<number> {
|
||||
return await invoke("import_claude_desktop_providers_from_claude");
|
||||
},
|
||||
|
||||
async getClaudeDesktopStatus(): Promise<ClaudeDesktopStatus> {
|
||||
return await invoke("get_claude_desktop_status");
|
||||
},
|
||||
|
||||
async getClaudeDesktopDefaultRoutes(): Promise<ClaudeDesktopDefaultRoute[]> {
|
||||
return await invoke("get_claude_desktop_default_routes");
|
||||
},
|
||||
|
||||
async updateTrayMenu(): Promise<boolean> {
|
||||
return await invoke("update_tray_menu");
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
|
||||
export type AppId =
|
||||
| "claude"
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "opencode"
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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<string, CustomEndpoint>;
|
||||
// 是否在切换/同步到 live 时应用通用配置片段
|
||||
commonConfigEnabled?: boolean;
|
||||
// Claude Desktop 3P 配置写入模式
|
||||
claudeDesktopMode?: "direct" | "proxy";
|
||||
// Claude Desktop 本地路由模式:Claude-safe route -> upstream model
|
||||
claudeDesktopModelRoutes?: Record<string, ClaudeDesktopModelRoute>;
|
||||
// 用量查询脚本配置
|
||||
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;
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface ProxyServerInfo {
|
||||
|
||||
export interface ProxyTakeoverStatus {
|
||||
claude: boolean;
|
||||
"claude-desktop"?: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user