Compare commits

..

10 Commits

Author SHA1 Message Date
YoVinchen d11a12a746 Merge branch 'main' into feature/error-request-logging 2025-12-16 18:09:19 +08:00
YoVinchen e76ee66d13 chore(clippy): fix uninlined format args 2025-12-16 18:02:08 +08:00
YoVinchen a540bf92ca fix(speedtest): skip client build for invalid inputs 2025-12-16 17:50:41 +08:00
YoVinchen 24391fc431 Merge remote-tracking branch 'origin/main' into feature/error-request-logging
# Conflicts:
#	src-tauri/src/proxy/handlers.rs
#	src-tauri/src/proxy/mod.rs
#	src-tauri/src/proxy/provider_router.rs
#	src-tauri/src/services/proxy.rs
#	src/components/providers/ProviderActions.tsx
2025-12-16 17:40:49 +08:00
YoVinchen 2e6ba77187 feat(proxy): add settings button to proxy panel
Add configuration buttons in both running and stopped states to
provide easy access to proxy settings dialog.
2025-12-15 00:16:12 +08:00
YoVinchen 53ccd5f70d style: apply code formatting
- Remove trailing whitespace in misc.rs
- Add trailing comma in App.tsx
- Format multi-line className in ProviderCard.tsx
2025-12-14 16:16:56 +08:00
YoVinchen 2af8dd2dac style: fix clippy warnings and typescript errors
- Add allow(dead_code) for CircuitBreaker::get_state (reserved for future)
- Fix all uninlined format string warnings (27 instances)
- Use inline format syntax for better readability
- Fix unused import and parameter warnings in ProviderActions.tsx
- Achieve zero warnings in both Rust and TypeScript
2025-12-14 16:05:38 +08:00
YoVinchen 4cf4654863 feat(proxy): implement error capture and logging in all handlers
- Capture and log all failed requests in handle_messages (Claude)
- Capture and log all failed requests in handle_gemini (Gemini)
- Capture and log all failed requests in handle_responses (Codex)
- Capture and log all failed requests in handle_chat_completions (Codex)
- Record error status codes, messages, and latency for all failures
- Generate unique session_id for each request
- Support both streaming and non-streaming error scenarios
2025-12-14 16:03:28 +08:00
YoVinchen 8b202ea988 feat(proxy): enhance error logging with context support
- Add log_error_with_context() method for detailed error recording
- Support streaming flag, session_id, and provider_type fields
- Remove dead_code warning from log_error() method
- Enable comprehensive error request tracking in database
2025-12-14 16:03:02 +08:00
YoVinchen 14bc8a00e5 feat(proxy): add error mapper for HTTP status code mapping
- Add error_mapper.rs module to map ProxyError to HTTP status codes
- Implement map_proxy_error_to_status() for error classification
- Implement get_error_message() for user-friendly error messages
- Support all error types: upstream, timeout, connection, provider failures
- Include comprehensive unit tests for all mappings
2025-12-14 16:02:04 +08:00
38 changed files with 433 additions and 2310 deletions
-94
View File
@@ -5,100 +5,6 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.9.0-1] - 2025-12-18
### Beta Release
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
### Major Features
#### Local Proxy Server
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
- **Per-app Takeover** - Independent control over which apps route through the proxy
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
#### Auto Failover
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
- **Health Tracking** - Real-time monitoring of provider availability
- **Independent Failover Queues** - Each app maintains its own failover queue
#### Monitoring
- **Request Logging** - Detailed logging of all proxy requests
- **Usage Statistics** - Token consumption, latency, success rate metrics
- **Real-time Status** - Frontend displays proxy status and statistics
#### Skills Multi-App Support
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
### Added
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Closable Toast** - Added close button to switch notification toast (#350)
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
### Fixed
#### Proxy Related
- Takeover Codex base_url via model_provider
- Harden crash recovery with fallback detection
- Sync UI when active provider differs from current setting
- Resolve circuit breaker race condition and error classification
- Stabilize live takeover and provider editing
- Reset health badges when proxy stops
- Retry failover for all HTTP errors including 4xx
- Fix HalfOpen counter underflow and config field inconsistencies
- Resolve circuit breaker state persistence and HalfOpen deadlock
- Auto-recover live config after abnormal exit
- Update live backup when hot-switching provider in proxy mode
- Wait for server shutdown before exiting app
- Disable auto-start on app launch by resetting enabled flag on stop
- Sync live config tokens to database before takeover
- Resolve 404 error and auto-setup proxy targets
#### MCP Related
- Skip sync when target CLI app is not installed
- Improve upsert and import robustness
- Use browser-compatible platform detection for MCP presets
#### UI Related
- Restore fade transition for Skills button
- Add close button to all success toasts
- Prevent card jitter when health badge appears
- Update SettingsPage tab styles (#342)
#### Other
- Fix Azure website link (#407)
- Add fallback to provider config for usage credentials (#360)
- Fix Windows black screen on startup (use system titlebar)
- Add fallback for crypto.randomUUID() on older WebViews
- Use correct npm package for Codex CLI version check
- Security fixes for JavaScript executor and usage script (#151)
### Improved
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
- **Card Animation** - Improved provider card hover animation
- **Remove Restart Prompt** - No longer prompts restart when switching providers
### Technical
- Implement per-app takeover mode
- Proxy module contains 20+ Rust files with complete layered architecture
- Add 5 new database tables for proxy functionality
- Modularize handlers.rs to reduce code duplication
- Remove is_proxy_target in favor of failover_queue
### Stats
- 55 commits since v3.8.2
- 164 files changed
- +22,164 / -570 lines
---
## [3.8.0] - 2025-11-28
### Major Updates
-165
View File
@@ -1,165 +0,0 @@
# CC Switch 代理功能使用指南
## 功能介绍
CC Switch 的代理功能是一个本地 HTTP 代理服务器,可以统一管理 Claude Code、Codex 和 Gemini CLI 的 API 请求。主要特性包括:
- **统一代理入口** - 所有 CLI 应用的请求通过本地代理转发
- **自动故障转移** - 当前供应商故障时自动切换到备用供应商
- **按应用控制** - 可独立控制每个应用是否启用代理
- **配置保护** - 自动备份原始配置,停止代理时安全恢复
## 快速开始
### 1. 启动代理
在 CC Switch 主界面,点击右上角的 **Proxy** 按钮,可以看到代理控制面板。
点击 **启动代理** 按钮启动本地代理服务器。代理默认监听 `127.0.0.1:15721`
### 2. 启用应用接管
代理启动后,你可以选择让哪些应用的请求通过代理:
- **Claude** - 接管 Claude Code 的 API 请求
- **Codex** - 接管 Codex CLI 的 API 请求
- **Gemini** - 接管 Gemini CLI 的 API 请求
点击对应应用的开关即可启用/禁用接管。
> **注意**:启用接管后,CC Switch 会自动修改对应应用的配置文件,将 API 端点指向本地代理。原始配置会被安全备份。
### 3. 正常使用 CLI
启用接管后,你可以正常使用各个 CLI 工具。所有请求都会经过 CC Switch 代理转发到配置的供应商。
### 4. 停止代理
当你不再需要代理时,点击 **停止代理** 按钮。CC Switch 会:
1. 安全关闭代理服务器
2. 自动恢复所有应用的原始配置
3. 清除代理状态
## 自动故障转移
### 工作原理
代理功能内置了智能故障转移机制:
1. **健康监控** - 实时监控每个供应商的响应状态
2. **熔断器** - 连续失败 5 次后触发熔断,暂停使用该供应商
3. **自动切换** - 熔断后自动切换到列表中的下一个供应商
4. **自动恢复** - 30 秒后尝试恢复熔断的供应商
### 配置故障转移
要使用故障转移功能,你需要:
1. 在对应应用下添加多个供应商(至少 2 个)
2. 启动代理并启用接管
3. 当主供应商故障时,代理会自动切换到备用供应商
### 健康状态指示
在供应商卡片上可以看到健康状态指示:
- **绿色** - 供应商正常
- **红色** - 供应商故障/熔断中
- **灰色** - 未使用代理或未检测
## 按应用接管
v3.9.0 新增了按应用分粒度控制功能:
- 你可以只接管 Claude,而让 Codex 使用原始配置
- 每个应用的接管状态独立管理
- 启用/禁用不会影响其他应用
### 接管状态检测
CC Switch 通过检测配置备份来判断接管状态:
- 存在备份 = 已接管
- 无备份 = 未接管
这确保了即使 CC Switch 异常退出,重新启动后也能正确识别状态。
## 代理配置
在代理面板中,你可以配置以下参数:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 监听地址 | 127.0.0.1 | 代理服务器绑定地址 |
| 监听端口 | 15721 | 代理服务器端口 |
| 最大重试 | 3 | 请求失败时的最大重试次数 |
| 请求超时 | 120 秒 | 单个请求的超时时间 |
| 启用日志 | 是 | 是否记录请求日志 |
## 常见问题
### Q: 代理启动失败,提示端口被占用?
A: 默认端口 15721 可能被其他程序占用。你可以:
- 关闭占用该端口的程序
- 在代理配置中修改端口号
### Q: 启用接管后 CLI 无法使用?
A: 请检查:
1. 代理服务器是否正常运行(查看代理面板状态)
2. 供应商配置是否正确(API Key 等)
3. 网络连接是否正常
### Q: 如何恢复原始配置?
A: 点击 **停止代理** 按钮,CC Switch 会自动恢复所有应用的原始配置。
如果 CC Switch 异常退出,重新启动后会检测到之前的备份,你可以:
- 点击停止代理来恢复配置
- 或继续使用代理功能
### Q: 故障转移没有生效?
A: 请确保:
1. 配置了至少 2 个供应商
2. 代理已启动且接管已启用
3. 故障转移只在代理模式下工作
### Q: 代理会影响性能吗?
A: 本地代理的延迟开销非常小(通常 < 1ms)。但如果启用了请求日志,在高频请求场景下可能会有少量性能影响。
## 技术细节
### 配置文件位置
启用接管后,CC Switch 会修改以下配置文件:
| 应用 | 配置文件 | 修改内容 |
|------|----------|----------|
| Claude | `~/.claude/settings.json` | `apiBaseUrl` 指向代理 |
| Codex | `~/.codex/config.toml` | `[api] baseUrl` 指向代理 |
| Gemini | `~/.gemini/.env` | `GEMINI_BASE_URL` 指向代理 |
原始配置备份在 CC Switch 数据库中,停止代理时自动恢复。
### 代理模式
代理服务器运行在接管模式下,会:
1. 接收来自 CLI 的 HTTPS 请求
2. 根据当前供应商配置转发到真实 API 端点
3. 返回响应给 CLI
4. 记录请求日志和健康状态
### 数据库表
代理功能使用以下数据库表:
- `proxy_config` - 代理配置
- `provider_health` - 供应商健康状态
- `proxy_request_logs` - 请求日志
- `circuit_breaker_config` - 熔断器配置
- `proxy_live_backup` - Live 配置备份
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.9.0-1",
"version": "3.8.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
+1 -1
View File
@@ -695,7 +695,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.9.0-1"
version = "3.8.2"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.9.0-1"
version = "3.8.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+5 -15
View File
@@ -155,17 +155,11 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(None, Some("未安装或无法执行".to_string()))
} else {
(Some(extract_version(raw)), None)
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
(Some(extract_version(&raw)), None)
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
(
None,
Some(if err.is_empty() {
@@ -245,13 +239,9 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
.output();
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if !raw.is_empty() {
return (Some(extract_version(raw)), None);
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
return (Some(extract_version(&raw)), None);
}
}
}
-29
View File
@@ -6,14 +6,6 @@ use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command]
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start(true).await
}
/// 启动代理服务器(带 Live 配置接管)
#[tauri::command]
pub async fn start_proxy_with_takeover(
@@ -28,27 +20,6 @@ pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Resul
state.proxy_service.stop_with_restore().await
}
/// 获取各应用接管状态
#[tauri::command]
pub async fn get_proxy_takeover_status(
state: tauri::State<'_, AppState>,
) -> Result<ProxyTakeoverStatus, String> {
state.proxy_service.get_takeover_status().await
}
/// 为指定应用开启/关闭接管
#[tauri::command]
pub async fn set_proxy_takeover_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
state
.proxy_service
.set_takeover_for_app(&app_type, enabled)
.await
}
/// 获取代理服务器状态
#[tauri::command]
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
+9 -13
View File
@@ -85,8 +85,15 @@ impl Database {
/// 检查是否处于 Live 接管模式
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
self.has_any_live_backup().await
let conn = lock_conn!(self.conn);
let active: i32 = conn
.query_row(
"SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(active != 0)
}
// ==================== Provider Health ====================
@@ -314,17 +321,6 @@ impl Database {
Ok(())
}
/// 检查是否存在任意 Live 配置备份
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
row.get(0)
})
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 获取 Live 配置备份
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
let conn = lock_conn!(self.conn);
+4 -11
View File
@@ -42,8 +42,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 补齐 type: "sse"Gemini 以字段名推断传输类型)
/// - 仅有 command 字段 → 补齐 type: "stdio"
/// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型)
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -65,15 +65,8 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
obj.insert("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string()));
}
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入
if obj.get("type").is_none() {
if obj.contains_key("command") {
obj.insert("type".to_string(), Value::String("stdio".to_string()));
} else if obj.contains_key("url") {
obj.insert("type".to_string(), Value::String("sse".to_string()));
}
}
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// 如果有 command 但没有 type,不添加 type(默认为 stdio
}
}
+32 -55
View File
@@ -49,6 +49,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{Emitter, Manager};
@@ -529,64 +530,43 @@ pub fn run() {
tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>();
// 检查是否有 Live 备份(表示上次有接管状态)
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
// 1. 检测异常退出并恢复 Live 配置
match state.db.is_live_takeover_active().await {
Ok(true) => {
// 接管标志为 true 但代理未运行 → 上次异常退出
if !state.proxy_service.is_running().await {
log::warn!("检测到上次异常退出,正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已从异常退出中恢复");
}
}
}
Ok(false) => {
// 正常状态,无需恢复
}
Err(e) => {
log::error!("检查 Live 备份失败: {e}");
false
log::error!("检查接管状态失败: {e}");
}
};
}
// 获取代理配置
let proxy_config = match state.db.get_proxy_config().await {
Ok(config) => Some(config),
Err(e) => {
log::error!("启动时获取代理配置失败: {e}");
None
}
};
if has_backups {
// 有备份说明上次退出时有接管状态
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if live_taken_over {
// Live 配置仍是接管状态,尝试重新启动代理服务以恢复接管
log::info!("检测到上次接管状态,正在重新启动代理服务...");
match state.proxy_service.start(false).await {
Ok(info) => {
log::info!("代理服务器已恢复启动: {}:{}", info.address, info.port);
}
Err(e) => {
// 启动失败,恢复 Live 配置
log::error!("恢复代理服务失败: {e},正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已恢复");
}
// 2. 自动启动代理服务器(如果配置为启用)
match state.db.get_proxy_config().await {
Ok(config) => {
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start_with_takeover().await {
Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}",
info.address,
info.port
),
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
} else {
// Live 配置已经是正常状态,清理残留备份
log::info!("Live 配置已是正常状态,清理残留备份...");
if let Err(e) = state.db.delete_all_live_backups().await {
log::warn!("清理残留 Live 备份失败: {e}");
}
}
} else if let Some(config) = proxy_config {
// 没有备份,检查是否需要自动启动代理服务器(总开关)
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start(true).await {
Ok(info) => {
log::info!("代理服务器自动启动成功: {}:{}", info.address, info.port)
}
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
}
});
@@ -692,11 +672,8 @@ pub fn run() {
commands::set_auto_launch,
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_server,
commands::start_proxy_with_takeover,
commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
commands::get_proxy_status,
commands::get_proxy_config,
commands::update_proxy_config,
-15
View File
@@ -8,12 +8,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_claude_mcp() -> bool {
// Claude 未安装/未初始化时:通常 ~/.claude 目录与 ~/.claude.json 都不存在。
// 按用户偏好:此时跳过写入/删除,不创建任何文件或目录。
crate::config::get_claude_config_dir().exists() || crate::config::get_claude_mcp_path().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
@@ -39,9 +33,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
@@ -116,9 +107,6 @@ pub fn sync_single_server_to_claude(
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
@@ -132,9 +120,6 @@ pub fn sync_single_server_to_claude(
/// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
+2 -19
View File
@@ -13,12 +13,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_codex_mcp() -> bool {
// Codex 未安装/未初始化时:~/.codex 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::codex_config::get_codex_config_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
@@ -279,9 +273,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
/// - 仅更新 `mcp_servers` 表,保留其它键
/// - 仅写入启用项;无启用项时清理 mcp_servers 表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::{Item, Table};
// 1) 收集启用项(Codex 维度)
@@ -348,9 +339,6 @@ pub fn sync_single_server_to_codex(
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::Item;
// 读取现有的 config.toml
@@ -388,8 +376,7 @@ pub fn sync_single_server_to_codex(
doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件
let new_text = doc.to_string();
crate::config::write_text_file(&config_path, &new_text)?;
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
@@ -397,9 +384,6 @@ pub fn sync_single_server_to_codex(
/// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() {
@@ -428,8 +412,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
}
// 写回文件
let new_text = doc.to_string();
crate::config::write_text_file(&config_path, &new_text)?;
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
-15
View File
@@ -8,12 +8,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_gemini_mcp() -> bool {
// Gemini 未安装/未初始化时:~/.gemini 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::gemini_config::get_gemini_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
@@ -39,9 +33,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}
@@ -112,9 +103,6 @@ pub fn sync_single_server_to_gemini(
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
@@ -127,9 +115,6 @@ pub fn sync_single_server_to_gemini(
/// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
+46 -134
View File
@@ -78,16 +78,6 @@ pub struct CircuitBreaker {
half_open_requests: Arc<AtomicU32>,
}
/// 熔断器放行结果
///
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
#[derive(Debug, Clone, Copy)]
pub struct AllowResult {
pub allowed: bool,
pub used_half_open_permit: bool,
}
impl CircuitBreaker {
/// 创建新的熔断器
pub fn new(config: CircuitBreakerConfig) -> Self {
@@ -140,16 +130,13 @@ impl CircuitBreaker {
}
/// 检查是否允许请求通过
pub async fn allow_request(&self) -> AllowResult {
pub async fn allow_request(&self) -> bool {
let state = *self.state.read().await;
let config = self.config.read().await;
match state {
CircuitState::Closed => AllowResult {
allowed: true,
used_half_open_permit: false,
},
CircuitState::Closed => true,
CircuitState::Open => {
let config = self.config.read().await;
// 检查是否应该尝试半开
if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
@@ -158,47 +145,52 @@ impl CircuitBreaker {
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
);
self.transition_to_half_open().await;
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
let current_state = *self.state.read().await;
return match current_state {
CircuitState::Closed => AllowResult {
allowed: true,
used_half_open_permit: false,
},
CircuitState::HalfOpen => self.allow_half_open_probe(),
CircuitState::Open => AllowResult {
allowed: false,
used_half_open_permit: false,
},
};
// 增加计数,确保 record_success/record_failure 减计数时不会下溢
self.half_open_requests.fetch_add(1, Ordering::SeqCst);
return true;
}
}
false
}
CircuitState::HalfOpen => {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
AllowResult {
allowed: false,
used_half_open_permit: false,
if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
true
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
false
}
}
CircuitState::HalfOpen => self.allow_half_open_probe(),
}
}
/// 记录成功
pub async fn record_success(&self, used_half_open_permit: bool) {
pub async fn record_success(&self) {
let state = *self.state.read().await;
let config = self.config.read().await;
if used_half_open_permit {
self.release_half_open_permit();
}
// 重置失败计数
self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst);
match state {
CircuitState::HalfOpen => {
// 释放 in-flight 名额(探测请求结束)
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
@@ -220,14 +212,10 @@ impl CircuitBreaker {
}
/// 记录失败
pub async fn record_failure(&self, used_half_open_permit: bool) {
pub async fn record_failure(&self) {
let state = *self.state.read().await;
let config = self.config.read().await;
if used_half_open_permit {
self.release_half_open_permit();
}
// 更新计数器
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
self.total_requests.fetch_add(1, Ordering::SeqCst);
@@ -246,6 +234,9 @@ impl CircuitBreaker {
// 检查是否应该打开熔断器
match state {
CircuitState::HalfOpen => {
// 释放 in-flight 名额(探测请求结束)
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
// HalfOpen 状态下失败,立即转为 Open
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
drop(config);
@@ -316,56 +307,6 @@ impl CircuitBreaker {
self.transition_to_closed().await;
}
fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
AllowResult {
allowed: true,
used_half_open_permit: true,
}
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
AllowResult {
allowed: false,
used_half_open_permit: false,
}
}
}
fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
return;
}
match self.half_open_requests.compare_exchange(
current,
current - 1,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return,
Err(actual) => current = actual,
}
}
}
/// 转换到打开状态
async fn transition_to_open(&self) {
*self.state.write().await = CircuitState::Open;
@@ -376,12 +317,7 @@ impl CircuitBreaker {
/// 转换到半开状态
async fn transition_to_half_open(&self) {
let mut state = self.state.write().await;
if *state != CircuitState::Open {
return;
}
*state = CircuitState::HalfOpen;
*self.state.write().await = CircuitState::HalfOpen;
self.consecutive_successes.store(0, Ordering::SeqCst);
// 重置半开状态的请求限流计数
self.half_open_requests.store(0, Ordering::SeqCst);
@@ -423,16 +359,16 @@ mod tests {
// 初始状态应该是关闭
assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await.allowed);
assert!(breaker.allow_request().await);
// 记录 3 次失败
for _ in 0..3 {
breaker.record_failure(false).await;
breaker.record_failure().await;
}
// 应该转换到打开状态
assert_eq!(breaker.get_state().await, CircuitState::Open);
assert!(!breaker.allow_request().await.allowed);
assert!(!breaker.allow_request().await);
}
#[tokio::test]
@@ -445,8 +381,8 @@ mod tests {
let breaker = CircuitBreaker::new(config);
// 打开熔断器
breaker.record_failure(false).await;
breaker.record_failure(false).await;
breaker.record_failure().await;
breaker.record_failure().await;
assert_eq!(breaker.get_state().await, CircuitState::Open);
// 手动转换到半开状态
@@ -454,37 +390,13 @@ mod tests {
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 记录 2 次成功
breaker.record_success(false).await;
breaker.record_success(false).await;
breaker.record_success().await;
breaker.record_success().await;
// 应该转换到关闭状态
assert_eq!(breaker.get_state().await, CircuitState::Closed);
}
#[tokio::test]
async fn test_half_open_transition_does_not_reset_inflight_permit() {
let config = CircuitBreakerConfig {
timeout_seconds: 0,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 进入 Open,然后由于 timeout_seconds=0allow_request 会立即切换到 HalfOpen 并占用探测名额
breaker.transition_to_open().await;
let first = breaker.allow_request().await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
breaker.transition_to_half_open().await;
// 由于名额仍被占用,第二次请求应被拒绝
let second = breaker.allow_request().await;
assert!(!second.allowed);
assert!(!second.used_half_open_permit);
}
#[tokio::test]
async fn test_circuit_breaker_reset() {
let config = CircuitBreakerConfig {
@@ -494,13 +406,13 @@ mod tests {
let breaker = CircuitBreaker::new(config);
// 打开熔断器
breaker.record_failure(false).await;
breaker.record_failure(false).await;
breaker.record_failure().await;
breaker.record_failure().await;
assert_eq!(breaker.get_state().await, CircuitState::Open);
// 重置
breaker.reset().await;
assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await.allowed);
assert!(breaker.allow_request().await);
}
}
+17 -50
View File
@@ -29,8 +29,6 @@ pub struct RequestForwarder {
failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
}
impl RequestForwarder {
@@ -42,7 +40,6 @@ impl RequestForwarder {
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
) -> Self {
let mut client_builder = Client::builder();
if timeout_secs > 0 {
@@ -61,7 +58,6 @@ impl RequestForwarder {
current_providers,
failover_manager,
app_handle,
current_provider_id_at_start,
}
}
@@ -98,8 +94,10 @@ impl RequestForwarder {
{
Ok(response) => return Ok(response),
Err(e) => {
// 只有“同一 Provider 内可重试”的错误才继续重试
if !self.should_retry_same_provider(&e) {
let category = self.categorize_proxy_error(&e);
// 只有可重试的错误才继续重试
if category == ErrorCategory::NonRetryable {
return Err(e);
}
@@ -149,16 +147,17 @@ impl RequestForwarder {
);
let mut last_error = None;
let mut failover_happened = false;
let mut attempted_providers = 0usize;
// 依次尝试每个供应商
for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
let permit = self
if !self
.router
.allow_provider_request(&provider.id, app_type_str)
.await;
if !permit.allowed {
.await
{
log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str,
@@ -167,9 +166,10 @@ impl RequestForwarder {
continue;
}
let used_half_open_permit = permit.used_half_open_permit;
attempted_providers += 1;
if attempted_providers > 1 {
failover_happened = true;
}
log::info!(
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
@@ -202,13 +202,7 @@ impl RequestForwarder {
// 成功:记录成功并更新熔断器
if let Err(e) = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.record_result(&provider.id, app_type_str, true, None)
.await
{
log::warn!("Failed to record success: {e}");
@@ -228,18 +222,16 @@ impl RequestForwarder {
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str() != provider.id.as_str();
if should_switch {
if failover_happened {
status.failover_count += 1;
log::info!(
"[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)",
"[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)",
app_type_str,
provider.name,
latency
);
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
// 异步触发供应商切换,更新 UI 和托盘菜单
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
@@ -276,13 +268,7 @@ impl RequestForwarder {
// 失败:记录失败并更新熔断器
if let Err(record_err) = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(e.to_string()),
)
.record_result(&provider.id, app_type_str, false, Some(e.to_string()))
.await
{
log::warn!("Failed to record failure: {record_err}");
@@ -503,19 +489,6 @@ impl RequestForwarder {
///
/// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。
/// 只有明确是客户端中断的情况才不重试。
fn should_retry_same_provider(&self, error: &ProxyError) -> bool {
match error {
// 网络类错误:短暂抖动时同一 Provider 内重试有意义
ProxyError::Timeout(_) => true,
ProxyError::ForwardFailed(_) => true,
// 上游 HTTP 错误:只对“可能瞬态”的状态码做同 Provider 重试(其余交给 failover
ProxyError::UpstreamError { status, .. } => {
*status == 408 || *status == 429 || *status >= 500
}
_ => false,
}
}
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error {
// 网络和上游错误:都应该尝试下一个供应商
@@ -526,15 +499,9 @@ impl RequestForwarder {
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
// 不代表其他供应商也会失败
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,
// Provider 级配置/转换问题:换一个 Provider 可能就能成功
ProxyError::ConfigError(_) => ErrorCategory::Retryable,
ProxyError::TransformError(_) => ErrorCategory::Retryable,
ProxyError::AuthError(_) => ErrorCategory::Retryable,
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable,
// 无可用供应商:所有供应商都试过了,无法重试
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(数据库/内部错误等):不是供应商能解决的问题
// 其他错误(配置错误、数据库错误等):不是供应商问题,无需重试
_ => ErrorCategory::NonRetryable,
}
}
-9
View File
@@ -26,11 +26,6 @@ pub struct RequestContext {
pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移)
providers: Vec<Provider>,
/// 请求开始时的“当前供应商”(用于判断是否需要同步 UI/托盘)
///
/// 这里使用本地 settings 的设备级 current provider。
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
pub current_provider_id: String,
/// 请求中的模型名称
pub request_model: String,
/// 日志标签(如 "Claude"、"Codex"、"Gemini"
@@ -63,8 +58,6 @@ impl RequestContext {
) -> Result<Self, ProxyError> {
let start_time = Instant::now();
let config = state.config.read().await.clone();
let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default();
// 从请求体提取模型名称
let request_model = body
@@ -99,7 +92,6 @@ impl RequestContext {
config,
provider,
providers,
current_provider_id,
request_model,
tag,
app_type_str,
@@ -141,7 +133,6 @@ impl RequestContext {
state.current_providers.clone(),
state.failover_manager.clone(),
state.app_handle.clone(),
self.current_provider_id.clone(),
)
}
+7 -8
View File
@@ -5,7 +5,7 @@
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
use crate::proxy::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -123,7 +123,7 @@ impl ProviderRouter {
///
/// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额,
/// 否则会导致该 Provider 长时间无法进入探测状态。
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> AllowResult {
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> bool {
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.allow_request().await
@@ -134,7 +134,6 @@ impl ProviderRouter {
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
success: bool,
error_msg: Option<String>,
) -> Result<(), AppError> {
@@ -147,10 +146,10 @@ impl ProviderRouter {
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if success {
breaker.record_success(used_half_open_permit).await;
breaker.record_success().await;
log::debug!("Provider {provider_id} request succeeded");
} else {
breaker.record_failure(used_half_open_permit).await;
breaker.record_failure().await;
log::warn!(
"Provider {} request failed: {}",
provider_id,
@@ -266,7 +265,7 @@ mod tests {
// 测试创建熔断器
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
assert!(breaker.allow_request().await.allowed);
assert!(breaker.allow_request().await);
}
#[tokio::test]
@@ -297,7 +296,7 @@ mod tests {
// 让 B 进入 Open 状态(failure_threshold=1
router
.record_result("b", "claude", false, false, Some("fail".to_string()))
.record_result("b", "claude", false, Some("fail".to_string()))
.await
.unwrap();
@@ -306,6 +305,6 @@ mod tests {
assert_eq!(providers.len(), 2);
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
assert!(router.allow_provider_request("b", "claude").await.allowed);
assert!(router.allow_provider_request("b", "claude").await);
}
}
-7
View File
@@ -191,23 +191,16 @@ impl ProxyServer {
.route("/v1/messages", post(handlers::handle_messages))
.route("/claude/v1/messages", post(handlers::handle_messages))
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route("/chat/completions", post(handlers::handle_chat_completions))
.route(
"/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route(
"/v1/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route(
"/codex/v1/chat/completions",
post(handlers::handle_chat_completions),
)
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
.route("/responses", post(handlers::handle_responses))
.route("/v1/responses", post(handlers::handle_responses))
.route("/v1/v1/responses", post(handlers::handle_responses))
.route("/codex/v1/responses", post(handlers::handle_responses))
// Gemini API (支持带前缀和不带前缀)
.route("/v1beta/*path", post(handlers::handle_gemini))
-8
View File
@@ -86,14 +86,6 @@ pub struct ProxyServerInfo {
pub started_at: String,
}
/// 各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
}
/// API 格式类型(预留,当前不需要格式转换)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
+9 -64
View File
@@ -17,27 +17,8 @@ impl McpService {
/// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
// 读取旧状态:用于处理“编辑时取消勾选某个应用”的场景(需要从对应 live 配置中移除)
let prev_apps = state
.db
.get_all_mcp_servers()?
.get(&server.id)
.map(|s| s.apps.clone())
.unwrap_or_default();
state.db.save_mcp_server(&server)?;
// 处理禁用:若旧版本启用但新版本取消,则需要从该应用的 live 配置移除
if prev_apps.claude && !server.apps.claude {
Self::remove_server_from_app(state, &server.id, &AppType::Claude)?;
}
if prev_apps.codex && !server.apps.codex {
Self::remove_server_from_app(state, &server.id, &AppType::Codex)?;
}
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
@@ -209,22 +190,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
// 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.claude = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
state.db.save_mcp_server(server)?;
// 同步到 Claude live 配置
Self::sync_server_to_apps(state, server)?;
}
}
}
@@ -243,22 +212,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
// 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.codex = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
state.db.save_mcp_server(server)?;
// 同步到 Codex live 配置
Self::sync_server_to_apps(state, server)?;
}
}
}
@@ -277,22 +234,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
// 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.gemini = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
state.db.save_mcp_server(server)?;
// 同步到 Gemini live 配置
Self::sync_server_to_apps(state, server)?;
}
}
}
+14 -30
View File
@@ -144,29 +144,9 @@ impl ProviderService {
state.db.save_provider(app_type.as_str(), &provider)?;
if is_current {
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
// - 不写 Live 配置(否则会破坏接管)
// - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置)
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let should_skip_live_write = is_app_taken_over && is_proxy_running;
if should_skip_live_write {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), &provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
write_live_snapshot(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
write_live_snapshot(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
Ok(true)
@@ -211,15 +191,12 @@ impl ProviderService {
// 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
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_takeover_flag =
futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false);
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = is_app_taken_over && is_proxy_running;
// Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running
let should_hot_switch = is_takeover_flag && is_proxy_running;
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
@@ -254,6 +231,13 @@ impl ProviderService {
}
// Normal mode: full switch with Live config write
// Also clear stale takeover flag if proxy is not running but flag was set
if is_takeover_flag && !is_proxy_running {
log::warn!("检测到代理接管标志残留(代理已停止),清除标志并执行正常切换");
// Clear stale takeover flag
let _ = futures::executor::block_on(state.db.set_live_takeover_active(false));
}
Self::switch_normal(state, app_type, id, &providers)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.9.0-1",
"version": "3.8.2",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+1 -11
View File
@@ -154,12 +154,6 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
// 模拟 Codex 已安装/已初始化:存在 ~/.codex 目录
let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir");
}
let mut config = MultiAppConfig::default();
config.mcp.codex.servers.insert(
"stdio-enabled".into(),
@@ -176,6 +170,7 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
let path = cc_switch_lib::get_codex_config_path();
assert!(path.exists(), "config.toml should be created");
let text = fs::read_to_string(&path).expect("read config.toml");
assert!(
@@ -599,11 +594,6 @@ command = "echo"
fn sync_claude_enabled_mcp_projects_to_user_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create claude dir");
let mut config = MultiAppConfig::default();
config.mcp.claude.servers.insert(
-308
View File
@@ -246,311 +246,3 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
"codex config should include the enabled server definition"
);
}
#[test]
fn enabling_codex_mcp_skips_when_codex_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Codex 配置目录不存在(模拟“未安装/未运行过 Codex CLI”)
assert!(
!home.join(".codex").exists(),
"~/.codex should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Codex 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Codex:目录缺失时应跳过写入(不创建 ~/.codex/config.toml
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle codex should succeed even when ~/.codex is missing");
assert!(
!home.join(".codex").exists(),
"~/.codex should still not exist after skipped sync"
);
}
#[test]
fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create ~/.claude dir");
// 先创建一个启用 Claude 的 MCP 服务器
let state = support::create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert should sync to Claude live config");
// 确认已写入 ~/.claude.json
let mcp_path = get_claude_mcp_path();
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_some(),
"echo should exist in Claude live config after enabling"
);
// 再次 upsert:取消勾选 Claudeapps.claude=false),应从 Claude live 配置中移除
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert disabling app should remove from Claude live config");
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json after disable");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_none(),
"echo should be removed from Claude live config after disabling"
);
}
#[test]
fn import_mcp_from_multiple_apps_merges_enabled_flags() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 1) Claude: ~/.claude.json
let mcp_path = get_claude_mcp_path();
let claude_json = json!({
"mcpServers": {
"shared": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
&mcp_path,
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed ~/.claude.json");
// 2) Codex: ~/.codex/config.toml
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("config.toml"),
r#"[mcp_servers.shared]
type = "stdio"
command = "echo"
"#,
)
.expect("seed ~/.codex/config.toml");
let state = support::create_test_state().expect("create test state");
McpService::import_from_claude(&state).expect("import from claude");
McpService::import_from_codex(&state).expect("import from codex");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("shared").expect("shared server exists");
assert!(entry.apps.claude, "shared should enable Claude");
assert!(entry.apps.codex, "shared should enable Codex");
}
#[test]
fn import_mcp_from_gemini_sse_url_only_is_valid() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Gemini MCP 位于 ~/.gemini/settings.json
let gemini_dir = home.join(".gemini");
fs::create_dir_all(&gemini_dir).expect("create gemini dir");
let settings_path = gemini_dir.join("settings.json");
// Gemini SSE:只包含 urlGemini 不使用 type 字段)
let gemini_settings = json!({
"mcpServers": {
"sse-server": {
"url": "https://example.com/sse"
}
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"),
)
.expect("seed ~/.gemini/settings.json");
let state = support::create_test_state().expect("create test state");
let changed = McpService::import_from_gemini(&state).expect("import from gemini");
assert!(changed > 0, "should import at least 1 server");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("sse-server").expect("sse-server exists");
assert!(entry.apps.gemini, "imported server should enable Gemini");
assert_eq!(
entry.server.get("type").and_then(|v| v.as_str()),
Some("sse"),
"Gemini url-only server should be normalized to type=sse in unified structure"
);
}
#[test]
fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Gemini 配置目录不存在(模拟“未安装/未运行过 Gemini CLI”)
assert!(
!home.join(".gemini").exists(),
"~/.gemini should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Gemini 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "gemini-server".to_string(),
name: "Gemini Server".to_string(),
server: json!({
"type": "sse",
"url": "https://example.com/sse"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Gemini:目录缺失时应跳过写入(不创建 ~/.gemini/settings.json
McpService::toggle_app(&state, "gemini-server", AppType::Gemini, true)
.expect("toggle gemini should succeed even when ~/.gemini is missing");
assert!(
!home.join(".gemini").exists(),
"~/.gemini should still not exist after skipped sync"
);
}
#[test]
fn enabling_claude_mcp_skips_when_claude_config_absent() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Claude 相关目录/文件都不存在(模拟“未安装/未运行过 Claude”)
assert!(
!home.join(".claude").exists(),
"~/.claude should not exist in fresh test environment"
);
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Claude 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "claude-server".to_string(),
name: "Claude Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Claude:配置缺失时应跳过写入(不创建 ~/.claude.json
McpService::toggle_app(&state, "claude-server", AppType::Claude, true)
.expect("toggle claude should succeed even when ~/.claude is missing");
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should still not exist after skipped sync"
);
}
+15 -28
View File
@@ -65,15 +65,7 @@ function App() {
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
// 获取代理服务状态
const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 任意应用的代理是否开启
const isTakeoverActive =
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
false;
const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus();
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
@@ -332,7 +324,7 @@ function App() {
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
isProxyTakeover={isProxyRunning && isTakeoverActive}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
@@ -429,7 +421,7 @@ function App() {
rel="noreferrer"
className={cn(
"text-xl font-semibold transition-colors",
isProxyRunning && isCurrentAppTakeoverActive
isProxyRunning && isTakeoverActive
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
)}
@@ -499,26 +491,22 @@ function App() {
)}
{currentView === "providers" && (
<>
<ProxyToggle activeApp={activeApp} />
<ProxyToggle />
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="bg-muted p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="h-4 w-4 flex-shrink-0" />
</Button>
{hasSkillsSupport && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
@@ -590,7 +578,6 @@ function App() {
}}
onSubmit={handleEditProvider}
appId={activeApp}
isProxyTakeover={isProxyRunning && isTakeoverActive}
/>
{usageProvider && (
@@ -16,7 +16,6 @@ interface EditProviderDialogProps {
onOpenChange: (open: boolean) => void;
onSubmit: (provider: Provider) => Promise<void> | void;
appId: AppId;
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
}
export function EditProviderDialog({
@@ -25,7 +24,6 @@ export function EditProviderDialog({
onOpenChange,
onSubmit,
appId,
isProxyTakeover = false,
}: EditProviderDialogProps) {
const { t } = useTranslation();
@@ -52,16 +50,6 @@ export function EditProviderDialog({
return;
}
// 代理接管模式:Live 配置已被代理改写,读取 live 会导致编辑界面展示代理地址/占位符等内容
// 因此直接回退到 SSOT(数据库)配置,避免用户困惑与误保存
if (isProxyTakeover) {
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
return;
}
try {
const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) {
@@ -94,7 +82,7 @@ export function EditProviderDialog({
return () => {
cancelled = true;
};
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react";
import { useState, useCallback } from "react";
import type { ProviderCategory } from "@/types";
import {
getApiKeyFromConfig,
@@ -32,28 +32,6 @@ export function useApiKeyState({
return "";
});
// 当外部通过 form.reset / 读取 live 等方式更新配置时,同步回 API Key 状态
// - 仅在 JSON 可解析时同步,避免用户编辑 JSON 过程中因临时无效导致输入框闪烁
useEffect(() => {
if (!initialConfig) return;
try {
JSON.parse(initialConfig);
} catch {
return;
}
// 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key)
if (!hasApiKeyField(initialConfig, appType)) {
return;
}
const extracted = getApiKeyFromConfig(initialConfig, appType);
if (extracted !== apiKey) {
setApiKey(extracted);
}
}, [initialConfig, appType, apiKey]);
const handleApiKeyChange = useCallback(
(key: string) => {
setApiKey(key);
+22 -31
View File
@@ -9,49 +9,40 @@ import { Radio, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
interface ProxyToggleProps {
className?: string;
activeApp: AppId;
}
export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
const { t } = useTranslation();
const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =
useProxyStatus();
export function ProxyToggle({ className }: ProxyToggleProps) {
const {
isRunning,
isTakeoverActive,
startWithTakeover,
stopWithRestore,
isPending,
status,
} = useProxyStatus();
const handleToggle = async (checked: boolean) => {
await setTakeoverForApp({ appType: activeApp, enabled: checked });
if (checked) {
await startWithTakeover();
} else {
await stopWithRestore();
}
};
const takeoverEnabled = takeoverStatus?.[activeApp] || false;
const isActive = isRunning && isTakeoverActive;
const appLabel =
activeApp === "claude"
? "Claude"
: activeApp === "codex"
? "Codex"
: "Gemini";
const tooltipText = takeoverEnabled
? isRunning
? t("proxy.takeover.tooltip.active", {
defaultValue: `${appLabel} 已接管 - ${status?.address}:${status?.port}\n切换该应用供应商为热切换`,
})
: t("proxy.takeover.tooltip.broken", {
defaultValue: `${appLabel} 已接管,但代理服务未运行`,
})
: t("proxy.takeover.tooltip.inactive", {
defaultValue: `接管 ${appLabel} 的 Live 配置,让该应用请求走本地代理`,
});
const tooltipText = isActive
? `代理模式运行中 - ${status?.address}:${status?.port}\n切换供应商为热切换`
: "开启代理模式\n启用后自动接管 Live 配置";
return (
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
takeoverEnabled
isActive
? "bg-emerald-500/10 border border-emerald-500/30"
: "bg-muted/50 hover:bg-muted",
className,
@@ -64,7 +55,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
<Radio
className={cn(
"h-4 w-4 transition-colors",
takeoverEnabled
isActive
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
@@ -73,7 +64,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
<span
className={cn(
"text-sm font-medium transition-colors select-none",
takeoverEnabled
isActive
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
@@ -81,7 +72,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
Proxy
</span>
<Switch
checked={takeoverEnabled}
checked={isActive}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
+70 -172
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import {
Download,
Copy,
ExternalLink,
Info,
Loader2,
@@ -9,7 +8,6 @@ import {
Terminal,
CheckCircle2,
AlertCircle,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
@@ -19,7 +17,6 @@ import { settingsApi } from "@/lib/api";
import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
interface AboutSectionProps {
isPortable: boolean;
@@ -32,10 +29,6 @@ interface ToolVersion {
error: string | null;
}
const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest
npm i -g @openai/codex@latest
npm i -g @google/gemini-cli@latest`;
export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
const { t } = useTranslation();
@@ -54,18 +47,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
isChecking,
} = useUpdate();
const loadToolVersions = useCallback(async () => {
setIsLoadingTools(true);
try {
const tools = await settingsApi.getToolVersions();
setToolVersions(tools);
} catch (error) {
console.error("[AboutSection] Failed to load tool versions", error);
} finally {
setIsLoadingTools(false);
}
}, []);
useEffect(() => {
let active = true;
const load = async () => {
@@ -169,25 +150,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
const handleCopyInstallCommands = useCallback(async () => {
try {
await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS);
toast.success(t("settings.installCommandsCopied"), { closeButton: true });
} catch (error) {
console.error("[AboutSection] Failed to copy install commands", error);
toast.error(t("settings.installCommandsCopyFailed"));
}
}, [t]);
const displayVersion = version ?? t("common.unknown");
return (
<motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<section className="space-y-6">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("common.about")}</h3>
<p className="text-xs text-muted-foreground">
@@ -195,22 +161,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</p>
</header>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
>
<div className="rounded-xl border border-border bg-card/50 p-6 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background/80">
<Badge variant="outline" className="gap-1.5 bg-background">
<span className="text-muted-foreground">
{t("common.version")}
</span>
@@ -229,15 +185,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleOpenReleaseNotes}
className="h-8 gap-1.5 text-xs"
className="h-9"
>
<ExternalLink className="h-3.5 w-3.5" />
<ExternalLink className="mr-2 h-4 w-4" />
{t("settings.releaseNotes")}
</Button>
<Button
@@ -245,41 +201,34 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm"
onClick={handleCheckUpdate}
disabled={isChecking || isDownloading}
className="h-8 gap-1.5 text-xs"
className="min-w-[140px] h-9"
>
{isDownloading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.updating")}
</>
</span>
) : hasUpdate ? (
<>
<Download className="h-3.5 w-3.5" />
<span className="inline-flex items-center gap-2">
<Download className="h-4 w-4" />
{t("settings.updateTo", {
version: updateInfo?.availableVersion ?? "",
})}
</>
</span>
) : isChecking ? (
<>
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
<span className="inline-flex items-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
{t("settings.checking")}
</>
</span>
) : (
<>
<RefreshCw className="h-3.5 w-3.5" />
{t("settings.checkForUpdates")}
</>
t("settings.checkForUpdates")
)}
</Button>
</div>
</div>
{hasUpdate && updateInfo && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm"
>
<div className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm">
<p className="font-medium text-primary mb-1">
{t("settings.updateAvailable", {
version: updateInfo.availableVersion,
@@ -290,111 +239,60 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{updateInfo.notes}
</p>
)}
</motion.div>
</div>
)}
</motion.div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
onClick={loadToolVersions}
disabled={isLoadingTools}
>
<RefreshCw
className={
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
}
/>
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{["claude", "codex", "gemini"].map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
const displayName = tool?.name ?? toolName;
const title = tool?.version || tool?.error || t("common.unknown");
return (
<motion.div
key={toolName}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}
whileHover={{ scale: 1.02 }}
className="flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize">
{displayName}
</span>
</div>
{isLoadingTools ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : tool?.version ? (
<div className="flex items-center gap-1.5">
{tool.latest_version &&
tool.version !== tool.latest_version && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
{tool.latest_version}
</span>
)}
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
</div>
<div
className="text-xs font-mono text-muted-foreground truncate"
title={title}
>
{isLoadingTools
? t("common.loading")
: tool?.version
? tool.version
: tool?.error || t("common.notInstalled")}
</div>
</motion.div>
);
})}
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="space-y-3"
>
<h3 className="text-sm font-medium px-1">
{t("settings.oneClickInstall")}
</h3>
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{t("settings.oneClickInstallHint")}
</p>
<Button
size="sm"
variant="outline"
onClick={handleCopyInstallCommands}
className="h-7 gap-1.5 text-xs"
>
<Copy className="h-3.5 w-3.5" />
{t("common.copy")}
</Button>
</div>
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
{ONE_CLICK_INSTALL_COMMANDS}
</pre>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground px-1">
</h4>
<div className="grid gap-3 sm:grid-cols-3">
{isLoadingTools
? Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-20 rounded-xl border border-border bg-card/50 animate-pulse"
/>
))
: toolVersions.map((tool) => (
<div
key={tool.name}
className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize">
{tool.name}
</span>
</div>
{tool.version ? (
<div className="flex items-center gap-1.5">
{tool.latest_version &&
tool.version !== tool.latest_version && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
Update: {tool.latest_version}
</span>
)}
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
</div>
<div className="flex flex-col gap-0.5">
<div
className="text-xs font-mono truncate"
title={tool.version || tool.error || "Unknown"}
>
{tool.version ? tool.version : tool.error || "未安装"}
</div>
</div>
</div>
))}
</div>
</motion.div>
</motion.section>
</div>
</section>
);
}
+4 -4
View File
@@ -177,8 +177,8 @@ export function SettingsPage({
const {
isRunning,
startProxyServer,
stopWithRestore,
startWithTakeover: startProxy,
stopWithRestore: stopProxy,
isPending: isProxyPending,
} = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true);
@@ -186,9 +186,9 @@ export function SettingsPage({
const handleToggleProxy = async (checked: boolean) => {
try {
if (!checked) {
await stopWithRestore();
await stopProxy();
} else {
await startProxyServer();
await startProxy();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
+1 -1
View File
@@ -80,7 +80,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "Azure OpenAI",
websiteUrl:
"https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/codex",
"https://learn.microsoft.com/azure/ai-services/openai/how-to/overview",
category: "third_party",
isOfficial: true,
auth: generateThirdPartyAuth(""),
+21 -71
View File
@@ -6,11 +6,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type {
ProxyStatus,
ProxyServerInfo,
ProxyTakeoverStatus,
} from "@/types/proxy";
import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy";
import { extractErrorMessage } from "@/utils/errorUtils";
/**
@@ -30,47 +26,47 @@ export function useProxyStatus() {
placeholderData: (previousData) => previousData,
});
// 查询各应用接管状态
const { data: takeoverStatus } = useQuery({
queryKey: ["proxyTakeoverStatus"],
queryFn: () => invoke<ProxyTakeoverStatus>("get_proxy_takeover_status"),
placeholderData: (previousData) => previousData,
// 查询接管状态
const { data: isTakeoverActive } = useQuery({
queryKey: ["proxyTakeoverActive"],
queryFn: () => invoke<boolean>("is_live_takeover_active"),
});
// 启动服务器(总开关:仅启动服务,不接管)
const startProxyServerMutation = useMutation({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"),
// 启动服务器(带 Live 配置接管)
const startWithTakeoverMutation = useMutation({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_with_takeover"),
onSuccess: (info) => {
toast.success(
t("proxy.server.started", {
defaultValue: `代理服务已启 - ${info.address}:${info.port}`,
t("proxy.startedWithTakeover", {
defaultValue: `代理模式已启 - ${info.address}:${info.port}`,
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || "未知错误";
toast.error(
t("proxy.server.startFailed", {
defaultValue: `启动代理服务失败: ${detail}`,
t("proxy.startWithTakeoverFailed", {
defaultValue: `启动失败: ${detail}`,
}),
);
},
});
// 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置)
// 停止服务器(恢复 Live 配置)
const stopWithRestoreMutation = useMutation({
mutationFn: () => invoke("stop_proxy_with_restore"),
onSuccess: () => {
toast.success(
t("proxy.stoppedWithRestore", {
defaultValue: "代理服务已关闭,已恢复所有接管配置",
defaultValue: "代理模式已关闭,配置已恢复",
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
},
@@ -84,42 +80,6 @@ export function useProxyStatus() {
},
});
// 按应用开启/关闭接管
const setTakeoverForAppMutation = useMutation({
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
invoke("set_proxy_takeover_for_app", { appType, enabled }),
onSuccess: (_data, variables) => {
const appLabel =
variables.appType === "claude"
? "Claude"
: variables.appType === "codex"
? "Codex"
: "Gemini";
toast.success(
variables.enabled
? t("proxy.takeover.enabled", {
defaultValue: `已接管 ${appLabel} 配置(请求将走本地代理)`,
})
: t("proxy.takeover.disabled", {
defaultValue: `已恢复 ${appLabel} 配置`,
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || "未知错误";
toast.error(
t("proxy.takeover.failed", {
defaultValue: `操作失败: ${detail}`,
}),
);
},
});
// 代理模式切换供应商(热切换)
const switchProxyProviderMutation = useMutation({
mutationFn: ({
@@ -160,20 +120,12 @@ export function useProxyStatus() {
status,
isLoading,
isRunning: status?.running || false,
takeoverStatus,
isTakeoverActive:
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
false,
isTakeoverActive: isTakeoverActive || false,
// 启动/停止(总开关
startProxyServer: startProxyServerMutation.mutateAsync,
// 启动/停止(接管模式
startWithTakeover: startWithTakeoverMutation.mutateAsync,
stopWithRestore: stopWithRestoreMutation.mutateAsync,
// 按应用接管开关
setTakeoverForApp: setTakeoverForAppMutation.mutateAsync,
// 代理模式下切换供应商
switchProxyProvider: switchProxyProviderMutation.mutateAsync,
@@ -182,11 +134,9 @@ export function useProxyStatus() {
checkTakeoverActive,
// 加载状态
isStarting: startProxyServerMutation.isPending,
isStarting: startWithTakeoverMutation.isPending,
isStopping: stopWithRestoreMutation.isPending,
isPending:
startProxyServerMutation.isPending ||
stopWithRestoreMutation.isPending ||
setTakeoverForAppMutation.isPending,
startWithTakeoverMutation.isPending || stopWithRestoreMutation.isPending,
};
}
+1 -10
View File
@@ -17,7 +17,6 @@
"about": "About",
"version": "Version",
"loading": "Loading...",
"notInstalled": "Not installed",
"success": "Success",
"error": "Error",
"unknown": "Unknown",
@@ -29,10 +28,7 @@
"formatError": "Format failed: {{error}}",
"copy": "Copy",
"view": "View",
"back": "Back",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"notInstalled": "Not installed"
"back": "Back"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -209,11 +205,6 @@
"releaseNotes": "Release Notes",
"viewReleaseNotes": "View release notes for this version",
"viewCurrentReleaseNotes": "View current version release notes",
"oneClickInstall": "One-click Install",
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI",
"localEnvCheck": "Local environment check",
"installCommandsCopied": "Install commands copied",
"installCommandsCopyFailed": "Copy failed, please copy manually.",
"importFailedError": "Import config failed: {{message}}",
"exportFailedError": "Export config failed:",
"restartRequired": "Restart Required",
+1 -10
View File
@@ -17,7 +17,6 @@
"about": "バージョン情報",
"version": "バージョン",
"loading": "読み込み中...",
"notInstalled": "未インストール",
"success": "成功",
"error": "エラー",
"unknown": "不明",
@@ -29,10 +28,7 @@
"formatError": "整形に失敗しました: {{error}}",
"copy": "コピー",
"view": "表示",
"back": "戻る",
"refresh": "更新",
"refreshing": "更新中...",
"notInstalled": "未インストール"
"back": "戻る"
},
"apiKeyInput": {
"placeholder": "API Key を入力",
@@ -209,11 +205,6 @@
"releaseNotes": "リリースノート",
"viewReleaseNotes": "このバージョンのリリースノートを見る",
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
"oneClickInstall": "ワンクリックインストール",
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール",
"localEnvCheck": "ローカル環境チェック",
"installCommandsCopied": "インストールコマンドをコピーしました",
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
"importFailedError": "設定のインポートに失敗しました: {{message}}",
"exportFailedError": "設定のエクスポートに失敗しました:",
"restartRequired": "再起動が必要です",
+1 -10
View File
@@ -17,7 +17,6 @@
"about": "关于",
"version": "版本",
"loading": "加载中...",
"notInstalled": "未安装",
"success": "成功",
"error": "错误",
"unknown": "未知",
@@ -29,10 +28,7 @@
"formatError": "格式化失败:{{error}}",
"copy": "复制",
"view": "查看",
"back": "返回",
"refresh": "刷新",
"refreshing": "刷新中...",
"notInstalled": "未安装"
"back": "返回"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
@@ -209,11 +205,6 @@
"releaseNotes": "更新日志",
"viewReleaseNotes": "查看该版本更新日志",
"viewCurrentReleaseNotes": "查看当前版本更新日志",
"oneClickInstall": "一键安装",
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI",
"localEnvCheck": "本地环境检查",
"installCommandsCopied": "安装命令已复制",
"installCommandsCopyFailed": "复制失败,请手动复制。",
"importFailedError": "导入配置失败:{{message}}",
"exportFailedError": "导出配置失败:",
"restartRequired": "需要重启应用",
-6
View File
@@ -38,12 +38,6 @@ export interface ProxyServerInfo {
started_at: string;
}
export interface ProxyTakeoverStatus {
claude: boolean;
codex: boolean;
gemini: boolean;
}
export interface ProviderHealth {
provider_id: string;
app_type: string;
-8
View File
@@ -258,14 +258,6 @@ export const handlers = [
}),
),
http.post(`${TAURI_ENDPOINT}/get_proxy_takeover_status`, () =>
success({
claude: false,
codex: false,
gemini: false,
}),
),
http.post(`${TAURI_ENDPOINT}/is_live_takeover_active`, () => success(false)),
// Failover / circuit breaker defaults