mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 256903ee70 | |||
| f42f73ebb0 | |||
| ec6e113cf2 | |||
| ae837ade02 | |||
| a8fd1f0dd2 | |||
| fa33330b3b | |||
| 18207771ad | |||
| 0cd7d0756c | |||
| bca0997afa | |||
| 0ef8a4153f | |||
| 1b73b26c0e | |||
| 3d514c8250 | |||
| 18e973b920 | |||
| ec20ff4d8c |
@@ -5,6 +5,100 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# 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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.8.2",
|
"version": "3.9.0-1",
|
||||||
"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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -695,7 +695,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.8.2"
|
version = "3.9.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.8.2"
|
version = "3.9.0-1"
|
||||||
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"
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ 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(true).await
|
||||||
|
}
|
||||||
|
|
||||||
/// 启动代理服务器(带 Live 配置接管)
|
/// 启动代理服务器(带 Live 配置接管)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_proxy_with_takeover(
|
pub async fn start_proxy_with_takeover(
|
||||||
@@ -20,6 +28,27 @@ 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> {
|
||||||
|
|||||||
@@ -85,15 +85,8 @@ 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> {
|
||||||
let conn = lock_conn!(self.conn);
|
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
|
||||||
let active: i32 = conn
|
self.has_any_live_backup().await
|
||||||
.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 ====================
|
||||||
@@ -321,6 +314,17 @@ 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);
|
||||||
|
|||||||
@@ -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 字段 → 保持不变(SSE 类型)
|
/// - 仅有 url 字段 → 补齐 type: "sse"(Gemini 以字段名推断传输类型)
|
||||||
/// - 仅有 command 字段 → 保持不变(stdio 类型)
|
/// - 仅有 command 字段 → 补齐 type: "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,8 +65,15 @@ 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)
|
|
||||||
// 如果有 command 但没有 type,不添加 type(默认为 stdio)
|
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入
|
||||||
|
if obj.get("type").is_none() {
|
||||||
|
if obj.contains_key("command") {
|
||||||
|
obj.insert("type".to_string(), Value::String("stdio".to_string()));
|
||||||
|
} else if obj.contains_key("url") {
|
||||||
|
obj.insert("type".to_string(), Value::String("sse".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-13
@@ -49,7 +49,6 @@ 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};
|
||||||
|
|
||||||
@@ -531,24 +530,42 @@ pub fn run() {
|
|||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
|
|
||||||
// 1. 检测异常退出并恢复 Live 配置
|
// 1. 检测异常退出并恢复 Live 配置
|
||||||
match state.db.is_live_takeover_active().await {
|
let is_proxy_running = state.proxy_service.is_running().await;
|
||||||
Ok(true) => {
|
if !is_proxy_running {
|
||||||
// 接管标志为 true 但代理未运行 → 上次异常退出
|
let takeover_flag = match state.db.is_live_takeover_active().await {
|
||||||
if !state.proxy_service.is_running().await {
|
Ok(active) => active,
|
||||||
log::warn!("检测到上次异常退出,正在恢复 Live 配置...");
|
Err(e) => {
|
||||||
|
log::error!("检查接管状态失败: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_backups = match state.db.has_any_live_backup().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("检查 Live 备份失败: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 兜底检测:旧版本/极端窗口期可能出现“标志未写入,但 Live 已被写成占位符”的残留状态。
|
||||||
|
// 只有在存在备份时才检查占位符,避免误判覆盖用户正常配置。
|
||||||
|
let live_taken_over =
|
||||||
|
has_backups && state.proxy_service.detect_takeover_in_live_configs();
|
||||||
|
|
||||||
|
if takeover_flag || live_taken_over {
|
||||||
|
log::warn!("检测到上次异常退出或残留接管状态,正在恢复 Live 配置...");
|
||||||
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
||||||
log::error!("恢复 Live 配置失败: {e}");
|
log::error!("恢复 Live 配置失败: {e}");
|
||||||
} else {
|
} else {
|
||||||
log::info!("Live 配置已从异常退出中恢复");
|
log::info!("Live 配置已从异常退出中恢复");
|
||||||
}
|
}
|
||||||
|
} else if has_backups {
|
||||||
|
// 备份残留但 Live 未处于接管状态:清理敏感备份,避免长期存储 Token
|
||||||
|
if let Err(e) = state.db.delete_all_live_backups().await {
|
||||||
|
log::warn!("清理残留 Live 备份失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
|
||||||
// 正常状态,无需恢复
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("检查接管状态失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 自动启动代理服务器(如果配置为启用)
|
// 2. 自动启动代理服务器(如果配置为启用)
|
||||||
@@ -556,7 +573,7 @@ pub fn run() {
|
|||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
if config.enabled {
|
if config.enabled {
|
||||||
log::info!("代理服务配置为启用,正在启动...");
|
log::info!("代理服务配置为启用,正在启动...");
|
||||||
match state.proxy_service.start_with_takeover().await {
|
match state.proxy_service.start(true).await {
|
||||||
Ok(info) => log::info!(
|
Ok(info) => log::info!(
|
||||||
"代理服务器自动启动成功: {}:{}",
|
"代理服务器自动启动成功: {}:{}",
|
||||||
info.address,
|
info.address,
|
||||||
@@ -672,8 +689,11 @@ 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,
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ 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();
|
||||||
@@ -33,6 +39,9 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -107,6 +116,9 @@ 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()?;
|
||||||
|
|
||||||
@@ -120,6 +132,9 @@ 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()?;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ 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();
|
||||||
@@ -273,6 +279,9 @@ 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 维度)
|
||||||
@@ -339,6 +348,9 @@ 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
|
||||||
@@ -376,7 +388,8 @@ pub fn sync_single_server_to_codex(
|
|||||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
let new_text = doc.to_string();
|
||||||
|
crate::config::write_text_file(&config_path, &new_text)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -384,6 +397,9 @@ 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() {
|
||||||
@@ -412,7 +428,8 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
let new_text = doc.to_string();
|
||||||
|
crate::config::write_text_file(&config_path, &new_text)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ 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();
|
||||||
@@ -33,6 +39,9 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -103,6 +112,9 @@ 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()?;
|
||||||
|
|
||||||
@@ -115,6 +127,9 @@ 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()?;
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ 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 {
|
||||||
@@ -130,13 +140,16 @@ impl CircuitBreaker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否允许请求通过
|
/// 检查是否允许请求通过
|
||||||
pub async fn allow_request(&self) -> bool {
|
pub async fn allow_request(&self) -> AllowResult {
|
||||||
let state = *self.state.read().await;
|
let state = *self.state.read().await;
|
||||||
let config = self.config.read().await;
|
|
||||||
|
|
||||||
match state {
|
match state {
|
||||||
CircuitState::Closed => true,
|
CircuitState::Closed => AllowResult {
|
||||||
|
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 {
|
||||||
@@ -145,52 +158,47 @@ 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 减计数时不会下溢
|
|
||||||
self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
CircuitState::HalfOpen => {
|
|
||||||
// 半开状态限流:只允许有限请求通过进行探测
|
|
||||||
// 默认最多允许 1 个请求(可在配置中扩展)
|
|
||||||
let max_half_open_requests = 1u32;
|
|
||||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
if current < max_half_open_requests {
|
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
|
||||||
log::debug!(
|
let current_state = *self.state.read().await;
|
||||||
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
|
return match current_state {
|
||||||
current + 1,
|
CircuitState::Closed => AllowResult {
|
||||||
max_half_open_requests
|
allowed: true,
|
||||||
);
|
used_half_open_permit: false,
|
||||||
true
|
},
|
||||||
} else {
|
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||||
// 超过限额,回退计数,拒绝请求
|
CircuitState::Open => AllowResult {
|
||||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
allowed: false,
|
||||||
log::debug!(
|
used_half_open_permit: false,
|
||||||
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
|
},
|
||||||
);
|
};
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AllowResult {
|
||||||
|
allowed: false,
|
||||||
|
used_half_open_permit: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录成功
|
/// 记录成功
|
||||||
pub async fn record_success(&self) {
|
pub async fn record_success(&self, used_half_open_permit: bool) {
|
||||||
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: {})",
|
||||||
@@ -212,10 +220,14 @@ impl CircuitBreaker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 记录失败
|
/// 记录失败
|
||||||
pub async fn record_failure(&self) {
|
pub async fn record_failure(&self, used_half_open_permit: bool) {
|
||||||
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);
|
||||||
@@ -234,9 +246,6 @@ 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);
|
||||||
@@ -307,6 +316,56 @@ 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;
|
||||||
@@ -317,7 +376,12 @@ impl CircuitBreaker {
|
|||||||
|
|
||||||
/// 转换到半开状态
|
/// 转换到半开状态
|
||||||
async fn transition_to_half_open(&self) {
|
async fn transition_to_half_open(&self) {
|
||||||
*self.state.write().await = CircuitState::HalfOpen;
|
let mut state = self.state.write().await;
|
||||||
|
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);
|
||||||
@@ -359,16 +423,16 @@ mod tests {
|
|||||||
|
|
||||||
// 初始状态应该是关闭
|
// 初始状态应该是关闭
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
||||||
assert!(breaker.allow_request().await);
|
assert!(breaker.allow_request().await.allowed);
|
||||||
|
|
||||||
// 记录 3 次失败
|
// 记录 3 次失败
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
breaker.record_failure().await;
|
breaker.record_failure(false).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应该转换到打开状态
|
// 应该转换到打开状态
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||||
assert!(!breaker.allow_request().await);
|
assert!(!breaker.allow_request().await.allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -381,8 +445,8 @@ mod tests {
|
|||||||
let breaker = CircuitBreaker::new(config);
|
let breaker = CircuitBreaker::new(config);
|
||||||
|
|
||||||
// 打开熔断器
|
// 打开熔断器
|
||||||
breaker.record_failure().await;
|
breaker.record_failure(false).await;
|
||||||
breaker.record_failure().await;
|
breaker.record_failure(false).await;
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||||
|
|
||||||
// 手动转换到半开状态
|
// 手动转换到半开状态
|
||||||
@@ -390,13 +454,37 @@ mod tests {
|
|||||||
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
|
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
|
||||||
|
|
||||||
// 记录 2 次成功
|
// 记录 2 次成功
|
||||||
breaker.record_success().await;
|
breaker.record_success(false).await;
|
||||||
breaker.record_success().await;
|
breaker.record_success(false).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=0,allow_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 {
|
||||||
@@ -406,13 +494,13 @@ mod tests {
|
|||||||
let breaker = CircuitBreaker::new(config);
|
let breaker = CircuitBreaker::new(config);
|
||||||
|
|
||||||
// 打开熔断器
|
// 打开熔断器
|
||||||
breaker.record_failure().await;
|
breaker.record_failure(false).await;
|
||||||
breaker.record_failure().await;
|
breaker.record_failure(false).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);
|
assert!(breaker.allow_request().await.allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ 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 {
|
||||||
@@ -40,6 +42,7 @@ 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 {
|
||||||
@@ -58,6 +61,7 @@ impl RequestForwarder {
|
|||||||
current_providers,
|
current_providers,
|
||||||
failover_manager,
|
failover_manager,
|
||||||
app_handle,
|
app_handle,
|
||||||
|
current_provider_id_at_start,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +98,8 @@ impl RequestForwarder {
|
|||||||
{
|
{
|
||||||
Ok(response) => return Ok(response),
|
Ok(response) => return Ok(response),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let category = self.categorize_proxy_error(&e);
|
// 只有“同一 Provider 内可重试”的错误才继续重试
|
||||||
|
if !self.should_retry_same_provider(&e) {
|
||||||
// 只有可重试的错误才继续重试
|
|
||||||
if category == ErrorCategory::NonRetryable {
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,17 +149,16 @@ 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 会占用探测名额)
|
||||||
if !self
|
let permit = 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,
|
||||||
@@ -166,10 +167,9 @@ 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: {})",
|
||||||
@@ -202,7 +202,13 @@ impl RequestForwarder {
|
|||||||
// 成功:记录成功并更新熔断器
|
// 成功:记录成功并更新熔断器
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.router
|
.router
|
||||||
.record_result(&provider.id, app_type_str, true, None)
|
.record_result(
|
||||||
|
&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}");
|
||||||
@@ -222,16 +228,18 @@ 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;
|
||||||
if failover_happened {
|
let should_switch =
|
||||||
|
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 和托盘菜单
|
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
|
||||||
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();
|
||||||
@@ -268,7 +276,13 @@ impl RequestForwarder {
|
|||||||
// 失败:记录失败并更新熔断器
|
// 失败:记录失败并更新熔断器
|
||||||
if let Err(record_err) = self
|
if let Err(record_err) = self
|
||||||
.router
|
.router
|
||||||
.record_result(&provider.id, app_type_str, false, Some(e.to_string()))
|
.record_result(
|
||||||
|
&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}");
|
||||||
@@ -489,6 +503,19 @@ 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 {
|
||||||
// 网络和上游错误:都应该尝试下一个供应商
|
// 网络和上游错误:都应该尝试下一个供应商
|
||||||
@@ -499,9 +526,15 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ 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")
|
||||||
@@ -58,6 +63,8 @@ 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
|
||||||
@@ -92,6 +99,7 @@ impl RequestContext {
|
|||||||
config,
|
config,
|
||||||
provider,
|
provider,
|
||||||
providers,
|
providers,
|
||||||
|
current_provider_id,
|
||||||
request_model,
|
request_model,
|
||||||
tag,
|
tag,
|
||||||
app_type_str,
|
app_type_str,
|
||||||
@@ -133,6 +141,7 @@ 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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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::{CircuitBreaker, CircuitBreakerConfig};
|
use crate::proxy::circuit_breaker::{AllowResult, 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) -> bool {
|
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> AllowResult {
|
||||||
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,6 +134,7 @@ 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> {
|
||||||
@@ -146,10 +147,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().await;
|
breaker.record_success(used_half_open_permit).await;
|
||||||
log::debug!("Provider {provider_id} request succeeded");
|
log::debug!("Provider {provider_id} request succeeded");
|
||||||
} else {
|
} else {
|
||||||
breaker.record_failure().await;
|
breaker.record_failure(used_half_open_permit).await;
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Provider {} request failed: {}",
|
"Provider {} request failed: {}",
|
||||||
provider_id,
|
provider_id,
|
||||||
@@ -265,7 +266,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);
|
assert!(breaker.allow_request().await.allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -296,7 +297,7 @@ mod tests {
|
|||||||
|
|
||||||
// 让 B 进入 Open 状态(failure_threshold=1)
|
// 让 B 进入 Open 状态(failure_threshold=1)
|
||||||
router
|
router
|
||||||
.record_result("b", "claude", false, Some("fail".to_string()))
|
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -305,6 +306,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);
|
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,16 +191,23 @@ 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))
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ 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)]
|
||||||
|
|||||||
@@ -17,8 +17,27 @@ 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)?;
|
||||||
|
|
||||||
@@ -190,10 +209,22 @@ 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() {
|
||||||
state.db.save_mcp_server(server)?;
|
// 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
// 同步到 Claude live 配置
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
Self::sync_server_to_apps(state, server)?;
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.claude = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,10 +243,22 @@ 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() {
|
||||||
state.db.save_mcp_server(server)?;
|
// 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
// 同步到 Codex live 配置
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
Self::sync_server_to_apps(state, server)?;
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.codex = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,10 +277,22 @@ 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() {
|
||||||
state.db.save_mcp_server(server)?;
|
// 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
// 同步到 Gemini live 配置
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
Self::sync_server_to_apps(state, server)?;
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.gemini = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,10 +144,30 @@ 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 {
|
||||||
|
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
||||||
|
// - 不写 Live 配置(否则会破坏接管)
|
||||||
|
// - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置)
|
||||||
|
let is_app_taken_over =
|
||||||
|
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
|
||||||
|
let should_skip_live_write = is_app_taken_over && is_proxy_running;
|
||||||
|
|
||||||
|
if should_skip_live_write {
|
||||||
|
futures::executor::block_on(
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.update_live_backup_from_provider(app_type.as_str(), &provider),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
|
} else {
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
// Sync MCP
|
// Sync MCP
|
||||||
McpService::sync_all_enabled(state)?;
|
McpService::sync_all_enabled(state)?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -191,12 +211,15 @@ 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_takeover_flag =
|
let is_app_taken_over =
|
||||||
futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false);
|
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 is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
|
||||||
|
|
||||||
// Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running
|
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
|
||||||
let should_hot_switch = is_takeover_flag && is_proxy_running;
|
let should_hot_switch = is_app_taken_over && 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
|
||||||
@@ -231,13 +254,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+802
-88
File diff suppressed because it is too large
Load Diff
@@ -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.8.2",
|
"version": "3.9.0-1",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ 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(),
|
||||||
@@ -170,7 +176,6 @@ 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!(
|
||||||
@@ -594,6 +599,11 @@ 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(
|
||||||
|
|||||||
@@ -246,3 +246,311 @@ 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:取消勾选 Claude(apps.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:只包含 url(Gemini 不使用 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+10
-5
@@ -491,22 +491,26 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{currentView === "providers" && (
|
{currentView === "providers" && (
|
||||||
<>
|
<>
|
||||||
<ProxyToggle />
|
<ProxyToggle activeApp={activeApp} />
|
||||||
|
|
||||||
<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">
|
||||||
{hasSkillsSupport && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("skills")}
|
onClick={() => setCurrentView("skills")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className={cn(
|
||||||
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
|
hasSkillsSupport
|
||||||
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
|
)}
|
||||||
title={t("skills.manage")}
|
title={t("skills.manage")}
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4" />
|
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||||
{/* {isClaudeApp && (
|
{/* {isClaudeApp && (
|
||||||
<Button
|
<Button
|
||||||
@@ -578,6 +582,7 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleEditProvider}
|
onSubmit={handleEditProvider}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
|
isProxyTakeover={isProxyRunning && isTakeoverActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{usageProvider && (
|
{usageProvider && (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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({
|
||||||
@@ -24,6 +25,7 @@ export function EditProviderDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
appId,
|
appId,
|
||||||
|
isProxyTakeover = false,
|
||||||
}: EditProviderDialogProps) {
|
}: EditProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -50,6 +52,16 @@ 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) {
|
||||||
@@ -82,7 +94,7 @@ export function EditProviderDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
|
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 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 { useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
import {
|
import {
|
||||||
getApiKeyFromConfig,
|
getApiKeyFromConfig,
|
||||||
@@ -32,6 +32,28 @@ 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,40 +9,49 @@ 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 }: ProxyToggleProps) {
|
export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||||
const {
|
const { t } = useTranslation();
|
||||||
isRunning,
|
const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =
|
||||||
isTakeoverActive,
|
useProxyStatus();
|
||||||
startWithTakeover,
|
|
||||||
stopWithRestore,
|
|
||||||
isPending,
|
|
||||||
status,
|
|
||||||
} = useProxyStatus();
|
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
const handleToggle = async (checked: boolean) => {
|
||||||
if (checked) {
|
await setTakeoverForApp({ appType: activeApp, enabled: checked });
|
||||||
await startWithTakeover();
|
|
||||||
} else {
|
|
||||||
await stopWithRestore();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = isRunning && isTakeoverActive;
|
const takeoverEnabled = takeoverStatus?.[activeApp] || false;
|
||||||
|
|
||||||
const tooltipText = isActive
|
const appLabel =
|
||||||
? `代理模式运行中 - ${status?.address}:${status?.port}\n切换供应商为热切换`
|
activeApp === "claude"
|
||||||
: "开启代理模式\n启用后自动接管 Live 配置";
|
? "Claude"
|
||||||
|
: activeApp === "codex"
|
||||||
|
? "Codex"
|
||||||
|
: "Gemini";
|
||||||
|
|
||||||
|
const tooltipText = takeoverEnabled
|
||||||
|
? isRunning
|
||||||
|
? t("proxy.takeover.tooltip.active", {
|
||||||
|
defaultValue: `${appLabel} 已接管 - ${status?.address}:${status?.port}\n切换该应用供应商为热切换`,
|
||||||
|
})
|
||||||
|
: t("proxy.takeover.tooltip.broken", {
|
||||||
|
defaultValue: `${appLabel} 已接管,但代理服务未运行`,
|
||||||
|
})
|
||||||
|
: t("proxy.takeover.tooltip.inactive", {
|
||||||
|
defaultValue: `接管 ${appLabel} 的 Live 配置,让该应用请求走本地代理`,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
|
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
|
||||||
isActive
|
takeoverEnabled
|
||||||
? "bg-emerald-500/10 border border-emerald-500/30"
|
? "bg-emerald-500/10 border border-emerald-500/30"
|
||||||
: "bg-muted/50 hover:bg-muted",
|
: "bg-muted/50 hover:bg-muted",
|
||||||
className,
|
className,
|
||||||
@@ -55,7 +64,7 @@ export function ProxyToggle({ className }: ProxyToggleProps) {
|
|||||||
<Radio
|
<Radio
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-colors",
|
"h-4 w-4 transition-colors",
|
||||||
isActive
|
takeoverEnabled
|
||||||
? "text-emerald-500 animate-pulse"
|
? "text-emerald-500 animate-pulse"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
@@ -64,7 +73,7 @@ export function ProxyToggle({ className }: ProxyToggleProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium transition-colors select-none",
|
"text-sm font-medium transition-colors select-none",
|
||||||
isActive
|
takeoverEnabled
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
@@ -72,7 +81,7 @@ export function ProxyToggle({ className }: ProxyToggleProps) {
|
|||||||
Proxy
|
Proxy
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isActive}
|
checked={takeoverEnabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="ml-1"
|
className="ml-1"
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ export function SettingsPage({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isRunning,
|
isRunning,
|
||||||
startWithTakeover: startProxy,
|
startProxyServer,
|
||||||
stopWithRestore: stopProxy,
|
stopWithRestore,
|
||||||
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 stopProxy();
|
await stopWithRestore();
|
||||||
} else {
|
} else {
|
||||||
await startProxy();
|
await startProxyServer();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Toggle proxy failed:", error);
|
console.error("Toggle proxy failed:", error);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
name: "Azure OpenAI",
|
name: "Azure OpenAI",
|
||||||
websiteUrl:
|
websiteUrl:
|
||||||
"https://learn.microsoft.com/azure/ai-services/openai/how-to/overview",
|
"https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/codex",
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
isOfficial: true,
|
isOfficial: true,
|
||||||
auth: generateThirdPartyAuth(""),
|
auth: generateThirdPartyAuth(""),
|
||||||
|
|||||||
+71
-21
@@ -6,7 +6,11 @@ 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 { ProxyStatus, ProxyServerInfo } from "@/types/proxy";
|
import type {
|
||||||
|
ProxyStatus,
|
||||||
|
ProxyServerInfo,
|
||||||
|
ProxyTakeoverStatus,
|
||||||
|
} from "@/types/proxy";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,47 +30,47 @@ export function useProxyStatus() {
|
|||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 查询接管状态
|
// 查询各应用接管状态
|
||||||
const { data: isTakeoverActive } = useQuery({
|
const { data: takeoverStatus } = useQuery({
|
||||||
queryKey: ["proxyTakeoverActive"],
|
queryKey: ["proxyTakeoverStatus"],
|
||||||
queryFn: () => invoke<boolean>("is_live_takeover_active"),
|
queryFn: () => invoke<ProxyTakeoverStatus>("get_proxy_takeover_status"),
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动服务器(带 Live 配置接管)
|
// 启动服务器(总开关:仅启动服务,不接管)
|
||||||
const startWithTakeoverMutation = useMutation({
|
const startProxyServerMutation = useMutation({
|
||||||
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_with_takeover"),
|
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"),
|
||||||
onSuccess: (info) => {
|
onSuccess: (info) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("proxy.startedWithTakeover", {
|
t("proxy.server.started", {
|
||||||
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.startWithTakeoverFailed", {
|
t("proxy.server.startFailed", {
|
||||||
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: ["proxyTakeoverActive"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||||
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
|
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||||
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
|
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
|
||||||
},
|
},
|
||||||
@@ -80,6 +84,42 @@ 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: ({
|
||||||
@@ -120,12 +160,20 @@ export function useProxyStatus() {
|
|||||||
status,
|
status,
|
||||||
isLoading,
|
isLoading,
|
||||||
isRunning: status?.running || false,
|
isRunning: status?.running || false,
|
||||||
isTakeoverActive: isTakeoverActive || false,
|
takeoverStatus,
|
||||||
|
isTakeoverActive:
|
||||||
|
takeoverStatus?.claude ||
|
||||||
|
takeoverStatus?.codex ||
|
||||||
|
takeoverStatus?.gemini ||
|
||||||
|
false,
|
||||||
|
|
||||||
// 启动/停止(接管模式)
|
// 启动/停止(总开关)
|
||||||
startWithTakeover: startWithTakeoverMutation.mutateAsync,
|
startProxyServer: startProxyServerMutation.mutateAsync,
|
||||||
stopWithRestore: stopWithRestoreMutation.mutateAsync,
|
stopWithRestore: stopWithRestoreMutation.mutateAsync,
|
||||||
|
|
||||||
|
// 按应用接管开关
|
||||||
|
setTakeoverForApp: setTakeoverForAppMutation.mutateAsync,
|
||||||
|
|
||||||
// 代理模式下切换供应商
|
// 代理模式下切换供应商
|
||||||
switchProxyProvider: switchProxyProviderMutation.mutateAsync,
|
switchProxyProvider: switchProxyProviderMutation.mutateAsync,
|
||||||
|
|
||||||
@@ -134,9 +182,11 @@ export function useProxyStatus() {
|
|||||||
checkTakeoverActive,
|
checkTakeoverActive,
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
isStarting: startWithTakeoverMutation.isPending,
|
isStarting: startProxyServerMutation.isPending,
|
||||||
isStopping: stopWithRestoreMutation.isPending,
|
isStopping: stopWithRestoreMutation.isPending,
|
||||||
isPending:
|
isPending:
|
||||||
startWithTakeoverMutation.isPending || stopWithRestoreMutation.isPending,
|
startProxyServerMutation.isPending ||
|
||||||
|
stopWithRestoreMutation.isPending ||
|
||||||
|
setTakeoverForAppMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ 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;
|
||||||
|
|||||||
@@ -258,6 +258,14 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user