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 [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) -[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](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 ![Zhipu GLM](assets/partners/banners/glm-en.jpg) -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 + +AIGoCode +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 オールインワン・アシスタント [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) -[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](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 拡張、システムプロンプトを統合管理。 - ## ❤️スポンサー ![Zhipu GLM](assets/partners/banners/glm-en.jpg) -本プロジェクトは 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 のスポンサー提供でお届けしています。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 全方位辅助工具 [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) -[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](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 扩展和系统提示词。 - ## ❤️赞助商 ![智谱 GLM](assets/partners/banners/glm-zh.jpg) -感谢智谱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 赞助了本项目!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 && ( - )} + )} */}
{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 = ({ {/* 内容 */}
-
+
{entry.url}
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 {