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
55 changed files with 615 additions and 2969 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/), 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). 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 ## [3.8.0] - 2025-11-28
### Major Updates ### 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", "name": "cc-switch",
"version": "3.9.0-2", "version": "3.8.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": { "scripts": {
"dev": "pnpm tauri dev", "dev": "pnpm tauri dev",
+1 -1
View File
@@ -695,7 +695,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.9.0-2" version = "3.8.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.9.0-2" version = "3.8.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
+5 -15
View File
@@ -155,17 +155,11 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
match output { match output {
Ok(out) => { 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() { if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout }; let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw.is_empty() { (Some(extract_version(&raw)), None)
(None, Some("未安装或无法执行".to_string()))
} else {
(Some(extract_version(raw)), None)
}
} else { } else {
let err = if stderr.is_empty() { stdout } else { stderr }; let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
( (
None, None,
Some(if err.is_empty() { Some(if err.is_empty() {
@@ -245,13 +239,9 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
.output(); .output();
if let Ok(out) = 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() { if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout }; let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !raw.is_empty() { return (Some(extract_version(&raw)), None);
return (Some(extract_version(raw)), None);
}
} }
} }
} }
-29
View File
@@ -6,14 +6,6 @@ use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState; use crate::store::AppState;
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command]
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await
}
/// 启动代理服务器(带 Live 配置接管) /// 启动代理服务器(带 Live 配置接管)
#[tauri::command] #[tauri::command]
pub async fn start_proxy_with_takeover( 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 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] #[tauri::command]
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> { pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
+22 -23
View File
@@ -13,8 +13,6 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database { impl Database {
/// 导出为 SQLite 兼容的 SQL 文本 /// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
@@ -38,8 +36,7 @@ impl Database {
} }
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = sql_raw.trim_start_matches('\u{feff}'); let sql_content = Self::sanitize_import_sql(&sql_raw);
Self::validate_cc_switch_sql_export(sql_content)?;
// 导入前备份现有数据库 // 导入前备份现有数据库
let backup_path = self.backup_database_file()?; let backup_path = self.backup_database_file()?;
@@ -54,7 +51,7 @@ impl Database {
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn temp_conn
.execute_batch(sql_content) .execute_batch(&sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验 // 补齐缺失表/索引并进行基础校验
@@ -96,17 +93,26 @@ impl Database {
Ok(snapshot) Ok(snapshot)
} }
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> { /// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
let trimmed = sql.trim_start(); fn sanitize_import_sql(sql: &str) -> String {
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) { let mut cleaned = String::new();
return Ok(()); let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
} }
Err(AppError::localized( cleaned
"backup.sql.invalid_format",
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
"Only SQL backups exported by CC Switch are supported.",
))
} }
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
@@ -123,15 +129,8 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone(); let backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
{ {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
+18 -21
View File
@@ -16,18 +16,19 @@ impl Database {
let result = { let result = {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
"SELECT listen_address, listen_port, max_retries, "SELECT enabled, listen_address, listen_port, max_retries,
request_timeout, enable_logging, live_takeover_active request_timeout, enable_logging, live_takeover_active
FROM proxy_config WHERE id = 1", FROM proxy_config WHERE id = 1",
[], [],
|row| { |row| {
Ok(ProxyConfig { Ok(ProxyConfig {
listen_address: row.get(0)?, enabled: row.get::<_, i32>(0)? != 0,
listen_port: row.get::<_, i32>(1)? as u16, listen_address: row.get(1)?,
max_retries: row.get::<_, i32>(2)? as u8, listen_port: row.get::<_, i32>(2)? as u16,
request_timeout: row.get::<_, i32>(3)? as u64, max_retries: row.get::<_, i32>(3)? as u8,
enable_logging: row.get::<_, i32>(4)? != 0, request_timeout: row.get::<_, i32>(4)? as u64,
live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0, enable_logging: row.get::<_, i32>(5)? != 0,
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
}) })
}, },
) )
@@ -56,7 +57,7 @@ impl Database {
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))", datetime('now'))",
rusqlite::params![ rusqlite::params![
0, // 已移除自动启用逻辑,保留列但固定为 0 if config.enabled { 1 } else { 0 },
config.listen_address, config.listen_address,
config.listen_port as i32, config.listen_port as i32,
config.max_retries as i32, config.max_retries as i32,
@@ -84,8 +85,15 @@ impl Database {
/// 检查是否处于 Live 接管模式 /// 检查是否处于 Live 接管模式
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> { pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管) let conn = lock_conn!(self.conn);
self.has_any_live_backup().await 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 ==================== // ==================== Provider Health ====================
@@ -313,17 +321,6 @@ impl Database {
Ok(()) 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 配置备份 /// 获取 Live 配置备份
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> { pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
let conn = lock_conn!(self.conn); 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 结构的兼容性: /// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http" /// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 补齐 type: "sse"Gemini 以字段名推断传输类型) /// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 补齐 type: "stdio" /// - 仅有 command 字段 → 保持不变(stdio 类型)
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> { pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path(); let path = user_config_path();
if !path.exists() { 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("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string())); obj.insert("type".to_string(), Value::String("http".to_string()));
} }
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入 // 如果有 command 但没有 type,不添加 type(默认为 stdio
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()));
}
}
} }
} }
+54 -43
View File
@@ -49,6 +49,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc; use std::sync::Arc;
use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::RunEvent; use tauri::RunEvent;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -524,29 +525,48 @@ pub fn run() {
} }
} }
// 异常退出恢复 // 异常退出恢复 + 自动启动代理服务器
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态) // 1. 检测异常退出并恢复 Live 配置
let has_backups = match state.db.has_any_live_backup().await { match state.db.is_live_takeover_active().await {
Ok(v) => v, 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) => { Err(e) => {
log::error!("检查 Live 备份失败: {e}"); log::error!("检查接管状态失败: {e}");
false
} }
}; }
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if has_backups || live_taken_over { // 2. 自动启动代理服务器(如果配置为启用)
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置..."); match state.db.get_proxy_config().await {
if let Err(e) = state.proxy_service.recover_from_crash().await { Ok(config) => {
log::error!("恢复 Live 配置失败: {e}"); if config.enabled {
} else { log::info!("代理服务配置为启用,正在启动...");
log::info!("Live 配置已恢复"); match state.proxy_service.start_with_takeover().await {
Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}",
info.address,
info.port
),
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
} }
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
} }
}); });
@@ -652,11 +672,8 @@ pub fn run() {
commands::set_auto_launch, commands::set_auto_launch,
commands::get_auto_launch_status, commands::get_auto_launch_status,
// Proxy server management // Proxy server management
commands::start_proxy_server,
commands::start_proxy_with_takeover, commands::start_proxy_with_takeover,
commands::stop_proxy_with_restore, commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
commands::get_proxy_status, commands::get_proxy_status,
commands::get_proxy_config, commands::get_proxy_config,
commands::update_proxy_config, commands::update_proxy_config,
@@ -811,33 +828,27 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<store::AppState>() { if let Some(state) = app_handle.try_state::<store::AppState>() {
let proxy_service = &state.proxy_service; let proxy_service = &state.proxy_service;
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。 // 检查代理是否在运行
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
Err(e) => {
log::error!("退出时检查 Live 备份失败: {e}");
false
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
if needs_restore {
log::info!("检测到接管残留,开始恢复 Live 配置...");
if let Err(e) = proxy_service.stop_with_restore().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置");
}
return;
}
// 非接管模式:代理在运行则仅停止代理
if proxy_service.is_running().await { if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始停止..."); log::info!("检测到代理服务器正在运行,开始清理...");
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}"); // 检查是否处于 Live 接管模式
if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
if is_takeover {
// 接管模式:停止并恢复配置
if let Err(e) = proxy_service.stop_with_restore().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置");
}
} else {
// 非接管模式:仅停止代理
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}");
}
}
} }
log::info!("代理服务器清理完成"); log::info!("代理服务器清理完成");
} }
} }
-15
View File
@@ -8,12 +8,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; 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 /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -39,9 +33,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json /// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> { 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); let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled) crate::claude_mcp::set_mcp_servers_map(&enabled)
} }
@@ -116,9 +107,6 @@ pub fn sync_single_server_to_claude(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?; let current = crate::claude_mcp::read_mcp_servers_map()?;
@@ -132,9 +120,6 @@ pub fn sync_single_server_to_claude(
/// 从 Claude live 配置中移除单个 MCP 服务器 /// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> { pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?; 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}; 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 /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -279,9 +273,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
/// - 仅更新 `mcp_servers` 表,保留其它键 /// - 仅更新 `mcp_servers` 表,保留其它键
/// - 仅写入启用项;无启用项时清理 mcp_servers 表 /// - 仅写入启用项;无启用项时清理 mcp_servers 表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::{Item, Table}; use toml_edit::{Item, Table};
// 1) 收集启用项(Codex 维度) // 1) 收集启用项(Codex 维度)
@@ -348,9 +339,6 @@ pub fn sync_single_server_to_codex(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::Item; use toml_edit::Item;
// 读取现有的 config.toml // 读取现有的 config.toml
@@ -388,8 +376,7 @@ pub fn sync_single_server_to_codex(
doc["mcp_servers"][id] = Item::Table(toml_table); doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件 // 写回文件
let new_text = doc.to_string(); std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
crate::config::write_text_file(&config_path, &new_text)?;
Ok(()) Ok(())
} }
@@ -397,9 +384,6 @@ pub fn sync_single_server_to_codex(
/// 从 Codex live 配置中移除单个 MCP 服务器 /// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据 /// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { 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(); let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() { if !config_path.exists() {
@@ -428,8 +412,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
} }
// 写回文件 // 写回文件
let new_text = doc.to_string(); std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
crate::config::write_text_file(&config_path, &new_text)?;
Ok(()) Ok(())
} }
-15
View File
@@ -8,12 +8,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; 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 /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); 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 配置 /// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> { 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); let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled) crate::gemini_mcp::set_mcp_servers_map(&enabled)
} }
@@ -112,9 +103,6 @@ pub fn sync_single_server_to_gemini(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?; let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
@@ -127,9 +115,6 @@ pub fn sync_single_server_to_gemini(
/// 从 Gemini live 配置中移除单个 MCP 服务器 /// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> { pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?; 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>, 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 { impl CircuitBreaker {
/// 创建新的熔断器 /// 创建新的熔断器
pub fn new(config: CircuitBreakerConfig) -> Self { 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 state = *self.state.read().await;
let config = self.config.read().await;
match state { match state {
CircuitState::Closed => AllowResult { CircuitState::Closed => true,
allowed: true,
used_half_open_permit: false,
},
CircuitState::Open => { CircuitState::Open => {
let config = self.config.read().await;
// 检查是否应该尝试半开 // 检查是否应该尝试半开
if let Some(opened_at) = *self.last_opened_at.read().await { if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds { if opened_at.elapsed().as_secs() >= config.timeout_seconds {
@@ -158,47 +145,52 @@ impl CircuitBreaker {
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)" "Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
); );
self.transition_to_half_open().await; self.transition_to_half_open().await;
// 增加计数,确保 record_success/record_failure 减计数时不会下溢
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额 self.half_open_requests.fetch_add(1, Ordering::SeqCst);
let current_state = *self.state.read().await; return true;
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,
},
};
} }
} }
false
}
CircuitState::HalfOpen => {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
AllowResult { if current < max_half_open_requests {
allowed: false, log::debug!(
used_half_open_permit: false, "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 state = *self.state.read().await;
let config = self.config.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.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst); self.total_requests.fetch_add(1, Ordering::SeqCst);
match state { match state {
CircuitState::HalfOpen => { CircuitState::HalfOpen => {
// 释放 in-flight 名额(探测请求结束)
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1; let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!( log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})", "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 state = *self.state.read().await;
let config = self.config.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; let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
self.total_requests.fetch_add(1, Ordering::SeqCst); self.total_requests.fetch_add(1, Ordering::SeqCst);
@@ -246,6 +234,9 @@ impl CircuitBreaker {
// 检查是否应该打开熔断器 // 检查是否应该打开熔断器
match state { match state {
CircuitState::HalfOpen => { CircuitState::HalfOpen => {
// 释放 in-flight 名额(探测请求结束)
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
// HalfOpen 状态下失败,立即转为 Open // HalfOpen 状态下失败,立即转为 Open
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open"); log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
drop(config); drop(config);
@@ -316,56 +307,6 @@ impl CircuitBreaker {
self.transition_to_closed().await; 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) { async fn transition_to_open(&self) {
*self.state.write().await = CircuitState::Open; *self.state.write().await = CircuitState::Open;
@@ -376,12 +317,7 @@ impl CircuitBreaker {
/// 转换到半开状态 /// 转换到半开状态
async fn transition_to_half_open(&self) { async fn transition_to_half_open(&self) {
let mut state = self.state.write().await; *self.state.write().await = CircuitState::HalfOpen;
if *state != CircuitState::Open {
return;
}
*state = CircuitState::HalfOpen;
self.consecutive_successes.store(0, Ordering::SeqCst); self.consecutive_successes.store(0, Ordering::SeqCst);
// 重置半开状态的请求限流计数 // 重置半开状态的请求限流计数
self.half_open_requests.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_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await.allowed); assert!(breaker.allow_request().await);
// 记录 3 次失败 // 记录 3 次失败
for _ in 0..3 { for _ in 0..3 {
breaker.record_failure(false).await; breaker.record_failure().await;
} }
// 应该转换到打开状态 // 应该转换到打开状态
assert_eq!(breaker.get_state().await, CircuitState::Open); assert_eq!(breaker.get_state().await, CircuitState::Open);
assert!(!breaker.allow_request().await.allowed); assert!(!breaker.allow_request().await);
} }
#[tokio::test] #[tokio::test]
@@ -445,8 +381,8 @@ mod tests {
let breaker = CircuitBreaker::new(config); let breaker = CircuitBreaker::new(config);
// 打开熔断器 // 打开熔断器
breaker.record_failure(false).await; breaker.record_failure().await;
breaker.record_failure(false).await; breaker.record_failure().await;
assert_eq!(breaker.get_state().await, CircuitState::Open); assert_eq!(breaker.get_state().await, CircuitState::Open);
// 手动转换到半开状态 // 手动转换到半开状态
@@ -454,37 +390,13 @@ mod tests {
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen); assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 记录 2 次成功 // 记录 2 次成功
breaker.record_success(false).await; breaker.record_success().await;
breaker.record_success(false).await; breaker.record_success().await;
// 应该转换到关闭状态 // 应该转换到关闭状态
assert_eq!(breaker.get_state().await, CircuitState::Closed); 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] #[tokio::test]
async fn test_circuit_breaker_reset() { async fn test_circuit_breaker_reset() {
let config = CircuitBreakerConfig { let config = CircuitBreakerConfig {
@@ -494,13 +406,13 @@ mod tests {
let breaker = CircuitBreaker::new(config); let breaker = CircuitBreaker::new(config);
// 打开熔断器 // 打开熔断器
breaker.record_failure(false).await; breaker.record_failure().await;
breaker.record_failure(false).await; breaker.record_failure().await;
assert_eq!(breaker.get_state().await, CircuitState::Open); assert_eq!(breaker.get_state().await, CircuitState::Open);
// 重置 // 重置
breaker.reset().await; breaker.reset().await;
assert_eq!(breaker.get_state().await, CircuitState::Closed); assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await.allowed); assert!(breaker.allow_request().await);
} }
} }
+17 -51
View File
@@ -29,12 +29,9 @@ pub struct RequestForwarder {
failover_manager: Arc<FailoverSwitchManager>, failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘 /// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
/// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
} }
impl RequestForwarder { impl RequestForwarder {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
router: Arc<ProviderRouter>, router: Arc<ProviderRouter>,
timeout_secs: u64, timeout_secs: u64,
@@ -43,7 +40,6 @@ impl RequestForwarder {
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>, current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>, failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
) -> Self { ) -> Self {
let mut client_builder = Client::builder(); let mut client_builder = Client::builder();
if timeout_secs > 0 { if timeout_secs > 0 {
@@ -62,7 +58,6 @@ impl RequestForwarder {
current_providers, current_providers,
failover_manager, failover_manager,
app_handle, app_handle,
current_provider_id_at_start,
} }
} }
@@ -99,8 +94,10 @@ impl RequestForwarder {
{ {
Ok(response) => return Ok(response), Ok(response) => return Ok(response),
Err(e) => { Err(e) => {
// 只有“同一 Provider 内可重试”的错误才继续重试 let category = self.categorize_proxy_error(&e);
if !self.should_retry_same_provider(&e) {
// 只有可重试的错误才继续重试
if category == ErrorCategory::NonRetryable {
return Err(e); return Err(e);
} }
@@ -150,16 +147,17 @@ impl RequestForwarder {
); );
let mut last_error = None; let mut last_error = None;
let mut failover_happened = false;
let mut attempted_providers = 0usize; let mut attempted_providers = 0usize;
// 依次尝试每个供应商 // 依次尝试每个供应商
for provider in providers.iter() { for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
let permit = self if !self
.router .router
.allow_provider_request(&provider.id, app_type_str) .allow_provider_request(&provider.id, app_type_str)
.await; .await
if !permit.allowed { {
log::debug!( log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过", "[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str, app_type_str,
@@ -168,9 +166,10 @@ impl RequestForwarder {
continue; continue;
} }
let used_half_open_permit = permit.used_half_open_permit;
attempted_providers += 1; attempted_providers += 1;
if attempted_providers > 1 {
failover_happened = true;
}
log::info!( log::info!(
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})", "[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
@@ -203,13 +202,7 @@ impl RequestForwarder {
// 成功:记录成功并更新熔断器 // 成功:记录成功并更新熔断器
if let Err(e) = self if let Err(e) = self
.router .router
.record_result( .record_result(&provider.id, app_type_str, true, None)
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await .await
{ {
log::warn!("Failed to record success: {e}"); log::warn!("Failed to record success: {e}");
@@ -229,18 +222,16 @@ impl RequestForwarder {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.success_requests += 1; status.success_requests += 1;
status.last_error = None; status.last_error = None;
let should_switch = if failover_happened {
self.current_provider_id_at_start.as_str() != provider.id.as_str();
if should_switch {
status.failover_count += 1; status.failover_count += 1;
log::info!( log::info!(
"[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)", "[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)",
app_type_str, app_type_str,
provider.name, provider.name,
latency latency
); );
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider // 异步触发供应商切换,更新 UI 和托盘菜单
let fm = self.failover_manager.clone(); let fm = self.failover_manager.clone();
let ah = self.app_handle.clone(); let ah = self.app_handle.clone();
let pid = provider.id.clone(); let pid = provider.id.clone();
@@ -277,13 +268,7 @@ impl RequestForwarder {
// 失败:记录失败并更新熔断器 // 失败:记录失败并更新熔断器
if let Err(record_err) = self if let Err(record_err) = self
.router .router
.record_result( .record_result(&provider.id, app_type_str, false, Some(e.to_string()))
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(e.to_string()),
)
.await .await
{ {
log::warn!("Failed to record failure: {record_err}"); log::warn!("Failed to record failure: {record_err}");
@@ -504,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 { fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error { match error {
// 网络和上游错误:都应该尝试下一个供应商 // 网络和上游错误:都应该尝试下一个供应商
@@ -527,15 +499,9 @@ impl RequestForwarder {
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误 // 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
// 不代表其他供应商也会失败 // 不代表其他供应商也会失败
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable, 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, ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(数据库/内部错误等):不是供应商能解决的问题 // 其他错误(配置错误、数据库错误等):不是供应商问题,无需重试
_ => ErrorCategory::NonRetryable, _ => ErrorCategory::NonRetryable,
} }
} }
-9
View File
@@ -26,11 +26,6 @@ pub struct RequestContext {
pub provider: Provider, pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移) /// 完整的 Provider 列表(用于故障转移)
providers: Vec<Provider>, providers: Vec<Provider>,
/// 请求开始时的“当前供应商”(用于判断是否需要同步 UI/托盘)
///
/// 这里使用本地 settings 的设备级 current provider。
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
pub current_provider_id: String,
/// 请求中的模型名称 /// 请求中的模型名称
pub request_model: String, pub request_model: String,
/// 日志标签(如 "Claude"、"Codex"、"Gemini" /// 日志标签(如 "Claude"、"Codex"、"Gemini"
@@ -63,8 +58,6 @@ impl RequestContext {
) -> Result<Self, ProxyError> { ) -> Result<Self, ProxyError> {
let start_time = Instant::now(); let start_time = Instant::now();
let config = state.config.read().await.clone(); 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 let request_model = body
@@ -99,7 +92,6 @@ impl RequestContext {
config, config,
provider, provider,
providers, providers,
current_provider_id,
request_model, request_model,
tag, tag,
app_type_str, app_type_str,
@@ -141,7 +133,6 @@ impl RequestContext {
state.current_providers.clone(), state.current_providers.clone(),
state.failover_manager.clone(), state.failover_manager.clone(),
state.app_handle.clone(), state.app_handle.clone(),
self.current_provider_id.clone(),
) )
} }
+4 -4
View File
@@ -5,7 +5,7 @@
//! 重构后的结构: //! 重构后的结构:
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块 //! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
//! - 各 handler 只保留独特的业务逻辑 //! - 各 handler 只保留独特的业务逻辑
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退 //! - Claude 的格式转换逻辑保留在此文件(独有功能
use super::{ use super::{
error_mapper::{get_error_message, map_proxy_error_to_status}, error_mapper::{get_error_message, map_proxy_error_to_status},
@@ -54,8 +54,8 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// 处理 /v1/messages 请求(Claude API /// 处理 /v1/messages 请求(Claude API
/// ///
/// Claude 处理器包含独特的格式转换逻辑: /// Claude 处理器包含独特的格式转换逻辑:
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic OpenAI 转换) /// - 当使用 OpenRouter 等中转服务时,需要将 Anthropic 格式转换为 OpenAI 格式
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退) /// - 响应需要从 OpenAI 格式转回 Anthropic 格式
pub async fn handle_messages( pub async fn handle_messages(
State(state): State<ProxyState>, State(state): State<ProxyState>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
@@ -112,7 +112,7 @@ pub async fn handle_messages(
/// Claude 格式转换处理(独有逻辑) /// Claude 格式转换处理(独有逻辑)
/// ///
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用) /// 处理 OpenRouter 等需要格式转换的中转服务
async fn handle_claude_transform( async fn handle_claude_transform(
response: reqwest::Response, response: reqwest::Response,
ctx: &RequestContext, ctx: &RequestContext,
+7 -8
View File
@@ -5,7 +5,7 @@
use crate::database::Database; use crate::database::Database;
use crate::error::AppError; use crate::error::AppError;
use crate::provider::Provider; 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::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -123,7 +123,7 @@ impl ProviderRouter {
/// ///
/// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额, /// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额,
/// 否则会导致该 Provider 长时间无法进入探测状态。 /// 否则会导致该 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 circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.allow_request().await breaker.allow_request().await
@@ -134,7 +134,6 @@ impl ProviderRouter {
&self, &self,
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
used_half_open_permit: bool,
success: bool, success: bool,
error_msg: Option<String>, error_msg: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
@@ -147,10 +146,10 @@ impl ProviderRouter {
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if success { if success {
breaker.record_success(used_half_open_permit).await; breaker.record_success().await;
log::debug!("Provider {provider_id} request succeeded"); log::debug!("Provider {provider_id} request succeeded");
} else { } else {
breaker.record_failure(used_half_open_permit).await; breaker.record_failure().await;
log::warn!( log::warn!(
"Provider {} request failed: {}", "Provider {} request failed: {}",
provider_id, provider_id,
@@ -266,7 +265,7 @@ mod tests {
// 测试创建熔断器 // 测试创建熔断器
let breaker = router.get_or_create_circuit_breaker("claude:test").await; let breaker = router.get_or_create_circuit_breaker("claude:test").await;
assert!(breaker.allow_request().await.allowed); assert!(breaker.allow_request().await);
} }
#[tokio::test] #[tokio::test]
@@ -297,7 +296,7 @@ mod tests {
// 让 B 进入 Open 状态(failure_threshold=1 // 让 B 进入 Open 状态(failure_threshold=1
router router
.record_result("b", "claude", false, false, Some("fail".to_string())) .record_result("b", "claude", false, Some("fail".to_string()))
.await .await
.unwrap(); .unwrap();
@@ -306,6 +305,6 @@ mod tests {
assert_eq!(providers.len(), 2); assert_eq!(providers.len(), 2);
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝) // 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
assert!(router.allow_provider_request("b", "claude").await.allowed); assert!(router.allow_provider_request("b", "claude").await);
} }
} }
+1 -1
View File
@@ -87,7 +87,7 @@ pub trait ProviderAdapter: Send + Sync {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// 默认返回 `false`(透传模式)。 /// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。 /// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
/// ///
/// # Arguments /// # Arguments
/// * `provider` - Provider 配置 /// * `provider` - Provider 配置
+20 -52
View File
@@ -5,7 +5,7 @@
//! ## 认证模式 //! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用) //! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider; use crate::provider::Provider;
@@ -28,8 +28,10 @@ impl ClaudeAdapter {
/// - Claude: 默认 Anthropic 官方 /// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType { pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 OpenRouter // 检测 OpenRouter
if self.is_openrouter(provider) { if let Ok(base_url) = self.extract_base_url(provider) {
return ProviderType::OpenRouter; if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
} }
// 检测 ClaudeAuth (仅 Bearer 认证) // 检测 ClaudeAuth (仅 Bearer 认证)
@@ -85,14 +87,6 @@ impl ClaudeAdapter {
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
return Some(key.to_string()); return Some(key.to_string());
} }
if let Some(key) = env
.get("ANTHROPIC_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
return Some(key.to_string());
}
// OpenRouter key // OpenRouter key
if let Some(key) = env if let Some(key) = env
.get("OPENROUTER_API_KEY") .get("OPENROUTER_API_KEY")
@@ -192,17 +186,12 @@ impl ProviderAdapter for ClaudeAdapter {
} }
fn build_url(&self, base_url: &str, endpoint: &str) -> String { fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// NOTE: // OpenRouter 使用 /v1/chat/completions
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages` if base_url.contains("openrouter.ai") {
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
// }
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可恢复下面这段分支:
//
// if base_url.contains("openrouter.ai") {
// return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
// }
// Anthropic 直连
format!( format!(
"{}/{}", "{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
@@ -218,25 +207,19 @@ impl ProviderAdapter for ClaudeAdapter {
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01"), .header("anthropic-version", "2023-06-01"),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key // ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => request AuthStrategy::ClaudeAuth => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
// OpenRouter: Bearer // OpenRouter: Bearer
AuthStrategy::Bearer => request AuthStrategy::Bearer => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
_ => request, _ => request,
} }
} }
fn needs_transform(&self, _provider: &Provider) -> bool { fn needs_transform(&self, provider: &Provider) -> bool {
// NOTE: self.is_openrouter(provider)
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
// Anthropic ↔ OpenAI 的格式转换。
//
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
// self.is_openrouter(_provider)
false
} }
fn transform_request( fn transform_request(
@@ -301,21 +284,6 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::Anthropic); assert_eq!(auth.strategy, AuthStrategy::Anthropic);
} }
#[test]
fn test_extract_auth_anthropic_api_key() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_API_KEY": "sk-ant-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test] #[test]
fn test_extract_auth_openrouter() { fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
@@ -410,7 +378,7 @@ mod tests {
fn test_build_url_openrouter() { fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages"); assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
} }
#[test] #[test]
@@ -429,6 +397,6 @@ mod tests {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api" "ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
} }
})); }));
assert!(!adapter.needs_transform(&openrouter_provider)); assert!(adapter.needs_transform(&openrouter_provider));
} }
} }
+4 -8
View File
@@ -48,21 +48,17 @@ pub enum ProviderType {
Gemini, Gemini,
/// Google Gemini CLI (OAuth Bearer) /// Google Gemini CLI (OAuth Bearer)
GeminiCli, GeminiCli,
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用) /// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
OpenRouter, OpenRouter,
} }
impl ProviderType { impl ProviderType {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式 /// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
#[allow(dead_code)] #[allow(dead_code)]
pub fn needs_transform(&self) -> bool { pub fn needs_transform(&self) -> bool {
match self { matches!(self, ProviderType::OpenRouter)
ProviderType::OpenRouter => false,
_ => false,
}
} }
/// 获取默认端点 /// 获取默认端点
@@ -219,7 +215,7 @@ mod tests {
assert!(!ProviderType::Codex.needs_transform()); assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform()); assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform()); assert!(!ProviderType::GeminiCli.needs_transform());
assert!(!ProviderType::OpenRouter.needs_transform()); assert!(ProviderType::OpenRouter.needs_transform());
} }
#[test] #[test]
-7
View File
@@ -191,23 +191,16 @@ impl ProxyServer {
.route("/v1/messages", post(handlers::handle_messages)) .route("/v1/messages", post(handlers::handle_messages))
.route("/claude/v1/messages", post(handlers::handle_messages)) .route("/claude/v1/messages", post(handlers::handle_messages))
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀) // OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route("/chat/completions", post(handlers::handle_chat_completions))
.route( .route(
"/v1/chat/completions", "/v1/chat/completions",
post(handlers::handle_chat_completions), post(handlers::handle_chat_completions),
) )
.route(
"/v1/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route( .route(
"/codex/v1/chat/completions", "/codex/v1/chat/completions",
post(handlers::handle_chat_completions), post(handlers::handle_chat_completions),
) )
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀) // OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
.route("/responses", post(handlers::handle_responses))
.route("/v1/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)) .route("/codex/v1/responses", post(handlers::handle_responses))
// Gemini API (支持带前缀和不带前缀) // Gemini API (支持带前缀和不带前缀)
.route("/v1beta/*path", post(handlers::handle_gemini)) .route("/v1beta/*path", post(handlers::handle_gemini))
+3 -8
View File
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
/// 代理服务器配置 /// 代理服务器配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig { pub struct ProxyConfig {
/// 是否启用代理服务
pub enabled: bool,
/// 监听地址 /// 监听地址
pub listen_address: String, pub listen_address: String,
/// 监听端口 /// 监听端口
@@ -21,6 +23,7 @@ pub struct ProxyConfig {
impl Default for ProxyConfig { impl Default for ProxyConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false,
listen_address: "127.0.0.1".to_string(), listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口 listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3, max_retries: 3,
@@ -83,14 +86,6 @@ pub struct ProxyServerInfo {
pub started_at: String, pub started_at: String,
} }
/// 各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
}
/// API 格式类型(预留,当前不需要格式转换) /// API 格式类型(预留,当前不需要格式转换)
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] #[allow(dead_code)]
+9 -64
View File
@@ -17,27 +17,8 @@ impl McpService {
/// 添加或更新 MCP 服务器 /// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> { 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)?; 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)?; Self::sync_server_to_apps(state, &server)?;
@@ -209,22 +190,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
// 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致) state.db.save_mcp_server(server)?;
let to_save = if let Some(existing_server) = existing.get(&server.id) { // 同步到 Claude live 配置
let mut merged = existing_server.clone(); Self::sync_server_to_apps(state, server)?;
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)?;
} }
} }
} }
@@ -243,22 +212,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
// 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致) state.db.save_mcp_server(server)?;
let to_save = if let Some(existing_server) = existing.get(&server.id) { // 同步到 Codex live 配置
let mut merged = existing_server.clone(); Self::sync_server_to_apps(state, server)?;
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)?;
} }
} }
} }
@@ -277,22 +234,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
// 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致) state.db.save_mcp_server(server)?;
let to_save = if let Some(existing_server) = existing.get(&server.id) { // 同步到 Gemini live 配置
let mut merged = existing_server.clone(); Self::sync_server_to_apps(state, server)?;
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)?;
} }
} }
} }
+14 -30
View File
@@ -144,29 +144,9 @@ impl ProviderService {
state.db.save_provider(app_type.as_str(), &provider)?; state.db.save_provider(app_type.as_str(), &provider)?;
if is_current { if is_current {
// 如果代理接管模式处于激活状态,并且代理服务正在运行: write_live_snapshot(&app_type, &provider)?;
// - 不写 Live 配置(否则会破坏接管) // Sync MCP
// - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置) McpService::sync_all_enabled(state)?;
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)?;
}
} }
Ok(true) Ok(true)
@@ -211,15 +191,12 @@ impl ProviderService {
// Check if proxy takeover mode is active AND proxy server is actually running // Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode // Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function // Use blocking wait since this is a sync function
let is_app_taken_over = let is_takeover_flag =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false);
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); 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 // Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running
let should_hot_switch = is_app_taken_over && is_proxy_running; let should_hot_switch = is_takeover_flag && is_proxy_running;
if should_hot_switch { if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config // 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 // 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) 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", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.9.0-2", "version": "3.8.2",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+1 -3
View File
@@ -4,9 +4,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"titleBarStyle": "Visible", "titleBarStyle": "Visible"
"minWidth": 900,
"minHeight": 600
} }
] ]
} }
+1 -84
View File
@@ -154,12 +154,6 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); 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(); let mut config = MultiAppConfig::default();
config.mcp.codex.servers.insert( config.mcp.codex.servers.insert(
"stdio-enabled".into(), "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"); 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"); assert!(path.exists(), "config.toml should be created");
let text = fs::read_to_string(&path).expect("read config.toml"); let text = fs::read_to_string(&path).expect("read config.toml");
assert!( assert!(
@@ -599,11 +594,6 @@ command = "echo"
fn sync_claude_enabled_mcp_projects_to_user_config() { fn sync_claude_enabled_mcp_projects_to_user_config() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); 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(); let mut config = MultiAppConfig::default();
config.mcp.claude.servers.insert( config.mcp.claude.servers.insert(
@@ -1003,76 +993,3 @@ fn export_sql_returns_error_for_invalid_path() {
other => panic!("expected IoContext or Io error, got {other:?}"), other => panic!("expected IoContext or Io error, got {other:?}"),
} }
} }
#[test]
fn import_sql_rejects_non_cc_switch_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let import_path = home.join("not-cc-switch.sql");
fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql");
let err = state
.db
.import_sql(&import_path)
.expect_err("non-cc-switch sql should be rejected");
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "backup.sql.invalid_format");
}
other => panic!("expected Localized error, got {other:?}"),
}
}
#[test]
fn import_sql_accepts_cc_switch_exported_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Create a database with some data and export it.
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "test-provider".to_string();
manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let export_path = home.join("cc-switch-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Reset database, then import into a fresh one.
reset_test_fs();
let state = create_test_state().expect("create test state");
state
.db
.import_sql(&export_path)
.expect("import should succeed");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("load providers");
assert!(
providers.contains_key("test-provider"),
"imported providers should contain test-provider"
);
}
-308
View File
@@ -246,311 +246,3 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
"codex config should include the enabled server definition" "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"
);
}
+16 -38
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useQueryClient } from "@tanstack/react-query";
import { import {
Plus, Plus,
Settings, Settings,
@@ -48,7 +47,6 @@ type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
function App() { function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>("claude"); const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers"); const [currentView, setCurrentView] = useState<View>("providers");
@@ -67,9 +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"; "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 { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 获取供应商列表,当代理服务运行时自动刷新 // 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, { const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
@@ -272,20 +268,7 @@ function App() {
// 导入配置成功后刷新 // 导入配置成功后刷新
const handleImportSuccess = async () => { const handleImportSuccess = async () => {
try { await refetch();
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
});
await queryClient.refetchQueries({
queryKey: ["providers"],
type: "all",
});
} catch (error) {
console.error("[App] Failed to refresh providers after import", error);
await refetch();
}
try { try {
await providersApi.updateTrayMenu(); await providersApi.updateTrayMenu();
} catch (error) { } catch (error) {
@@ -341,7 +324,7 @@ function App() {
appId={activeApp} appId={activeApp}
isLoading={isLoading} isLoading={isLoading}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} isProxyTakeover={isProxyRunning && isTakeoverActive}
onSwitch={switchProvider} onSwitch={switchProvider}
onEdit={setEditingProvider} onEdit={setEditingProvider}
onDelete={setConfirmDelete} onDelete={setConfirmDelete}
@@ -438,7 +421,7 @@ function App() {
rel="noreferrer" rel="noreferrer"
className={cn( className={cn(
"text-xl font-semibold transition-colors", "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-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", : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
)} )}
@@ -508,26 +491,22 @@ function App() {
)} )}
{currentView === "providers" && ( {currentView === "providers" && (
<> <>
<ProxyToggle activeApp={activeApp} /> <ProxyToggle />
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} /> <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="bg-muted p-1 rounded-xl flex items-center gap-1"> <div className="bg-muted p-1 rounded-xl flex items-center gap-1">
<Button {hasSkillsSupport && (
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => setCurrentView("skills")} size="sm"
className={cn( onClick={() => setCurrentView("skills")}
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5", className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
"transition-all duration-200 ease-in-out overflow-hidden", title={t("skills.manage")}
hasSkillsSupport >
? "opacity-100 w-8 scale-100 px-2" <Wrench className="h-4 w-4" />
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1", </Button>
)} )}
title={t("skills.manage")}
>
<Wrench className="h-4 w-4 flex-shrink-0" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && ( {/* {isClaudeApp && (
<Button <Button
@@ -599,7 +578,6 @@ function App() {
}} }}
onSubmit={handleEditProvider} onSubmit={handleEditProvider}
appId={activeApp} appId={activeApp}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
/> />
{usageProvider && ( {usageProvider && (
+4 -4
View File
@@ -24,11 +24,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
}; };
return ( return (
<div className="inline-flex bg-muted rounded-xl p-1 gap-1"> <div className="inline-flex bg-muted rounded-lg p-1 gap-1">
<button <button
type="button" type="button"
onClick={() => handleSwitch("claude")} onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude" activeApp === "claude"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -50,7 +50,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("codex")} onClick={() => handleSwitch("codex")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex" activeApp === "codex"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -72,7 +72,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("gemini")} onClick={() => handleSwitch("gemini")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini" activeApp === "gemini"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -16,7 +16,6 @@ interface EditProviderDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (provider: Provider) => Promise<void> | void; onSubmit: (provider: Provider) => Promise<void> | void;
appId: AppId; appId: AppId;
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
} }
export function EditProviderDialog({ export function EditProviderDialog({
@@ -25,7 +24,6 @@ export function EditProviderDialog({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
appId, appId,
isProxyTakeover = false,
}: EditProviderDialogProps) { }: EditProviderDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,16 +50,6 @@ export function EditProviderDialog({
return; return;
} }
// 代理接管模式:Live 配置已被代理改写,读取 live 会导致编辑界面展示代理地址/占位符等内容
// 因此直接回退到 SSOT(数据库)配置,避免用户困惑与误保存
if (isProxyTakeover) {
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
return;
}
try { try {
const currentId = await providersApi.getCurrent(appId); const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) { if (currentId && provider.id === currentId) {
@@ -94,7 +82,7 @@ export function EditProviderDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象 }, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => { const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record< 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 type { ProviderCategory } from "@/types";
import { import {
getApiKeyFromConfig, getApiKeyFromConfig,
@@ -32,28 +32,6 @@ export function useApiKeyState({
return ""; 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( const handleApiKeyChange = useCallback(
(key: string) => { (key: string) => {
setApiKey(key); setApiKey(key);
+9 -11
View File
@@ -26,11 +26,8 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy"; import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(包含可编辑字段 // 表单数据类型(包含 enabled 字段,该字段由后端自动管理
type ProxyConfigForm = Pick< type ProxyConfigForm = Omit<ProxyConfig, "enabled">;
ProxyConfig,
"listen_address" | "listen_port" | "max_retries" | "request_timeout" | "enable_logging"
>;
const createProxyConfigSchema = (t: TFunction) => { const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z const requestTimeoutSchema = z
@@ -123,18 +120,19 @@ export function ProxySettingsDialog({
useEffect(() => { useEffect(() => {
if (config) { if (config) {
form.reset({ form.reset({
listen_address: config.listen_address, ...config,
listen_port: config.listen_port,
max_retries: config.max_retries,
request_timeout: config.request_timeout,
enable_logging: config.enable_logging,
}); });
} }
}, [config, form]); }, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => { const onSubmit = async (data: ProxyConfigForm) => {
try { try {
await updateConfig(data); // 添加 enabled 字段(从当前配置中获取,保持不变)
const configToSave: ProxyConfig = {
...data,
enabled: config?.enabled ?? true,
};
await updateConfig(configToSave);
closePanel(); closePanel();
} catch (error) { } catch (error) {
console.error("Save config failed:", error); console.error("Save config failed:", error);
+46 -54
View File
@@ -9,82 +9,74 @@ import { Radio, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
interface ProxyToggleProps { interface ProxyToggleProps {
className?: string; className?: string;
activeApp: AppId;
} }
export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { export function ProxyToggle({ className }: ProxyToggleProps) {
const { t } = useTranslation(); const {
const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } = isRunning,
useProxyStatus(); isTakeoverActive,
startWithTakeover,
stopWithRestore,
isPending,
status,
} = useProxyStatus();
const handleToggle = async (checked: boolean) => { 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 = const tooltipText = isActive
activeApp === "claude" ? `代理模式运行中 - ${status?.address}:${status?.port}\n切换供应商为热切换`
? "Claude" : "开启代理模式\n启用后自动接管 Live 配置";
: 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 配置,让该应用请求走本地代理`,
});
return ( return (
<div <div
className={cn( className={cn(
"p-1 rounded-xl transition-all", "flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
isActive
? "bg-emerald-500/10 border border-emerald-500/30"
: "bg-muted/50 hover:bg-muted",
className, className,
)} )}
title={tooltipText} title={tooltipText}
> >
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default"> {isPending ? (
{isPending ? ( <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> ) : (
) : ( <Radio
<Radio
className={cn(
"h-4 w-4 transition-colors",
takeoverEnabled
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<span
className={cn( className={cn(
"text-sm font-medium transition-colors select-none", "h-4 w-4 transition-colors",
takeoverEnabled isActive
? "text-emerald-600 dark:text-emerald-400" ? "text-emerald-500 animate-pulse"
: "text-muted-foreground", : "text-muted-foreground",
)} )}
>
Proxy
</span>
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
/> />
</div> )}
<span
className={cn(
"text-sm font-medium transition-colors select-none",
isActive
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
Proxy
</span>
<Switch
checked={isActive}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
/>
</div> </div>
); );
} }
+70 -172
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Download, Download,
Copy,
ExternalLink, ExternalLink,
Info, Info,
Loader2, Loader2,
@@ -9,7 +8,6 @@ import {
Terminal, Terminal,
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,7 +17,6 @@ import { settingsApi } from "@/lib/api";
import { useUpdate } from "@/contexts/UpdateContext"; import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater"; import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
interface AboutSectionProps { interface AboutSectionProps {
isPortable: boolean; isPortable: boolean;
@@ -32,10 +29,6 @@ interface ToolVersion {
error: string | null; 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) { export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ... // ... (use hooks as before) ...
const { t } = useTranslation(); const { t } = useTranslation();
@@ -54,18 +47,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
isChecking, isChecking,
} = useUpdate(); } = 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(() => { useEffect(() => {
let active = true; let active = true;
const load = async () => { const load = async () => {
@@ -169,25 +150,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
} }
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]); }, [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"); const displayVersion = version ?? t("common.unknown");
return ( return (
<motion.section <section className="space-y-6">
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<header className="space-y-1"> <header className="space-y-1">
<h3 className="text-sm font-medium">{t("common.about")}</h3> <h3 className="text-sm font-medium">{t("common.about")}</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -195,22 +161,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</p> </p>
</header> </header>
<motion.div <div className="rounded-xl border border-border bg-card/50 p-6 space-y-6">
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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> <Badge variant="outline" className="gap-1.5 bg-background">
<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">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("common.version")} {t("common.version")}
</span> </span>
@@ -229,15 +185,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleOpenReleaseNotes} 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")} {t("settings.releaseNotes")}
</Button> </Button>
<Button <Button
@@ -245,41 +201,34 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm" size="sm"
onClick={handleCheckUpdate} onClick={handleCheckUpdate}
disabled={isChecking || isDownloading} disabled={isChecking || isDownloading}
className="h-8 gap-1.5 text-xs" className="min-w-[140px] h-9"
> >
{isDownloading ? ( {isDownloading ? (
<> <span className="inline-flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t("settings.updating")} {t("settings.updating")}
</> </span>
) : hasUpdate ? ( ) : hasUpdate ? (
<> <span className="inline-flex items-center gap-2">
<Download className="h-3.5 w-3.5" /> <Download className="h-4 w-4" />
{t("settings.updateTo", { {t("settings.updateTo", {
version: updateInfo?.availableVersion ?? "", version: updateInfo?.availableVersion ?? "",
})} })}
</> </span>
) : isChecking ? ( ) : isChecking ? (
<> <span className="inline-flex items-center gap-2">
<RefreshCw className="h-3.5 w-3.5 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
{t("settings.checking")} {t("settings.checking")}
</> </span>
) : ( ) : (
<> t("settings.checkForUpdates")
<RefreshCw className="h-3.5 w-3.5" />
{t("settings.checkForUpdates")}
</>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
{hasUpdate && updateInfo && ( {hasUpdate && updateInfo && (
<motion.div <div className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm">
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"
>
<p className="font-medium text-primary mb-1"> <p className="font-medium text-primary mb-1">
{t("settings.updateAvailable", { {t("settings.updateAvailable", {
version: updateInfo.availableVersion, version: updateInfo.availableVersion,
@@ -290,111 +239,60 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{updateInfo.notes} {updateInfo.notes}
</p> </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> </div>
<motion.div <div className="space-y-3">
initial={{ opacity: 0, y: 10 }} <h4 className="text-sm font-medium text-muted-foreground px-1">
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }} </h4>
className="space-y-3" <div className="grid gap-3 sm:grid-cols-3">
> {isLoadingTools
<h3 className="text-sm font-medium px-1"> ? Array.from({ length: 3 }).map((_, i) => (
{t("settings.oneClickInstall")} <div
</h3> key={i}
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm"> className="h-20 rounded-xl border border-border bg-card/50 animate-pulse"
<div className="flex items-center justify-between gap-2"> />
<p className="text-xs text-muted-foreground"> ))
{t("settings.oneClickInstallHint")} : toolVersions.map((tool) => (
</p> <div
<Button key={tool.name}
size="sm" className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50"
variant="outline" >
onClick={handleCopyInstallCommands} <div className="flex items-center justify-between">
className="h-7 gap-1.5 text-xs" <div className="flex items-center gap-2">
> <Terminal className="h-4 w-4 text-muted-foreground" />
<Copy className="h-3.5 w-3.5" /> <span className="text-sm font-medium capitalize">
{t("common.copy")} {tool.name}
</Button> </span>
</div> </div>
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto"> {tool.version ? (
{ONE_CLICK_INSTALL_COMMANDS} <div className="flex items-center gap-1.5">
</pre> {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> </div>
</motion.div> </div>
</motion.section> </section>
); );
} }
+4 -4
View File
@@ -177,8 +177,8 @@ export function SettingsPage({
const { const {
isRunning, isRunning,
startProxyServer, startWithTakeover: startProxy,
stopWithRestore, stopWithRestore: stopProxy,
isPending: isProxyPending, isPending: isProxyPending,
} = useProxyStatus(); } = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true); const [failoverEnabled, setFailoverEnabled] = useState(true);
@@ -186,9 +186,9 @@ export function SettingsPage({
const handleToggleProxy = async (checked: boolean) => { const handleToggleProxy = async (checked: boolean) => {
try { try {
if (!checked) { if (!checked) {
await stopWithRestore(); await stopProxy();
} else { } else {
await startProxyServer(); await startProxy();
} }
} catch (error) { } catch (error) {
console.error("Toggle proxy failed:", error); console.error("Toggle proxy failed:", error);
+1 -9
View File
@@ -185,8 +185,6 @@ export const providerPresets: ProviderPreset[] = [
}, },
}, },
category: "aggregator", category: "aggregator",
icon: "modelscope",
iconColor: "#624AFF",
}, },
{ {
name: "KAT-Coder", name: "KAT-Coder",
@@ -230,8 +228,6 @@ export const providerPresets: ProviderPreset[] = [
}, },
}, },
category: "cn_official", category: "cn_official",
icon: "longcat",
iconColor: "#29E154",
}, },
{ {
name: "MiniMax", name: "MiniMax",
@@ -334,8 +330,6 @@ export const providerPresets: ProviderPreset[] = [
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖 // 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"], endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
category: "aggregator", category: "aggregator",
icon: "aihubmix",
iconColor: "#006FFB",
}, },
{ {
name: "DMXAPI", name: "DMXAPI",
@@ -350,8 +344,6 @@ export const providerPresets: ProviderPreset[] = [
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖 // 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"], endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
category: "aggregator", category: "aggregator",
isPartner: true, // 合作伙伴
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
}, },
{ {
name: "PackyCode", name: "PackyCode",
@@ -389,6 +381,6 @@ export const providerPresets: ProviderPreset[] = [
}, },
category: "aggregator", category: "aggregator",
icon: "openrouter", icon: "openrouter",
iconColor: "#6566F1", iconColor: "#6366F1",
}, },
]; ];
+1 -3
View File
@@ -80,7 +80,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{ {
name: "Azure OpenAI", name: "Azure OpenAI",
websiteUrl: 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", category: "third_party",
isOfficial: true, isOfficial: true,
auth: generateThirdPartyAuth(""), auth: generateThirdPartyAuth(""),
@@ -131,8 +131,6 @@ requires_openai_auth = true`,
"gpt-5.1-codex", "gpt-5.1-codex",
), ),
endpointCandidates: ["https://www.dmxapi.cn/v1"], endpointCandidates: ["https://www.dmxapi.cn/v1"],
isPartner: true, // 合作伙伴
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
}, },
{ {
name: "PackyCode", name: "PackyCode",
+14 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
@@ -39,6 +39,15 @@ export function useImportExport(
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [backupId, setBackupId] = useState<string | null>(null); const [backupId, setBackupId] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const successTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) {
window.clearTimeout(successTimerRef.current);
}
};
}, []);
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setSelectedFile(""); setSelectedFile("");
@@ -96,10 +105,6 @@ export function useImportExport(
} }
setBackupId(result.backupId ?? null); setBackupId(result.backupId ?? null);
// 导入成功后立即触发外部刷新(与 live 同步结果解耦)
// - 避免 sync 失败时 UI 不刷新
// - 避免依赖 setTimeout(组件卸载会取消)
void onImportSuccess?.();
const syncResult = await syncCurrentProvidersLiveSafe(); const syncResult = await syncCurrentProvidersLiveSafe();
if (syncResult.ok) { if (syncResult.ok) {
@@ -110,6 +115,10 @@ export function useImportExport(
}), }),
{ closeButton: true }, { closeButton: true },
); );
successTimerRef.current = window.setTimeout(() => {
void onImportSuccess?.();
}, 1500);
} else { } else {
console.error( console.error(
"[useImportExport] Failed to sync live config", "[useImportExport] Failed to sync live config",
+21 -71
View File
@@ -6,11 +6,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy";
ProxyStatus,
ProxyServerInfo,
ProxyTakeoverStatus,
} from "@/types/proxy";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
/** /**
@@ -30,47 +26,47 @@ export function useProxyStatus() {
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
// 查询各应用接管状态 // 查询接管状态
const { data: takeoverStatus } = useQuery({ const { data: isTakeoverActive } = useQuery({
queryKey: ["proxyTakeoverStatus"], queryKey: ["proxyTakeoverActive"],
queryFn: () => invoke<ProxyTakeoverStatus>("get_proxy_takeover_status"), queryFn: () => invoke<boolean>("is_live_takeover_active"),
placeholderData: (previousData) => previousData,
}); });
// 启动服务器(总开关:仅启动服务,不接管) // 启动服务器(带 Live 配置接管)
const startProxyServerMutation = useMutation({ const startWithTakeoverMutation = useMutation({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"), mutationFn: () => invoke<ProxyServerInfo>("start_proxy_with_takeover"),
onSuccess: (info) => { onSuccess: (info) => {
toast.success( toast.success(
t("proxy.server.started", { t("proxy.startedWithTakeover", {
defaultValue: `代理服务已启 - ${info.address}:${info.port}`, defaultValue: `代理模式已启 - ${info.address}:${info.port}`,
}), }),
{ closeButton: true }, { closeButton: true },
); );
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
}, },
onError: (error: Error) => { onError: (error: Error) => {
const detail = extractErrorMessage(error) || "未知错误"; const detail = extractErrorMessage(error) || "未知错误";
toast.error( toast.error(
t("proxy.server.startFailed", { t("proxy.startWithTakeoverFailed", {
defaultValue: `启动代理服务失败: ${detail}`, defaultValue: `启动失败: ${detail}`,
}), }),
); );
}, },
}); });
// 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置) // 停止服务器(恢复 Live 配置)
const stopWithRestoreMutation = useMutation({ const stopWithRestoreMutation = useMutation({
mutationFn: () => invoke("stop_proxy_with_restore"), mutationFn: () => invoke("stop_proxy_with_restore"),
onSuccess: () => { onSuccess: () => {
toast.success( toast.success(
t("proxy.stoppedWithRestore", { t("proxy.stoppedWithRestore", {
defaultValue: "代理服务已关闭,已恢复所有接管配置", defaultValue: "代理模式已关闭,配置已恢复",
}), }),
{ closeButton: true }, { closeButton: true },
); );
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
// 清除所有供应商健康状态缓存(后端已清空数据库记录) // 清除所有供应商健康状态缓存(后端已清空数据库记录)
queryClient.invalidateQueries({ queryKey: ["providerHealth"] }); 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({ const switchProxyProviderMutation = useMutation({
mutationFn: ({ mutationFn: ({
@@ -160,20 +120,12 @@ export function useProxyStatus() {
status, status,
isLoading, isLoading,
isRunning: status?.running || false, isRunning: status?.running || false,
takeoverStatus, isTakeoverActive: isTakeoverActive || false,
isTakeoverActive:
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
false,
// 启动/停止(总开关 // 启动/停止(接管模式
startProxyServer: startProxyServerMutation.mutateAsync, startWithTakeover: startWithTakeoverMutation.mutateAsync,
stopWithRestore: stopWithRestoreMutation.mutateAsync, stopWithRestore: stopWithRestoreMutation.mutateAsync,
// 按应用接管开关
setTakeoverForApp: setTakeoverForAppMutation.mutateAsync,
// 代理模式下切换供应商 // 代理模式下切换供应商
switchProxyProvider: switchProxyProviderMutation.mutateAsync, switchProxyProvider: switchProxyProviderMutation.mutateAsync,
@@ -182,11 +134,9 @@ export function useProxyStatus() {
checkTakeoverActive, checkTakeoverActive,
// 加载状态 // 加载状态
isStarting: startProxyServerMutation.isPending, isStarting: startWithTakeoverMutation.isPending,
isStopping: stopWithRestoreMutation.isPending, isStopping: stopWithRestoreMutation.isPending,
isPending: isPending:
startProxyServerMutation.isPending || startWithTakeoverMutation.isPending || stopWithRestoreMutation.isPending,
stopWithRestoreMutation.isPending ||
setTakeoverForAppMutation.isPending,
}; };
} }
+4 -14
View File
@@ -17,7 +17,6 @@
"about": "About", "about": "About",
"version": "Version", "version": "Version",
"loading": "Loading...", "loading": "Loading...",
"notInstalled": "Not installed",
"success": "Success", "success": "Success",
"error": "Error", "error": "Error",
"unknown": "Unknown", "unknown": "Unknown",
@@ -29,10 +28,7 @@
"formatError": "Format failed: {{error}}", "formatError": "Format failed: {{error}}",
"copy": "Copy", "copy": "Copy",
"view": "View", "view": "View",
"back": "Back", "back": "Back"
"refresh": "Refresh",
"refreshing": "Refreshing...",
"notInstalled": "Not installed"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "Enter API Key", "placeholder": "Enter API Key",
@@ -150,7 +146,7 @@
"themeDark": "Dark", "themeDark": "Dark",
"themeSystem": "System", "themeSystem": "System",
"importExport": "SQL Import/Export", "importExport": "SQL Import/Export",
"importExportHint": "Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).", "importExportHint": "Import or export database SQL backups for migration or restore.",
"exportConfig": "Export SQL Backup", "exportConfig": "Export SQL Backup",
"selectConfigFile": "Select SQL File", "selectConfigFile": "Select SQL File",
"noFileSelected": "No configuration file selected.", "noFileSelected": "No configuration file selected.",
@@ -166,7 +162,7 @@
"selectFileFailed": "Please choose a valid SQL backup file", "selectFileFailed": "Please choose a valid SQL backup file",
"configCorrupted": "SQL file may be corrupted or invalid", "configCorrupted": "SQL file may be corrupted or invalid",
"backupId": "Backup ID", "backupId": "Backup ID",
"autoReload": "Data refreshed", "autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語", "languageOptionJapanese": "日本語",
@@ -209,11 +205,6 @@
"releaseNotes": "Release Notes", "releaseNotes": "Release Notes",
"viewReleaseNotes": "View release notes for this version", "viewReleaseNotes": "View release notes for this version",
"viewCurrentReleaseNotes": "View current version release notes", "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}}", "importFailedError": "Import config failed: {{message}}",
"exportFailedError": "Export config failed:", "exportFailedError": "Export config failed:",
"restartRequired": "Restart Required", "restartRequired": "Restart Required",
@@ -273,8 +264,7 @@
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount", "zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off", "packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9", "minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)", "minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
"dmxapi": "Claude Code exclusive model 66% OFF now!"
}, },
"parameterConfig": "Parameter Config - {{name}} *", "parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)", "mainModel": "Main Model (optional)",
+4 -14
View File
@@ -17,7 +17,6 @@
"about": "バージョン情報", "about": "バージョン情報",
"version": "バージョン", "version": "バージョン",
"loading": "読み込み中...", "loading": "読み込み中...",
"notInstalled": "未インストール",
"success": "成功", "success": "成功",
"error": "エラー", "error": "エラー",
"unknown": "不明", "unknown": "不明",
@@ -29,10 +28,7 @@
"formatError": "整形に失敗しました: {{error}}", "formatError": "整形に失敗しました: {{error}}",
"copy": "コピー", "copy": "コピー",
"view": "表示", "view": "表示",
"back": "戻る", "back": "戻る"
"refresh": "更新",
"refreshing": "更新中...",
"notInstalled": "未インストール"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "API Key を入力", "placeholder": "API Key を入力",
@@ -150,7 +146,7 @@
"themeDark": "ダーク", "themeDark": "ダーク",
"themeSystem": "システム", "themeSystem": "システム",
"importExport": "SQL インポート/エクスポート", "importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。", "importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。",
"exportConfig": "SQL バックアップをエクスポート", "exportConfig": "SQL バックアップをエクスポート",
"selectConfigFile": "SQL ファイルを選択", "selectConfigFile": "SQL ファイルを選択",
"noFileSelected": "ファイルが選択されていません。", "noFileSelected": "ファイルが選択されていません。",
@@ -166,7 +162,7 @@
"selectFileFailed": "有効な SQL バックアップファイルを選択してください", "selectFileFailed": "有効な SQL バックアップファイルを選択してください",
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります", "configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
"backupId": "バックアップ ID", "backupId": "バックアップ ID",
"autoReload": "データを更新しました", "autoReload": "2 秒後に自動で再読み込みします...",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語", "languageOptionJapanese": "日本語",
@@ -209,11 +205,6 @@
"releaseNotes": "リリースノート", "releaseNotes": "リリースノート",
"viewReleaseNotes": "このバージョンのリリースノートを見る", "viewReleaseNotes": "このバージョンのリリースノートを見る",
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る", "viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
"oneClickInstall": "ワンクリックインストール",
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール",
"localEnvCheck": "ローカル環境チェック",
"installCommandsCopied": "インストールコマンドをコピーしました",
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
"importFailedError": "設定のインポートに失敗しました: {{message}}", "importFailedError": "設定のインポートに失敗しました: {{message}}",
"exportFailedError": "設定のエクスポートに失敗しました:", "exportFailedError": "設定のエクスポートに失敗しました:",
"restartRequired": "再起動が必要です", "restartRequired": "再起動が必要です",
@@ -273,8 +264,7 @@
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引", "zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ", "packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から", "minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF", "minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF"
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!"
}, },
"parameterConfig": "パラメーター設定 - {{name}} *", "parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)", "mainModel": "メインモデル(任意)",
+4 -14
View File
@@ -17,7 +17,6 @@
"about": "关于", "about": "关于",
"version": "版本", "version": "版本",
"loading": "加载中...", "loading": "加载中...",
"notInstalled": "未安装",
"success": "成功", "success": "成功",
"error": "错误", "error": "错误",
"unknown": "未知", "unknown": "未知",
@@ -29,10 +28,7 @@
"formatError": "格式化失败:{{error}}", "formatError": "格式化失败:{{error}}",
"copy": "复制", "copy": "复制",
"view": "查看", "view": "查看",
"back": "返回", "back": "返回"
"refresh": "刷新",
"refreshing": "刷新中...",
"notInstalled": "未安装"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "请输入API Key", "placeholder": "请输入API Key",
@@ -150,7 +146,7 @@
"themeDark": "深色", "themeDark": "深色",
"themeSystem": "跟随系统", "themeSystem": "跟随系统",
"importExport": "SQL 导入导出", "importExport": "SQL 导入导出",
"importExportHint": "导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。", "importExportHint": "导入/导出数据库 SQL 备份,便于备份或迁移。",
"exportConfig": "导出 SQL 备份", "exportConfig": "导出 SQL 备份",
"selectConfigFile": "选择 SQL 文件", "selectConfigFile": "选择 SQL 文件",
"noFileSelected": "尚未选择配置文件。", "noFileSelected": "尚未选择配置文件。",
@@ -166,7 +162,7 @@
"selectFileFailed": "请选择有效的 SQL 备份文件", "selectFileFailed": "请选择有效的 SQL 备份文件",
"configCorrupted": "SQL 文件可能已损坏或格式不正确", "configCorrupted": "SQL 文件可能已损坏或格式不正确",
"backupId": "备份ID", "backupId": "备份ID",
"autoReload": "数据已刷新", "autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語", "languageOptionJapanese": "日本語",
@@ -209,11 +205,6 @@
"releaseNotes": "更新日志", "releaseNotes": "更新日志",
"viewReleaseNotes": "查看该版本更新日志", "viewReleaseNotes": "查看该版本更新日志",
"viewCurrentReleaseNotes": "查看当前版本更新日志", "viewCurrentReleaseNotes": "查看当前版本更新日志",
"oneClickInstall": "一键安装",
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI",
"localEnvCheck": "本地环境检查",
"installCommandsCopied": "安装命令已复制",
"installCommandsCopyFailed": "复制失败,请手动复制。",
"importFailedError": "导入配置失败:{{message}}", "importFailedError": "导入配置失败:{{message}}",
"exportFailedError": "导出配置失败:", "exportFailedError": "导出配置失败:",
"restartRequired": "需要重启应用", "restartRequired": "需要重启应用",
@@ -273,8 +264,7 @@
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠", "zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠", "packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起", "minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)", "minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!"
}, },
"parameterConfig": "参数配置 - {{name}} *", "parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)", "mainModel": "主模型 (可选)",
-1
View File
@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-4
View File
@@ -45,10 +45,6 @@ export const icons: Record<string, string> = {
yi: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>`, yi: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>`,
zeroone: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`, zeroone: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`,
zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`, zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`,
openrouter: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>`,
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
}; };
export const iconList = Object.keys(icons); export const iconList = Object.keys(icons);
-1
View File
@@ -1 +0,0 @@
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>

Before

Width:  |  Height:  |  Size: 819 B

-28
View File
@@ -303,34 +303,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["chatglm", "glm"], keywords: ["chatglm", "glm"],
defaultColor: "#0F62FE", defaultColor: "#0F62FE",
}, },
openrouter: {
name: "openrouter",
displayName: "OpenRouter",
category: "ai-provider",
keywords: ["openrouter", "router", "aggregator"],
defaultColor: "#6566F1",
},
longcat: {
name: "longcat",
displayName: "LongCat",
category: "ai-provider",
keywords: ["longcat", "long", "cat"],
defaultColor: "#29E154",
},
modelscope: {
name: "modelscope",
displayName: "ModelScope",
category: "ai-provider",
keywords: ["modelscope", "alibaba", "scope"],
defaultColor: "#624AFF",
},
aihubmix: {
name: "aihubmix",
displayName: "AiHubMix",
category: "ai-provider",
keywords: ["aihubmix", "hub", "mix", "aggregator"],
defaultColor: "#006FFB",
},
}; };
export function getIconMetadata(name: string): IconMetadata | undefined { export function getIconMetadata(name: string): IconMetadata | undefined {
-1
View File
@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>

Before

Width:  |  Height:  |  Size: 632 B

-1
View File
@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>

Before

Width:  |  Height:  |  Size: 906 B

+1 -6
View File
@@ -1,4 +1,5 @@
export interface ProxyConfig { export interface ProxyConfig {
enabled: boolean;
listen_address: string; listen_address: string;
listen_port: number; listen_port: number;
max_retries: number; max_retries: number;
@@ -37,12 +38,6 @@ export interface ProxyServerInfo {
started_at: string; started_at: string;
} }
export interface ProxyTakeoverStatus {
claude: boolean;
codex: boolean;
gemini: boolean;
}
export interface ProviderHealth { export interface ProviderHealth {
provider_id: string; provider_id: string;
app_type: string; app_type: string;
+6
View File
@@ -110,6 +110,12 @@ describe("useImportExport Hook", () => {
expect(result.current.status).toBe("success"); expect(result.current.status).toBe("success");
expect(result.current.backupId).toBe("backup-123"); expect(result.current.backupId).toBe("backup-123");
expect(toastSuccessMock).toHaveBeenCalledTimes(1); expect(toastSuccessMock).toHaveBeenCalledTimes(1);
// Skip delay to execute callback
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(onImportSuccess).toHaveBeenCalledTimes(1); expect(onImportSuccess).toHaveBeenCalledTimes(1);
}); });
-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)), http.post(`${TAURI_ENDPOINT}/is_live_takeover_active`, () => success(false)),
// Failover / circuit breaker defaults // Failover / circuit breaker defaults