diff --git a/README.md b/README.md
index c9b6fd93f..6bc7a1b08 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[](https://github.com/farion1231/cc-switch/releases)
-[](https://github.com/trending/typescript)
[](https://github.com/farion1231/cc-switch/releases)
[](https://tauri.app/)
[](https://github.com/farion1231/cc-switch/releases/latest)
@@ -12,21 +11,13 @@
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
-**From Provider Switcher to All-in-One AI CLI Management Platform**
-
-Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
-
## ❤️Sponsor

-This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
-
-GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
-
-Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
+This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
---
@@ -41,6 +32,12 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! Free download for Mac/Win
+
+
+Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via this link , you’ll receive an extra 10% bonus credit on your first top-up!
+
+
+
## Screenshots
diff --git a/README_JA.md b/README_JA.md
index 0798c086c..12853b309 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -3,7 +3,6 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[](https://github.com/farion1231/cc-switch/releases)
-[](https://github.com/trending/typescript)
[](https://github.com/farion1231/cc-switch/releases)
[](https://tauri.app/)
[](https://github.com/farion1231/cc-switch/releases/latest)
@@ -12,21 +11,13 @@
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
-**プロバイダスイッチャーから AI CLI 一体型管理プラットフォームへ**
-
-Claude Code・Codex・Gemini CLI のプロバイダ設定、MCP サーバー、Skills 拡張、システムプロンプトを統合管理。
-
## ❤️スポンサー

-本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。
-
-GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。
-
-[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
+本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
---
@@ -41,6 +32,13 @@ GLM CODING PLAN は AI コーディング向けのサブスクリプションで
ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。Mac/Win 版を無料ダウンロード
+
+
+本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンク から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
+
+
+
+
## スクリーンショット
diff --git a/README_ZH.md b/README_ZH.md
index 9dd909acf..167257c7d 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -3,7 +3,6 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[](https://github.com/farion1231/cc-switch/releases)
-[](https://github.com/trending/typescript)
[](https://github.com/farion1231/cc-switch/releases)
[](https://tauri.app/)
[](https://github.com/farion1231/cc-switch/releases/latest)
@@ -12,21 +11,13 @@
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
-**从供应商切换器到 AI CLI 一体化管理平台**
-
-统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
-
## ❤️赞助商

-感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
-
-GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
-
-CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
+感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
---
@@ -41,6 +32,11 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,免费下载
+
+
+感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接 注册的用户首次充值可以获得额外10%奖励额度!
+
+
## 界面预览
diff --git a/assets/partners/logos/aigocode.png b/assets/partners/logos/aigocode.png
new file mode 100644
index 000000000..c9a30c7d4
Binary files /dev/null and b/assets/partners/logos/aigocode.png differ
diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs
index d0664c2f3..f5a1b6bcc 100644
--- a/src-tauri/src/commands/misc.rs
+++ b/src-tauri/src/commands/misc.rs
@@ -69,8 +69,6 @@ pub struct ToolVersion {
#[tauri::command]
pub async fn get_tool_versions() -> Result, String> {
- use std::process::Command;
-
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
@@ -81,39 +79,16 @@ pub async fn get_tool_versions() -> Result, String> {
.map_err(|e| e.to_string())?;
for tool in tools {
- // 1. 获取本地版本 (保持不变)
+ // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = {
- let output = if cfg!(target_os = "windows") {
- Command::new("cmd")
- .args(["/C", &format!("{tool} --version")])
- .output()
- } else {
- Command::new("sh")
- .arg("-c")
- .arg(format!("{tool} --version"))
- .output()
- };
+ // 先尝试直接执行
+ let direct_result = try_get_version(tool);
- match output {
- Ok(out) => {
- if out.status.success() {
- (
- Some(String::from_utf8_lossy(&out.stdout).trim().to_string()),
- None,
- )
- } else {
- let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
- (
- None,
- Some(if err.is_empty() {
- "未安装或无法执行".to_string()
- } else {
- err
- }),
- )
- }
- }
- Err(e) => (None, Some(e.to_string())),
+ if direct_result.0.is_some() {
+ direct_result
+ } else {
+ // 扫描常见的 npm 全局安装路径
+ scan_cli_version(tool)
}
};
@@ -153,3 +128,124 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
Err(_) => None,
}
}
+
+/// 从版本输出中提取纯版本号
+fn extract_version(raw: &str) -> String {
+ // 匹配 semver 格式: x.y.z 或 x.y.z-xxx
+ let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
+ re.find(raw)
+ .map(|m| m.as_str().to_string())
+ .unwrap_or_else(|| raw.to_string())
+}
+
+/// 尝试直接执行命令获取版本
+fn try_get_version(tool: &str) -> (Option, Option) {
+ use std::process::Command;
+
+ let output = if cfg!(target_os = "windows") {
+ Command::new("cmd")
+ .args(["/C", &format!("{tool} --version")])
+ .output()
+ } else {
+ Command::new("sh")
+ .arg("-c")
+ .arg(format!("{tool} --version"))
+ .output()
+ };
+
+ match output {
+ Ok(out) => {
+ if out.status.success() {
+ let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ (Some(extract_version(&raw)), None)
+ } else {
+ let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
+ (
+ None,
+ Some(if err.is_empty() {
+ "未安装或无法执行".to_string()
+ } else {
+ err
+ }),
+ )
+ }
+ }
+ Err(e) => (None, Some(e.to_string())),
+ }
+}
+
+/// 扫描常见路径查找 CLI
+fn scan_cli_version(tool: &str) -> (Option, Option) {
+ use std::process::Command;
+
+ let home = dirs::home_dir().unwrap_or_default();
+
+ // 常见的 npm 全局安装路径
+ let mut search_paths: Vec = vec![
+ home.join(".npm-global/bin"),
+ home.join(".local/bin"),
+ home.join("n/bin"), // n version manager
+ ];
+
+ #[cfg(target_os = "macos")]
+ {
+ search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
+ search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
+ }
+
+ #[cfg(target_os = "linux")]
+ {
+ search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
+ search_paths.push(std::path::PathBuf::from("/usr/bin"));
+ }
+
+ #[cfg(target_os = "windows")]
+ {
+ if let Some(appdata) = dirs::data_dir() {
+ search_paths.push(appdata.join("npm"));
+ }
+ search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
+ }
+
+ // 扫描 nvm 目录下的所有 node 版本
+ let nvm_base = home.join(".nvm/versions/node");
+ if nvm_base.exists() {
+ if let Ok(entries) = std::fs::read_dir(&nvm_base) {
+ for entry in entries.flatten() {
+ let bin_path = entry.path().join("bin");
+ if bin_path.exists() {
+ search_paths.push(bin_path);
+ }
+ }
+ }
+ }
+
+ // 在每个路径中查找工具
+ for path in &search_paths {
+ let tool_path = if cfg!(target_os = "windows") {
+ path.join(format!("{tool}.cmd"))
+ } else {
+ path.join(tool)
+ };
+
+ if tool_path.exists() {
+ // 构建 PATH 环境变量,确保 node 可被找到
+ let current_path = std::env::var("PATH").unwrap_or_default();
+ let new_path = format!("{}:{}", path.display(), current_path);
+
+ let output = Command::new(&tool_path)
+ .arg("--version")
+ .env("PATH", &new_path)
+ .output();
+
+ if let Ok(out) = output {
+ if out.status.success() {
+ let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ return (Some(extract_version(&raw)), None);
+ }
+ }
+ }
+ }
+
+ (None, Some("未安装或无法执行".to_string()))
+}
diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs
index 79a8e49db..9c9a34212 100644
--- a/src-tauri/src/commands/proxy.rs
+++ b/src-tauri/src/commands/proxy.rs
@@ -7,18 +7,18 @@ use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
-/// 启动代理服务器
+/// 启动代理服务器(带 Live 配置接管)
#[tauri::command]
-pub async fn start_proxy_server(
+pub async fn start_proxy_with_takeover(
state: tauri::State<'_, AppState>,
) -> Result {
- state.proxy_service.start().await
+ state.proxy_service.start_with_takeover().await
}
-/// 停止代理服务器
+/// 停止代理服务器(恢复 Live 配置)
#[tauri::command]
-pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
- state.proxy_service.stop().await
+pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
+ state.proxy_service.stop_with_restore().await
}
/// 获取代理服务器状态
@@ -48,6 +48,25 @@ pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result) -> Result {
+ state.proxy_service.is_takeover_active().await
+}
+
+/// 代理模式下切换供应商(热切换)
+#[tauri::command]
+pub async fn switch_proxy_provider(
+ state: tauri::State<'_, AppState>,
+ app_type: String,
+ provider_id: String,
+) -> Result<(), String> {
+ state
+ .proxy_service
+ .switch_proxy_target(&app_type, &provider_id)
+ .await
+}
+
// ==================== 故障转移相关命令 ====================
/// 获取代理目标列表
diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs
index 63ccb70dc..b9cf8de8a 100644
--- a/src-tauri/src/database/dao/providers.rs
+++ b/src-tauri/src/database/dao/providers.rs
@@ -483,6 +483,26 @@ impl Database {
Ok(targets)
}
+ /// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
+ pub fn update_provider_settings_config(
+ &self,
+ app_type: &str,
+ provider_id: &str,
+ settings_config: &serde_json::Value,
+ ) -> Result<(), AppError> {
+ let conn = lock_conn!(self.conn);
+ conn.execute(
+ "UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
+ params![
+ serde_json::to_string(settings_config).unwrap(),
+ provider_id,
+ app_type
+ ],
+ )
+ .map_err(|e| AppError::Database(e.to_string()))?;
+ Ok(())
+ }
+
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs
index b87d07d02..961760c42 100644
--- a/src-tauri/src/database/dao/proxy.rs
+++ b/src-tauri/src/database/dao/proxy.rs
@@ -17,7 +17,7 @@ impl Database {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT enabled, listen_address, listen_port, max_retries,
- request_timeout, enable_logging
+ request_timeout, enable_logging, live_takeover_active
FROM proxy_config WHERE id = 1",
[],
|row| {
@@ -28,6 +28,7 @@ impl Database {
max_retries: row.get::<_, i32>(3)? as u8,
request_timeout: row.get::<_, i32>(4)? as u64,
enable_logging: row.get::<_, i32>(5)? != 0,
+ live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
})
},
)
@@ -51,8 +52,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO proxy_config
- (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at)
- VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7,
+ (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
+ VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))",
rusqlite::params![
@@ -62,6 +63,7 @@ impl Database {
config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 },
+ if config.live_takeover_active { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值
],
)
@@ -70,6 +72,30 @@ impl Database {
Ok(())
}
+ /// 设置 Live 接管状态
+ pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
+ let conn = lock_conn!(self.conn);
+ conn.execute(
+ "UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
+ rusqlite::params![if active { 1 } else { 0 }],
+ )
+ .map_err(|e| AppError::Database(e.to_string()))?;
+ Ok(())
+ }
+
+ /// 检查是否处于 Live 接管模式
+ pub async fn is_live_takeover_active(&self) -> Result {
+ let conn = lock_conn!(self.conn);
+ let active: i32 = conn
+ .query_row(
+ "SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1",
+ [],
+ |row| row.get(0),
+ )
+ .unwrap_or(0);
+ Ok(active != 0)
+ }
+
// ==================== Provider Health ====================
/// 获取Provider健康状态
@@ -241,4 +267,74 @@ impl Database {
Ok(())
}
+
+ // ==================== Live Backup ====================
+
+ /// 保存 Live 配置备份
+ pub async fn save_live_backup(
+ &self,
+ app_type: &str,
+ config_json: &str,
+ ) -> Result<(), AppError> {
+ let conn = lock_conn!(self.conn);
+ let now = chrono::Utc::now().to_rfc3339();
+
+ conn.execute(
+ "INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)
+ VALUES (?1, ?2, ?3)",
+ rusqlite::params![app_type, config_json, now],
+ )
+ .map_err(|e| AppError::Database(e.to_string()))?;
+
+ log::info!("已备份 {app_type} Live 配置");
+ Ok(())
+ }
+
+ /// 获取 Live 配置备份
+ pub async fn get_live_backup(&self, app_type: &str) -> Result, AppError> {
+ let conn = lock_conn!(self.conn);
+
+ let result = conn.query_row(
+ "SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1",
+ rusqlite::params![app_type],
+ |row| {
+ Ok(LiveBackup {
+ app_type: row.get(0)?,
+ original_config: row.get(1)?,
+ backed_up_at: row.get(2)?,
+ })
+ },
+ );
+
+ match result {
+ Ok(backup) => Ok(Some(backup)),
+ Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
+ Err(e) => Err(AppError::Database(e.to_string())),
+ }
+ }
+
+ /// 删除 Live 配置备份
+ pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {
+ let conn = lock_conn!(self.conn);
+
+ conn.execute(
+ "DELETE FROM proxy_live_backup WHERE app_type = ?1",
+ rusqlite::params![app_type],
+ )
+ .map_err(|e| AppError::Database(e.to_string()))?;
+
+ log::info!("已删除 {app_type} Live 配置备份");
+ Ok(())
+ }
+
+ /// 删除所有 Live 配置备份
+ pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {
+ let conn = lock_conn!(self.conn);
+
+ conn.execute("DELETE FROM proxy_live_backup", [])
+ .map_err(|e| AppError::Database(e.to_string()))?;
+
+ log::info!("已删除所有 Live 配置备份");
+ Ok(())
+ }
}
diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs
index a8781054e..3dbdca557 100644
--- a/src-tauri/src/database/schema.rs
+++ b/src-tauri/src/database/schema.rs
@@ -302,6 +302,23 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
+ // 16. Proxy Live Backup 表 (Live 配置备份)
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS proxy_live_backup (
+ app_type TEXT PRIMARY KEY,
+ original_config TEXT NOT NULL,
+ backed_up_at TEXT NOT NULL
+ )",
+ [],
+ )
+ .map_err(|e| AppError::Database(e.to_string()))?;
+
+ // 尝试添加 live_takeover_active 列到 proxy_config 表
+ let _ = conn.execute(
+ "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
+ [],
+ );
+
Ok(())
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 26db9bae5..c791a0a28 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -530,7 +530,7 @@ pub fn run() {
Ok(config) => {
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
- match state.proxy_service.start().await {
+ match state.proxy_service.start_with_takeover().await {
Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}",
info.address,
@@ -647,12 +647,14 @@ pub fn run() {
commands::set_auto_launch,
commands::get_auto_launch_status,
// Proxy server management
- commands::start_proxy_server,
- commands::stop_proxy_server,
+ commands::start_proxy_with_takeover,
+ commands::stop_proxy_with_restore,
commands::get_proxy_status,
commands::get_proxy_config,
commands::update_proxy_config,
commands::is_proxy_running,
+ commands::is_live_takeover_active,
+ commands::switch_proxy_provider,
// Proxy failover commands
commands::get_proxy_targets,
commands::set_proxy_target,
@@ -685,6 +687,22 @@ pub fn run() {
.expect("error while running tauri application");
app.run(|app_handle, event| {
+ // 处理退出请求(所有平台)
+ if let RunEvent::ExitRequested { api, .. } = &event {
+ log::info!("收到退出请求,开始清理...");
+ // 阻止立即退出,执行清理
+ api.prevent_exit();
+
+ let app_handle = app_handle.clone();
+ tauri::async_runtime::spawn(async move {
+ cleanup_before_exit(&app_handle).await;
+ log::info!("清理完成,退出应用");
+ // 使用 std::process::exit 避免再次触发 ExitRequested
+ std::process::exit(0);
+ });
+ return;
+ }
+
#[cfg(target_os = "macos")]
{
match event {
@@ -764,6 +782,44 @@ pub fn run() {
});
}
+// ============================================================
+// 应用退出清理
+// ============================================================
+
+/// 应用退出前的清理工作
+///
+/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
+/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
+pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
+ if let Some(state) = app_handle.try_state::() {
+ let proxy_service = &state.proxy_service;
+
+ // 检查代理是否在运行
+ if proxy_service.is_running().await {
+ log::info!("检测到代理服务器正在运行,开始清理...");
+
+ // 检查是否处于 Live 接管模式
+ if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
+ if is_takeover {
+ // 接管模式:停止并恢复配置
+ if let Err(e) = proxy_service.stop_with_restore().await {
+ log::error!("退出时恢复 Live 配置失败: {e}");
+ } else {
+ log::info!("已恢复 Live 配置");
+ }
+ } else {
+ // 非接管模式:仅停止代理
+ if let Err(e) = proxy_service.stop().await {
+ log::error!("退出时停止代理失败: {e}");
+ }
+ }
+ }
+
+ log::info!("代理服务器清理完成");
+ }
+ }
+}
+
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs
index 6f4705983..8e4483414 100644
--- a/src-tauri/src/proxy/provider_router.rs
+++ b/src-tauri/src/proxy/provider_router.rs
@@ -30,58 +30,42 @@ impl ProviderRouter {
/// 选择可用的供应商(支持故障转移)
/// 返回按优先级排序的可用供应商列表
pub async fn select_providers(&self, app_type: &str) -> Result, AppError> {
- // 1. 获取所有启用代理的供应商
- let providers = self.db.get_proxy_targets(app_type).await?;
+ // 直接获取当前选中的供应商(基于 is_current 字段)
+ let current_id = self
+ .db
+ .get_current_provider(app_type)?
+ .ok_or_else(|| AppError::Config(format!("No current provider for {}", app_type)))?;
- if providers.is_empty() {
- return Err(AppError::Config(
- "No proxy target providers configured".to_string(),
- ));
- }
-
- log::debug!(
- "Found {} proxy target providers for app_type: {}",
- providers.len(),
- app_type
- );
-
- // 2. 按 sort_index 排序(已经在数据库查询中排序了)
- let sorted_providers: Vec<_> = providers.into_values().collect();
-
- // 3. 过滤可用的供应商(检查熔断器状态)
- let mut available_providers = Vec::new();
-
- for provider in sorted_providers {
- let circuit_key = format!("{}:{}", app_type, provider.id);
- let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
-
- if breaker.allow_request().await {
- log::debug!(
- "Provider {} is available (circuit state: {:?})",
- provider.id,
- breaker.get_state().await
- );
- available_providers.push(provider);
- } else {
- log::warn!(
- "Provider {} is unavailable (circuit breaker open)",
- provider.id
- );
- }
- }
-
- if available_providers.is_empty() {
- return Err(AppError::Config(
- "All proxy target providers are unavailable (circuit breakers open)".to_string(),
- ));
- }
+ let providers = self.db.get_all_providers(app_type)?;
+ let provider = providers
+ .get(¤t_id)
+ .ok_or_else(|| AppError::Config(format!("Current provider {} not found", current_id)))?
+ .clone();
log::info!(
- "Selected {} available providers for failover chain",
- available_providers.len()
+ "[{}] Selected current provider: {} ({})",
+ app_type,
+ provider.name,
+ provider.id
);
- Ok(available_providers)
+ // 检查熔断器状态
+ let circuit_key = format!("{}:{}", app_type, provider.id);
+ let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
+
+ if !breaker.allow_request().await {
+ log::warn!(
+ "Provider {} is unavailable (circuit breaker open)",
+ provider.id
+ );
+ return Err(AppError::Config(format!(
+ "Current provider {} is unavailable (circuit breaker open)",
+ provider.name
+ )));
+ }
+
+ // 返回单个供应商(保留 Vec 接口以兼容现有代码)
+ Ok(vec![provider])
}
/// 记录供应商请求结果
diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs
index 6da189c36..b66b0026b 100644
--- a/src-tauri/src/proxy/server.rs
+++ b/src-tauri/src/proxy/server.rs
@@ -148,17 +148,24 @@ impl ProxyServer {
// 健康检查
.route("/health", get(handlers::health_check))
.route("/status", get(handlers::get_status))
- // Claude API
+ // Claude API (支持带前缀和不带前缀两种格式)
.route("/v1/messages", post(handlers::handle_messages))
- // OpenAI Chat Completions API (Codex CLI)
+ .route("/claude/v1/messages", post(handlers::handle_messages))
+ // OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route(
"/v1/chat/completions",
post(handlers::handle_chat_completions),
)
- // OpenAI Responses API (Codex CLI)
+ .route(
+ "/codex/v1/chat/completions",
+ post(handlers::handle_chat_completions),
+ )
+ // OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
.route("/v1/responses", post(handlers::handle_responses))
- // Gemini API
+ .route("/codex/v1/responses", post(handlers::handle_responses))
+ // Gemini API (支持带前缀和不带前缀)
.route("/v1beta/*path", post(handlers::handle_gemini))
+ .route("/gemini/v1beta/*path", post(handlers::handle_gemini))
.layer(cors)
.with_state(self.state.clone())
}
diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs
index e28553b72..9a5211268 100644
--- a/src-tauri/src/proxy/types.rs
+++ b/src-tauri/src/proxy/types.rs
@@ -15,6 +15,9 @@ pub struct ProxyConfig {
pub request_timeout: u64,
/// 是否启用日志
pub enable_logging: bool,
+ /// 是否正在接管 Live 配置
+ #[serde(default)]
+ pub live_takeover_active: bool,
}
impl Default for ProxyConfig {
@@ -26,6 +29,7 @@ impl Default for ProxyConfig {
max_retries: 3,
request_timeout: 300,
enable_logging: true,
+ live_takeover_active: false,
}
}
}
@@ -103,3 +107,14 @@ pub struct ProviderHealth {
pub last_error: Option,
pub updated_at: String,
}
+
+/// Live 配置备份记录
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LiveBackup {
+ /// 应用类型 (claude/codex/gemini)
+ pub app_type: String,
+ /// 原始配置 JSON
+ pub original_config: String,
+ /// 备份时间
+ pub backed_up_at: String,
+}
diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs
index 8e4c5f965..bc6be2206 100644
--- a/src-tauri/src/services/provider/mod.rs
+++ b/src-tauri/src/services/provider/mod.rs
@@ -173,14 +173,71 @@ impl ProviderService {
///
/// Switch flow:
/// 1. Validate target provider exists
- /// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
- /// 3. Update local settings current_provider_xxx (device-level)
- /// 4. Update database is_current (as default for new devices)
- /// 5. Write target provider config to live files
- /// 6. Sync MCP configuration
+ /// 2. Check if proxy takeover mode is active AND proxy server is running
+ /// 3. If takeover mode active: hot-switch proxy target only (no Live config write)
+ /// 4. If normal mode:
+ /// a. **Backfill mechanism**: Backfill current live config to current provider
+ /// b. Update local settings current_provider_xxx (device-level)
+ /// c. Update database is_current (as default for new devices)
+ /// d. Write target provider config to live files
+ /// e. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
+ let _provider = providers
+ .get(id)
+ .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
+
+ // Check if proxy takeover mode is active AND proxy server is actually running
+ // Both conditions must be true to use hot-switch mode
+ // Use blocking wait since this is a sync function
+ let is_takeover_flag =
+ futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false);
+ let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
+
+ // Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running
+ let should_hot_switch = is_takeover_flag && is_proxy_running;
+
+ if should_hot_switch {
+ // Proxy takeover mode: hot-switch only, don't write Live config
+ log::info!(
+ "代理接管模式:热切换 {} 的目标供应商为 {}",
+ app_type.as_str(),
+ id
+ );
+
+ // Update database is_current
+ state.db.set_current_provider(app_type.as_str(), id)?;
+
+ // 同时更新 is_proxy_target(代理路由器使用此字段选择供应商)
+ state.db.set_proxy_target_provider(app_type.as_str(), id)?;
+
+ // Update local settings for consistency
+ crate::settings::set_current_provider(&app_type, Some(id))?;
+
+ // Note: No Live config write, no MCP sync
+ // The proxy server will route requests to the new provider via is_proxy_target
+ return Ok(());
+ }
+
+ // 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)
+ }
+
+ /// Normal switch flow (non-proxy mode)
+ fn switch_normal(
+ state: &AppState,
+ app_type: AppType,
+ id: &str,
+ providers: &indexmap::IndexMap,
+ ) -> Result<(), AppError> {
let provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs
index 97bf9de3a..111154f72 100644
--- a/src-tauri/src/services/proxy.rs
+++ b/src-tauri/src/services/proxy.rs
@@ -2,9 +2,13 @@
//!
//! 提供代理服务器的启动、停止和配置管理
+use crate::app_config::AppType;
+use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::proxy::server::ProxyServer;
use crate::proxy::types::*;
+use serde_json::{json, Value};
+use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -59,6 +63,196 @@ impl ProxyService {
Ok(info)
}
+ /// 启动代理服务器(带 Live 配置接管)
+ pub async fn start_with_takeover(&self) -> Result {
+ // 1. 自动将各应用当前选中的供应商设置为代理目标
+ self.setup_proxy_targets().await?;
+
+ // 2. 备份各应用的 Live 配置
+ self.backup_live_configs().await?;
+
+ // 3. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)
+ self.sync_live_to_providers().await?;
+
+ // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)
+ self.takeover_live_configs().await?;
+
+ // 5. 设置接管状态
+ self.db
+ .set_live_takeover_active(true)
+ .await
+ .map_err(|e| format!("设置接管状态失败: {e}"))?;
+
+ // 6. 启动代理服务器
+ match self.start().await {
+ Ok(info) => Ok(info),
+ Err(e) => {
+ // 启动失败,恢复原始配置
+ log::error!("代理启动失败,尝试恢复原始配置: {e}");
+ let _ = self.restore_live_configs().await;
+ let _ = self.db.set_live_takeover_active(false).await;
+ Err(e)
+ }
+ }
+ }
+
+ /// 自动设置代理目标:将各应用当前选中的供应商设置为代理目标
+ async fn setup_proxy_targets(&self) -> Result<(), String> {
+ let app_types = ["claude", "codex", "gemini"];
+
+ for app_type in app_types {
+ // 获取当前选中的供应商
+ if let Ok(Some(provider_id)) = self.db.get_current_provider(app_type) {
+ // 设置为代理目标
+ if let Err(e) = self.db.set_proxy_target(&provider_id, app_type, true).await {
+ log::warn!("设置 {} 的代理目标 {} 失败: {}", app_type, provider_id, e);
+ } else {
+ log::info!(
+ "已将 {} 的当前供应商 {} 设置为代理目标",
+ app_type,
+ provider_id
+ );
+ }
+ } else {
+ log::debug!("{} 没有当前供应商,跳过代理目标设置", app_type);
+ }
+ }
+
+ Ok(())
+ }
+
+ /// 同步 Live 配置中的 Token 到数据库
+ ///
+ /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。
+ /// 这样代理才能从数据库读取到正确的认证信息。
+ async fn sync_live_to_providers(&self) -> Result<(), String> {
+ // Claude: 同步 ANTHROPIC_AUTH_TOKEN
+ if let Ok(live_config) = self.read_claude_live() {
+ if let Some(provider_id) = self.db.get_current_provider("claude").ok().flatten() {
+ if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "claude") {
+ // 从 live 配置提取 token
+ if let Some(env) = live_config.get("env") {
+ if let Some(token) =
+ env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str())
+ {
+ if !token.is_empty() {
+ // 更新 provider 的 settings_config
+ if let Some(env_obj) = provider
+ .settings_config
+ .get_mut("env")
+ .and_then(|v| v.as_object_mut())
+ {
+ env_obj
+ .insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(token));
+ } else {
+ provider.settings_config["env"] = json!({
+ "ANTHROPIC_AUTH_TOKEN": token
+ });
+ }
+ // 保存到数据库
+ if let Err(e) = self.db.update_provider_settings_config(
+ "claude",
+ &provider_id,
+ &provider.settings_config,
+ ) {
+ log::warn!("同步 Claude Token 到数据库失败: {e}");
+ } else {
+ log::info!(
+ "已同步 Claude Token 到数据库 (provider: {})",
+ provider_id
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Codex: 同步 OPENAI_API_KEY
+ if let Ok(live_config) = self.read_codex_live() {
+ if let Some(provider_id) = self.db.get_current_provider("codex").ok().flatten() {
+ if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "codex") {
+ // 从 live 配置提取 token
+ if let Some(auth) = live_config.get("auth") {
+ if let Some(token) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
+ if !token.is_empty() {
+ // 更新 provider 的 settings_config
+ if let Some(auth_obj) = provider
+ .settings_config
+ .get_mut("auth")
+ .and_then(|v| v.as_object_mut())
+ {
+ auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
+ } else {
+ provider.settings_config["auth"] = json!({
+ "OPENAI_API_KEY": token
+ });
+ }
+ // 保存到数据库
+ if let Err(e) = self.db.update_provider_settings_config(
+ "codex",
+ &provider_id,
+ &provider.settings_config,
+ ) {
+ log::warn!("同步 Codex Token 到数据库失败: {e}");
+ } else {
+ log::info!(
+ "已同步 Codex Token 到数据库 (provider: {})",
+ provider_id
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Gemini: 同步 GOOGLE_API_KEY
+ if let Ok(live_config) = self.read_gemini_live() {
+ if let Some(provider_id) = self.db.get_current_provider("gemini").ok().flatten() {
+ if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "gemini") {
+ // 从 live 配置提取 token
+ if let Some(env) = live_config.get("env") {
+ if let Some(token) = env.get("GOOGLE_API_KEY").and_then(|v| v.as_str()) {
+ if !token.is_empty() {
+ // 更新 provider 的 settings_config
+ if let Some(env_obj) = provider
+ .settings_config
+ .get_mut("env")
+ .and_then(|v| v.as_object_mut())
+ {
+ env_obj.insert("GOOGLE_API_KEY".to_string(), json!(token));
+ } else {
+ provider.settings_config["env"] = json!({
+ "GOOGLE_API_KEY": token
+ });
+ }
+ // 保存到数据库
+ if let Err(e) = self.db.update_provider_settings_config(
+ "gemini",
+ &provider_id,
+ &provider.settings_config,
+ ) {
+ log::warn!("同步 Gemini Token 到数据库失败: {e}");
+ } else {
+ log::info!(
+ "已同步 Gemini Token 到数据库 (provider: {})",
+ provider_id
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ log::info!("Live 配置 Token 同步完成");
+ Ok(())
+ }
+
/// 停止代理服务器
pub async fn stop(&self) -> Result<(), String> {
if let Some(server) = self.server.write().await.take() {
@@ -66,6 +260,13 @@ impl ProxyService {
.stop()
.await
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
+
+ // 将 enabled 设为 false,避免下次启动时自动开启
+ if let Ok(mut config) = self.db.get_proxy_config().await {
+ config.enabled = false;
+ let _ = self.db.update_proxy_config(config).await;
+ }
+
log::info!("代理服务器已停止");
Ok(())
} else {
@@ -73,6 +274,267 @@ impl ProxyService {
}
}
+ /// 停止代理服务器(恢复 Live 配置)
+ pub async fn stop_with_restore(&self) -> Result<(), String> {
+ // 1. 停止代理服务器
+ self.stop().await?;
+
+ // 2. 恢复原始 Live 配置
+ self.restore_live_configs().await?;
+
+ // 3. 清除接管状态
+ self.db
+ .set_live_takeover_active(false)
+ .await
+ .map_err(|e| format!("清除接管状态失败: {e}"))?;
+
+ // 4. 删除备份
+ self.db
+ .delete_all_live_backups()
+ .await
+ .map_err(|e| format!("删除备份失败: {e}"))?;
+
+ log::info!("代理已停止,Live 配置已恢复");
+ Ok(())
+ }
+
+ /// 备份各应用的 Live 配置
+ async fn backup_live_configs(&self) -> Result<(), String> {
+ // Claude
+ if let Ok(config) = self.read_claude_live() {
+ let json_str = serde_json::to_string(&config)
+ .map_err(|e| format!("序列化 Claude 配置失败: {e}"))?;
+ self.db
+ .save_live_backup("claude", &json_str)
+ .await
+ .map_err(|e| format!("备份 Claude 配置失败: {e}"))?;
+ }
+
+ // Codex
+ if let Ok(config) = self.read_codex_live() {
+ let json_str = serde_json::to_string(&config)
+ .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
+ self.db
+ .save_live_backup("codex", &json_str)
+ .await
+ .map_err(|e| format!("备份 Codex 配置失败: {e}"))?;
+ }
+
+ // Gemini
+ if let Ok(config) = self.read_gemini_live() {
+ let json_str = serde_json::to_string(&config)
+ .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?;
+ self.db
+ .save_live_backup("gemini", &json_str)
+ .await
+ .map_err(|e| format!("备份 Gemini 配置失败: {e}"))?;
+ }
+
+ log::info!("已备份所有应用的 Live 配置");
+ Ok(())
+ }
+
+ /// 接管各应用的 Live 配置(写入代理地址)
+ ///
+ /// 代理服务器的路由已经根据 API 端点自动区分应用类型:
+ /// - `/v1/messages` → Claude
+ /// - `/v1/chat/completions`, `/v1/responses` → Codex
+ /// - `/v1beta/*` → Gemini
+ ///
+ /// 因此不需要在 URL 中添加应用前缀。
+ async fn takeover_live_configs(&self) -> Result<(), String> {
+ let config = self
+ .db
+ .get_proxy_config()
+ .await
+ .map_err(|e| format!("获取代理配置失败: {e}"))?;
+
+ let proxy_url = format!("http://{}:{}", config.listen_address, config.listen_port);
+
+ // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)
+ if let Ok(mut live_config) = self.read_claude_live() {
+ if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
+ env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
+ // 使用占位符,避免 Claude Code 显示缺少 key 的警告
+ env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!("PROXY_MANAGED"));
+ } else {
+ live_config["env"] = json!({
+ "ANTHROPIC_BASE_URL": &proxy_url,
+ "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
+ });
+ }
+ self.write_claude_live(&live_config)?;
+ log::info!("Claude Live 配置已接管,代理地址: {}", proxy_url);
+ }
+
+ // Codex: 修改 OPENAI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)
+ if let Ok(mut live_config) = self.read_codex_live() {
+ if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) {
+ auth.insert("OPENAI_BASE_URL".to_string(), json!(&proxy_url));
+ // 使用占位符,避免显示缺少 key 的警告
+ auth.insert("OPENAI_API_KEY".to_string(), json!("PROXY_MANAGED"));
+ }
+ self.write_codex_live(&live_config)?;
+ log::info!("Codex Live 配置已接管,代理地址: {}", proxy_url);
+ }
+
+ // Gemini: 修改 GEMINI_API_BASE,使用占位符替代真实 Token(代理会注入真实 Token)
+ if let Ok(mut live_config) = self.read_gemini_live() {
+ if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
+ env.insert("GEMINI_API_BASE".to_string(), json!(&proxy_url));
+ // 使用占位符,避免显示缺少 key 的警告
+ env.insert("GOOGLE_API_KEY".to_string(), json!("PROXY_MANAGED"));
+ } else {
+ live_config["env"] = json!({
+ "GEMINI_API_BASE": &proxy_url,
+ "GOOGLE_API_KEY": "PROXY_MANAGED"
+ });
+ }
+ self.write_gemini_live(&live_config)?;
+ log::info!("Gemini Live 配置已接管,代理地址: {}", proxy_url);
+ }
+
+ Ok(())
+ }
+
+ /// 恢复原始 Live 配置
+ async fn restore_live_configs(&self) -> Result<(), String> {
+ // Claude
+ if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
+ let config: Value = serde_json::from_str(&backup.original_config)
+ .map_err(|e| format!("解析 Claude 备份失败: {e}"))?;
+ self.write_claude_live(&config)?;
+ log::info!("Claude Live 配置已恢复");
+ }
+
+ // Codex
+ if let Ok(Some(backup)) = self.db.get_live_backup("codex").await {
+ let config: Value = serde_json::from_str(&backup.original_config)
+ .map_err(|e| format!("解析 Codex 备份失败: {e}"))?;
+ self.write_codex_live(&config)?;
+ log::info!("Codex Live 配置已恢复");
+ }
+
+ // Gemini
+ if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await {
+ let config: Value = serde_json::from_str(&backup.original_config)
+ .map_err(|e| format!("解析 Gemini 备份失败: {e}"))?;
+ self.write_gemini_live(&config)?;
+ log::info!("Gemini Live 配置已恢复");
+ }
+
+ Ok(())
+ }
+
+ /// 检查是否处于 Live 接管模式
+ pub async fn is_takeover_active(&self) -> Result {
+ self.db
+ .is_live_takeover_active()
+ .await
+ .map_err(|e| format!("检查接管状态失败: {e}"))
+ }
+
+ /// 代理模式下切换供应商(热切换,不写 Live)
+ pub async fn switch_proxy_target(
+ &self,
+ app_type: &str,
+ provider_id: &str,
+ ) -> Result<(), String> {
+ // 更新数据库中的 is_current 标记
+ let app_type_enum =
+ AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
+
+ self.db
+ .set_current_provider(app_type_enum.as_str(), provider_id)
+ .map_err(|e| format!("更新当前供应商失败: {e}"))?;
+
+ log::info!(
+ "代理模式:已切换 {} 的目标供应商为 {}",
+ app_type,
+ provider_id
+ );
+ Ok(())
+ }
+
+ // ==================== Live 配置读写辅助方法 ====================
+
+ fn read_claude_live(&self) -> Result {
+ let path = get_claude_settings_path();
+ if !path.exists() {
+ return Err("Claude 配置文件不存在".to_string());
+ }
+ read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}"))
+ }
+
+ fn write_claude_live(&self, config: &Value) -> Result<(), String> {
+ let path = get_claude_settings_path();
+ write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}"))
+ }
+
+ fn read_codex_live(&self) -> Result {
+ use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
+
+ let auth_path = get_codex_auth_path();
+ if !auth_path.exists() {
+ return Err("Codex auth.json 不存在".to_string());
+ }
+
+ let auth: Value =
+ read_json_file(&auth_path).map_err(|e| format!("读取 Codex auth 失败: {e}"))?;
+
+ let config_path = get_codex_config_path();
+ let config_str = if config_path.exists() {
+ std::fs::read_to_string(&config_path)
+ .map_err(|e| format!("读取 Codex config 失败: {e}"))?
+ } else {
+ String::new()
+ };
+
+ Ok(json!({
+ "auth": auth,
+ "config": config_str
+ }))
+ }
+
+ fn write_codex_live(&self, config: &Value) -> Result<(), String> {
+ use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
+
+ if let Some(auth) = config.get("auth") {
+ let auth_path = get_codex_auth_path();
+ write_json_file(&auth_path, auth).map_err(|e| format!("写入 Codex auth 失败: {e}"))?;
+ }
+
+ if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
+ let config_path = get_codex_config_path();
+ std::fs::write(&config_path, config_str)
+ .map_err(|e| format!("写入 Codex config 失败: {e}"))?;
+ }
+
+ Ok(())
+ }
+
+ fn read_gemini_live(&self) -> Result {
+ use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
+
+ let env_path = get_gemini_env_path();
+ if !env_path.exists() {
+ return Err("Gemini .env 文件不存在".to_string());
+ }
+
+ let env_map = read_gemini_env().map_err(|e| format!("读取 Gemini env 失败: {e}"))?;
+ Ok(env_to_json(&env_map))
+ }
+
+ fn write_gemini_live(&self, config: &Value) -> Result<(), String> {
+ use crate::gemini_config::{json_to_env, write_gemini_env_atomic};
+
+ let env_map = json_to_env(config).map_err(|e| format!("转换 Gemini 配置失败: {e}"))?;
+ write_gemini_env_atomic(&env_map).map_err(|e| format!("写入 Gemini env 失败: {e}"))?;
+ Ok(())
+ }
+
+ // ==================== 原有方法 ====================
+
/// 获取服务器状态
pub async fn get_status(&self) -> Result {
if let Some(server) = self.server.read().await.as_ref() {
@@ -103,9 +565,10 @@ impl ProxyService {
.await
.map_err(|e| format!("获取代理配置失败: {e}"))?;
- // 保存到数据库(保持 enabled 状态不变)
+ // 保存到数据库(保持 enabled 和 live_takeover_active 状态不变)
let mut new_config = config.clone();
new_config.enabled = previous.enabled;
+ new_config.live_takeover_active = previous.live_takeover_active;
self.db
.update_proxy_config(new_config.clone())
diff --git a/src/App.tsx b/src/App.tsx
index 05c695206..7d4a84d55 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -25,6 +25,7 @@ import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { extractErrorMessage } from "@/utils/errorUtils";
+import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -33,6 +34,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
+import { ProxyToggle } from "@/components/proxy/ProxyToggle";
import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
@@ -63,7 +65,7 @@ function App() {
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
// 获取代理服务状态
- const { isRunning: isProxyRunning } = useProxyStatus();
+ const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus();
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
@@ -321,6 +323,7 @@ function App() {
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
+ isProxyTakeover={isProxyRunning && isTakeoverActive}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
@@ -415,7 +418,12 @@ function App() {
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
- className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
+ className={cn(
+ "text-xl font-semibold transition-colors",
+ isProxyRunning && isTakeoverActive
+ ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
+ : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
+ )}
>
CC Switch
@@ -482,6 +490,8 @@ function App() {
)}
{currentView === "providers" && (
<>
+
+
diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx
index 4a35f27a0..c649053cb 100644
--- a/src/components/providers/ProviderActions.tsx
+++ b/src/components/providers/ProviderActions.tsx
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
interface ProviderActionsProps {
isCurrent: boolean;
isTesting?: boolean;
+ isProxyTakeover?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
@@ -30,6 +31,7 @@ interface ProviderActionsProps {
export function ProviderActions({
isCurrent,
isTesting,
+ isProxyTakeover = false,
onSwitch,
onEdit,
onDuplicate,
@@ -54,6 +56,10 @@ export function ProviderActions({
"w-[4.5rem] px-2.5",
isCurrent &&
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
+ // 代理接管模式下启用按钮使用绿色
+ !isCurrent &&
+ isProxyTakeover &&
+ "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
)}
>
{isCurrent ? (
@@ -118,7 +124,8 @@ export function ProviderActions({
{/* 重置熔断器按钮 - 代理目标启用时显示 */}
- {onResetCircuitBreaker && isProxyTarget && (
+ {/* TODO: 暂时隐藏,后续根据故障转移功能启用 */}
+ {/* {onResetCircuitBreaker && isProxyTarget && (
- )}
+ )} */}
diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx
index e6afe4240..2f8029b2e 100644
--- a/src/components/providers/ProviderCard.tsx
+++ b/src/components/providers/ProviderCard.tsx
@@ -11,13 +11,10 @@ import { cn } from "@/lib/utils";
import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
-import { Switch } from "@/components/ui/switch";
-import { Label } from "@/components/ui/label";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import {
useProviderHealth,
useResetCircuitBreaker,
- useSetProxyTarget,
} from "@/lib/query/failover";
import { toast } from "sonner";
@@ -40,8 +37,7 @@ interface ProviderCardProps {
onTest?: (provider: Provider) => void;
isTesting?: boolean;
isProxyRunning: boolean;
- proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
- allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
+ isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
dragHandleProps?: DragHandleProps;
}
@@ -93,8 +89,7 @@ export function ProviderCard({
onTest,
isTesting,
isProxyRunning,
- proxyPriority,
- allProviders,
+ isProxyTakeover = false,
dragHandleProps,
}: ProviderCardProps) {
const { t } = useTranslation();
@@ -102,77 +97,9 @@ export function ProviderCard({
// 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId);
- // 设置代理目标
- const setProxyTargetMutation = useSetProxyTarget();
-
// 重置熔断器
const resetCircuitBreaker = useResetCircuitBreaker();
- const handleSetProxyTarget = async (enabled: boolean) => {
- try {
- await setProxyTargetMutation.mutateAsync({
- providerId: provider.id,
- appType: appId,
- enabled,
- });
-
- // 计算实际优先级(开启时)
- let actualPriority: number | undefined;
- if (enabled && allProviders) {
- // 模拟开启后的状态:获取所有将要启用代理的 providers
- const futureProxyTargets = allProviders.filter((p) => {
- // 包括:已经是代理目标的 或 当前要开启的这个
- if (p.id === provider.id) return true;
- return p.isProxyTarget;
- });
-
- // 按 sortIndex 排序
- const sortedTargets = futureProxyTargets.sort((a, b) => {
- const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
- const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
- return indexA - indexB;
- });
-
- // 找到当前 provider 的位置
- const position = sortedTargets.findIndex((p) => p.id === provider.id);
- actualPriority = position >= 0 ? position + 1 : undefined;
- }
-
- const message = enabled
- ? actualPriority
- ? t("provider.proxyTargetEnabled", {
- defaultValue: `已启用代理目标(优先级:P${actualPriority})`,
- })
- : t("provider.proxyTargetEnabled", {
- defaultValue: "已启用代理目标",
- })
- : t("provider.proxyTargetDisabled", {
- defaultValue: "已禁用代理目标",
- });
-
- const description = enabled
- ? t("provider.proxyTargetEnabledDesc", {
- defaultValue: "下次请求将按优先级自动选择此供应商",
- })
- : t("provider.proxyTargetDisabledDesc", {
- defaultValue: "后续请求将使用其他可用供应商",
- });
-
- toast.success(message, {
- description,
- duration: 4000,
- });
- } catch (error) {
- toast.error(
- t("provider.setProxyTargetFailed", {
- defaultValue: "操作失败",
- }) +
- ": " +
- String(error),
- );
- }
- };
-
const handleResetCircuitBreaker = async () => {
try {
await resetCircuitBreaker.mutateAsync({
@@ -230,13 +157,25 @@ export function ProviderCard({
-
+
{/* 健康状态徽章和优先级 */}
- {isProxyRunning && (
-
- {/* 健康徽章:代理目标启用时始终显示,没有健康数据时默认为正常(0失败) */}
- {(provider.isProxyTarget || health) && (
-
- )}
- {/* 优先级:仅在代理目标启用时显示 */}
- {provider.isProxyTarget && proxyPriority && (
-
- P{proxyPriority}
-
- )}
-
+ {isProxyRunning && health && (
+
)}
{provider.category === "third_party" &&
@@ -302,42 +226,6 @@ export function ProviderCard({
⭐
)}
-
- {/* 代理目标开关 - 仅在代理服务运行时显示 */}
- {isProxyRunning && (
- e.stopPropagation()}
- >
- {
- handleSetProxyTarget(checked);
- }}
- disabled={setProxyTargetMutation.isPending}
- className="scale-75 data-[state=checked]:bg-green-500"
- />
- {provider.isProxyTarget && (
-
- {t("provider.proxyTarget", { defaultValue: "代理目标" })}
-
- )}
- {!provider.isProxyTarget && (
-
- {t("provider.setAsProxyTarget", {
- defaultValue: "设为代理",
- })}
-
- )}
-
- )}
{displayUrl && (
@@ -360,7 +248,7 @@ export function ProviderCard({
-
+
-
+
onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
diff --git a/src/components/providers/ProviderHealthBadge.tsx b/src/components/providers/ProviderHealthBadge.tsx
index dbacb6caf..1a9f901d7 100644
--- a/src/components/providers/ProviderHealthBadge.tsx
+++ b/src/components/providers/ProviderHealthBadge.tsx
@@ -3,7 +3,6 @@ import { ProviderHealthStatus } from "@/types/proxy";
interface ProviderHealthBadgeProps {
consecutiveFailures: number;
- isProxyTarget?: boolean;
className?: string;
}
@@ -13,14 +12,8 @@ interface ProviderHealthBadgeProps {
*/
export function ProviderHealthBadge({
consecutiveFailures,
- isProxyTarget,
className,
}: ProviderHealthBadgeProps) {
- // 如果代理目标已关闭但有失败记录,仍然显示(自动熔断场景)
- // 如果代理目标启用,始终显示
- // 如果代理目标关闭且无失败记录,隐藏
- if (!isProxyTarget && consecutiveFailures === 0) return null;
-
// 根据失败次数计算状态
const getStatus = () => {
if (consecutiveFailures === 0) {
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx
index 53499aed9..748c8a930 100644
--- a/src/components/providers/ProviderList.tsx
+++ b/src/components/providers/ProviderList.tsx
@@ -5,7 +5,6 @@ import {
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
-import { useMemo } from "react";
import type { CSSProperties } from "react";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
@@ -27,6 +26,7 @@ interface ProviderListProps {
onCreate?: () => void;
isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态
+ isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
}
export function ProviderList({
@@ -42,6 +42,7 @@ export function ProviderList({
onCreate,
isLoading = false,
isProxyRunning = false, // 默认值为 false
+ isProxyTakeover = false, // 默认值为 false
}: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers,
@@ -51,27 +52,6 @@ export function ProviderList({
// 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId);
- // 计算代理目标的实际优先级映射 (P1, P2, P3...)
- const proxyPriorityMap = useMemo(() => {
- // 获取所有启用代理目标的供应商
- const proxyTargets = sortedProviders.filter((p) => p.isProxyTarget);
-
- // 按 sortIndex 排序
- const sortedTargets = proxyTargets.sort((a, b) => {
- const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
- const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
- return indexA - indexB;
- });
-
- // 创建优先级映射
- const map = new Map();
- sortedTargets.forEach((provider, index) => {
- map.set(provider.id, index + 1); // P1, P2, P3...
- });
-
- return map;
- }, [sortedProviders]);
-
const handleTest = (provider: Provider) => {
checkProvider(provider.id, provider.name);
};
@@ -122,8 +102,7 @@ export function ProviderList({
onTest={handleTest}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
- proxyPriority={proxyPriorityMap.get(provider.id)}
- allProviders={sortedProviders}
+ isProxyTakeover={isProxyTakeover}
/>
))}
@@ -145,8 +124,7 @@ interface SortableProviderCardProps {
onTest: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
- proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
- allProviders?: Provider[]; // 所有供应商列表
+ isProxyTakeover: boolean;
}
function SortableProviderCard({
@@ -162,8 +140,7 @@ function SortableProviderCard({
onTest,
isTesting,
isProxyRunning,
- proxyPriority,
- allProviders,
+ isProxyTakeover,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -196,8 +173,7 @@ function SortableProviderCard({
onTest={onTest}
isTesting={isTesting}
isProxyRunning={isProxyRunning}
- proxyPriority={proxyPriority}
- allProviders={allProviders}
+ isProxyTakeover={isProxyTakeover}
dragHandleProps={{
attributes,
listeners,
diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx
index 3adbf9dbd..ebeb77129 100644
--- a/src/components/providers/forms/EndpointSpeedTest.tsx
+++ b/src/components/providers/forms/EndpointSpeedTest.tsx
@@ -559,7 +559,7 @@ const EndpointSpeedTest: React.FC
= ({
handleSelect(entry.url)}
- className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
+ className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition text-foreground ${
isSelected
? "border-primary/70 bg-primary/5 shadow-sm"
: "border-border-default bg-background hover:bg-muted"
@@ -577,7 +577,7 @@ const EndpointSpeedTest: React.FC
= ({
{/* 内容 */}
-
diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx
index d8e644c94..c7fab569d 100644
--- a/src/components/proxy/AutoFailoverConfigPanel.tsx
+++ b/src/components/proxy/AutoFailoverConfigPanel.tsx
@@ -18,7 +18,11 @@ export interface AutoFailoverConfigPanelProps {
export function AutoFailoverConfigPanel({
enabled,
+ onEnabledChange: _onEnabledChange,
}: AutoFailoverConfigPanelProps) {
+ // Note: onEnabledChange is currently unused but kept in the interface
+ // for potential future use by parent components
+ void _onEnabledChange;
const { t } = useTranslation();
const { data: config, isLoading, error } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
@@ -76,11 +80,11 @@ export function AutoFailoverConfigPanel({
return (
- {/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
- Since we need it in the accordion header, and this component is inside the content, we can use a portal or
- absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
- and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
- Better yet, let's just render the content directly without the wrapping Card header/collapse logic
+ {/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
+ Since we need it in the accordion header, and this component is inside the content, we can use a portal or
+ absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
+ and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
+ Better yet, let's just render the content directly without the wrapping Card header/collapse logic
since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
*/}
diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx
index fab2fb54f..c4cbabeb3 100644
--- a/src/components/proxy/ProxyPanel.tsx
+++ b/src/components/proxy/ProxyPanel.tsx
@@ -302,10 +302,9 @@ function ProviderQueueItem({
)}
- {/* 健康徽章:队列中的代理目标始终显示,没有健康数据时默认为正常 */}
+ {/* 健康徽章 */}
);
diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx
new file mode 100644
index 000000000..17300e571
--- /dev/null
+++ b/src/components/proxy/ProxyToggle.tsx
@@ -0,0 +1,82 @@
+/**
+ * 代理模式切换开关组件
+ *
+ * 放置在主界面头部,用于一键启用/关闭代理模式
+ * 启用时自动接管 Live 配置,关闭时恢复原始配置
+ */
+
+import { Radio, Loader2 } from "lucide-react";
+import { Switch } from "@/components/ui/switch";
+import { useProxyStatus } from "@/hooks/useProxyStatus";
+import { cn } from "@/lib/utils";
+
+interface ProxyToggleProps {
+ className?: string;
+}
+
+export function ProxyToggle({ className }: ProxyToggleProps) {
+ const {
+ isRunning,
+ isTakeoverActive,
+ startWithTakeover,
+ stopWithRestore,
+ isPending,
+ status,
+ } = useProxyStatus();
+
+ const handleToggle = async (checked: boolean) => {
+ if (checked) {
+ await startWithTakeover();
+ } else {
+ await stopWithRestore();
+ }
+ };
+
+ const isActive = isRunning && isTakeoverActive;
+
+ const tooltipText = isActive
+ ? `代理模式运行中 - ${status?.address}:${status?.port}\n切换供应商为热切换`
+ : "开启代理模式\n启用后自动接管 Live 配置";
+
+ return (
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+
+ Proxy
+
+
+
+ );
+}
diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx
index 33e4c8bfc..42ece5a2e 100644
--- a/src/components/settings/SettingsPage.tsx
+++ b/src/components/settings/SettingsPage.tsx
@@ -174,8 +174,8 @@ export function SettingsPage({
const {
isRunning,
- start: startProxy,
- stop: stopProxy,
+ startWithTakeover: startProxy,
+ stopWithRestore: stopProxy,
isPending: isProxyPending,
} = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true);
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 5050b0cdf..98c583d82 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -9,7 +9,7 @@ const Input = React.forwardRef(
previousData,
});
- // 启动服务器
- const startMutation = useMutation({
- mutationFn: () => invoke("start_proxy_server"),
+ // 查询接管状态
+ const { data: isTakeoverActive } = useQuery({
+ queryKey: ["proxyTakeoverActive"],
+ queryFn: () => invoke("is_live_takeover_active"),
+ });
+
+ // 启动服务器(带 Live 配置接管)
+ const startWithTakeoverMutation = useMutation({
+ mutationFn: () => invoke("start_proxy_with_takeover"),
onSuccess: (info) => {
- toast.success(`代理服务已启动 - ${info.address}:${info.port}`);
+ toast.success(
+ t("proxy.startedWithTakeover", {
+ defaultValue: `代理模式已启用 - ${info.address}:${info.port}`,
+ }),
+ );
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
},
onError: (error: Error) => {
- toast.error(`启动失败: ${error.message}`);
+ const detail = extractErrorMessage(error) || "未知错误";
+ toast.error(
+ t("proxy.startWithTakeoverFailed", {
+ defaultValue: `启动失败: ${detail}`,
+ }),
+ );
},
});
- // 停止服务器
- const stopMutation = useMutation({
- mutationFn: () => invoke("stop_proxy_server"),
+ // 停止服务器(恢复 Live 配置)
+ const stopWithRestoreMutation = useMutation({
+ mutationFn: () => invoke("stop_proxy_with_restore"),
+ onSuccess: () => {
+ toast.success(
+ t("proxy.stoppedWithRestore", {
+ defaultValue: "代理模式已关闭,配置已恢复",
+ }),
+ );
+ queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
+ },
+ onError: (error: Error) => {
+ const detail = extractErrorMessage(error) || "未知错误";
+ toast.error(
+ t("proxy.stopWithRestoreFailed", {
+ defaultValue: `停止失败: ${detail}`,
+ }),
+ );
+ },
+ });
+
+ // 代理模式切换供应商(热切换)
+ const switchProxyProviderMutation = useMutation({
+ mutationFn: ({
+ appType,
+ providerId,
+ }: {
+ appType: string;
+ providerId: string;
+ }) => invoke("switch_proxy_provider", { appType, providerId }),
onSuccess: () => {
- toast.success("代理服务已停止");
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
- toast.error(`停止失败: ${error.message}`);
+ const detail = extractErrorMessage(error) || "未知错误";
+ toast.error(`切换失败: ${detail}`);
},
});
@@ -56,15 +103,36 @@ export function useProxyStatus() {
}
};
+ // 检查接管状态
+ const checkTakeoverActive = async () => {
+ try {
+ return await invoke("is_live_takeover_active");
+ } catch {
+ return false;
+ }
+ };
+
return {
status,
isLoading,
isRunning: status?.running || false,
- start: startMutation.mutateAsync,
- stop: stopMutation.mutateAsync,
+ isTakeoverActive: isTakeoverActive || false,
+
+ // 启动/停止(接管模式)
+ startWithTakeover: startWithTakeoverMutation.mutateAsync,
+ stopWithRestore: stopWithRestoreMutation.mutateAsync,
+
+ // 代理模式下切换供应商
+ switchProxyProvider: switchProxyProviderMutation.mutateAsync,
+
+ // 状态检查
checkRunning,
- isStarting: startMutation.isPending,
- isStopping: stopMutation.isPending,
- isPending: startMutation.isPending || stopMutation.isPending,
+ checkTakeoverActive,
+
+ // 加载状态
+ isStarting: startWithTakeoverMutation.isPending,
+ isStopping: stopWithRestoreMutation.isPending,
+ isPending:
+ startWithTakeoverMutation.isPending || stopWithRestoreMutation.isPending,
};
}
diff --git a/src/types/proxy.ts b/src/types/proxy.ts
index 8d4d4dc54..133295c2e 100644
--- a/src/types/proxy.ts
+++ b/src/types/proxy.ts
@@ -5,6 +5,7 @@ export interface ProxyConfig {
max_retries: number;
request_timeout: number;
enable_logging: boolean;
+ live_takeover_active?: boolean;
}
export interface ProxyStatus {