Merge branch 'main' into feat/provider-icon-color

# Conflicts:
#	src-tauri/src/proxy/types.rs
#	src-tauri/src/usage_script.rs
#	src/components/proxy/AutoFailoverConfigPanel.tsx
This commit is contained in:
YoVinchen
2025-12-11 17:08:09 +08:00
28 changed files with 1186 additions and 336 deletions
+7 -10
View File
@@ -3,7 +3,6 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI # 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) [![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) [![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/) [![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) [![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) 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.
</div> </div>
## ❤️Sponsor ## ❤️Sponsor
![Zhipu GLM](assets/partners/banners/glm-en.jpg) ![Zhipu GLM](assets/partners/banners/glm-en.jpg)
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN. 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)!
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
<td>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! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td> <td>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! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr> </tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>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 <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, youll receive an extra 10% bonus credit on your first top-up!
</td>
</tr>
</table> </table>
## Screenshots ## Screenshots
+8 -10
View File
@@ -3,7 +3,6 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント # Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![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) [![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/) [![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) [![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) [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 拡張、システムプロンプトを統合管理。
</div> </div>
## ❤️スポンサー ## ❤️スポンサー
![Zhipu GLM](assets/partners/banners/glm-en.jpg) ![Zhipu GLM](assets/partners/banners/glm-en.jpg)
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。 本プロジェクトは 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% オフになります!
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 コーディング向けのサブスクリプションで
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td> <td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
</tr> </tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
</td>
</tr>
</table> </table>
## スクリーンショット ## スクリーンショット
+6 -10
View File
@@ -3,7 +3,6 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具 # Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![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) [![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/) [![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) [![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) [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 扩展和系统提示词。
</div> </div>
## ❤️赞助商 ## ❤️赞助商
![智谱 GLM](assets/partners/banners/glm-zh.jpg) ![智谱 GLM](assets/partners/banners/glm-zh.jpg)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目! 感谢智谱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)购买可以享受九折优惠。
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 即可一键导入编
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td> <td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr> </tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
</table> </table>
## 界面预览 ## 界面预览
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+129 -33
View File
@@ -69,8 +69,6 @@ pub struct ToolVersion {
#[tauri::command] #[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> { pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
use std::process::Command;
let tools = vec!["claude", "codex", "gemini"]; let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new(); let mut results = Vec::new();
@@ -81,39 +79,16 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
for tool in tools { for tool in tools {
// 1. 获取本地版本 (保持不变) // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = { let (local_version, local_error) = {
let output = if cfg!(target_os = "windows") { // 先尝试直接执行
Command::new("cmd") let direct_result = try_get_version(tool);
.args(["/C", &format!("{tool} --version")])
.output()
} else {
Command::new("sh")
.arg("-c")
.arg(format!("{tool} --version"))
.output()
};
match output { if direct_result.0.is_some() {
Ok(out) => { direct_result
if out.status.success() { } else {
( // 扫描常见的 npm 全局安装路径
Some(String::from_utf8_lossy(&out.stdout).trim().to_string()), scan_cli_version(tool)
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())),
} }
}; };
@@ -153,3 +128,124 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
Err(_) => None, 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<String>, Option<String>) {
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<String>, Option<String>) {
use std::process::Command;
let home = dirs::home_dir().unwrap_or_default();
// 常见的 npm 全局安装路径
let mut search_paths: Vec<std::path::PathBuf> = 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()))
}
+25 -6
View File
@@ -7,18 +7,18 @@ use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState; use crate::store::AppState;
/// 启动代理服务器 /// 启动代理服务器(带 Live 配置接管)
#[tauri::command] #[tauri::command]
pub async fn start_proxy_server( pub async fn start_proxy_with_takeover(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> { ) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await state.proxy_service.start_with_takeover().await
} }
/// 停止代理服务器 /// 停止代理服务器(恢复 Live 配置)
#[tauri::command] #[tauri::command]
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> { pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
state.proxy_service.stop().await state.proxy_service.stop_with_restore().await
} }
/// 获取代理服务器状态 /// 获取代理服务器状态
@@ -48,6 +48,25 @@ pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool,
Ok(state.proxy_service.is_running().await) Ok(state.proxy_service.is_running().await)
} }
/// 检查是否处于 Live 接管模式
#[tauri::command]
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
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
}
// ==================== 故障转移相关命令 ==================== // ==================== 故障转移相关命令 ====================
/// 获取代理目标列表 /// 获取代理目标列表
+20
View File
@@ -483,6 +483,26 @@ impl Database {
Ok(targets) 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( pub fn add_custom_endpoint(
&self, &self,
+99 -3
View File
@@ -17,7 +17,7 @@ impl Database {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
"SELECT enabled, listen_address, listen_port, max_retries, "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", FROM proxy_config WHERE id = 1",
[], [],
|row| { |row| {
@@ -28,6 +28,7 @@ impl Database {
max_retries: row.get::<_, i32>(3)? as u8, max_retries: row.get::<_, i32>(3)? as u8,
request_timeout: row.get::<_, i32>(4)? as u64, request_timeout: row.get::<_, i32>(4)? as u64,
enable_logging: row.get::<_, i32>(5)? != 0, 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( conn.execute(
"INSERT OR REPLACE INTO proxy_config "INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at) (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, VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))", datetime('now'))",
rusqlite::params![ rusqlite::params![
@@ -62,6 +63,7 @@ impl Database {
config.max_retries as i32, config.max_retries as i32,
config.request_timeout as i32, config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 }, if config.enable_logging { 1 } else { 0 },
if config.live_takeover_active { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值 "claude", // 兼容旧字段,写入默认值
], ],
) )
@@ -70,6 +72,30 @@ impl Database {
Ok(()) 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<bool, AppError> {
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 Health ====================
/// 获取Provider健康状态 /// 获取Provider健康状态
@@ -241,4 +267,74 @@ impl Database {
Ok(()) 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<Option<LiveBackup>, 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(())
}
} }
+17
View File
@@ -302,6 +302,23 @@ impl Database {
) )
.map_err(|e| AppError::Database(e.to_string()))?; .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(()) Ok(())
} }
+59 -3
View File
@@ -530,7 +530,7 @@ pub fn run() {
Ok(config) => { Ok(config) => {
if config.enabled { if config.enabled {
log::info!("代理服务配置为启用,正在启动..."); log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start().await { match state.proxy_service.start_with_takeover().await {
Ok(info) => log::info!( Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}", "代理服务器自动启动成功: {}:{}",
info.address, info.address,
@@ -647,12 +647,14 @@ pub fn run() {
commands::set_auto_launch, commands::set_auto_launch,
commands::get_auto_launch_status, commands::get_auto_launch_status,
// Proxy server management // Proxy server management
commands::start_proxy_server, commands::start_proxy_with_takeover,
commands::stop_proxy_server, commands::stop_proxy_with_restore,
commands::get_proxy_status, commands::get_proxy_status,
commands::get_proxy_config, commands::get_proxy_config,
commands::update_proxy_config, commands::update_proxy_config,
commands::is_proxy_running, commands::is_proxy_running,
commands::is_live_takeover_active,
commands::switch_proxy_provider,
// Proxy failover commands // Proxy failover commands
commands::get_proxy_targets, commands::get_proxy_targets,
commands::set_proxy_target, commands::set_proxy_target,
@@ -685,6 +687,22 @@ pub fn run() {
.expect("error while running tauri application"); .expect("error while running tauri application");
app.run(|app_handle, event| { 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")] #[cfg(target_os = "macos")]
{ {
match event { 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::<store::AppState>() {
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!("代理服务器清理完成");
}
}
}
// ============================================================ // ============================================================
// 迁移错误对话框辅助函数 // 迁移错误对话框辅助函数
// ============================================================ // ============================================================
+31 -47
View File
@@ -30,58 +30,42 @@ impl ProviderRouter {
/// 选择可用的供应商(支持故障转移) /// 选择可用的供应商(支持故障转移)
/// 返回按优先级排序的可用供应商列表 /// 返回按优先级排序的可用供应商列表
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> { pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
// 1. 获取所有启用代理的供应商 // 直接获取当前选中的供应商(基于 is_current 字段)
let providers = self.db.get_proxy_targets(app_type).await?; 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() { let providers = self.db.get_all_providers(app_type)?;
return Err(AppError::Config( let provider = providers
"No proxy target providers configured".to_string(), .get(&current_id)
)); .ok_or_else(|| AppError::Config(format!("Current provider {} not found", current_id)))?
} .clone();
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(),
));
}
log::info!( log::info!(
"Selected {} available providers for failover chain", "[{}] Selected current provider: {} ({})",
available_providers.len() 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])
} }
/// 记录供应商请求结果 /// 记录供应商请求结果
+11 -4
View File
@@ -148,17 +148,24 @@ impl ProxyServer {
// 健康检查 // 健康检查
.route("/health", get(handlers::health_check)) .route("/health", get(handlers::health_check))
.route("/status", get(handlers::get_status)) .route("/status", get(handlers::get_status))
// Claude API // Claude API (支持带前缀和不带前缀两种格式)
.route("/v1/messages", post(handlers::handle_messages)) .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( .route(
"/v1/chat/completions", "/v1/chat/completions",
post(handlers::handle_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)) .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("/v1beta/*path", post(handlers::handle_gemini))
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
.layer(cors) .layer(cors)
.with_state(self.state.clone()) .with_state(self.state.clone())
} }
+15
View File
@@ -15,6 +15,9 @@ pub struct ProxyConfig {
pub request_timeout: u64, pub request_timeout: u64,
/// 是否启用日志 /// 是否启用日志
pub enable_logging: bool, pub enable_logging: bool,
/// 是否正在接管 Live 配置
#[serde(default)]
pub live_takeover_active: bool,
} }
impl Default for ProxyConfig { impl Default for ProxyConfig {
@@ -26,6 +29,7 @@ impl Default for ProxyConfig {
max_retries: 3, max_retries: 3,
request_timeout: 300, request_timeout: 300,
enable_logging: true, enable_logging: true,
live_takeover_active: false,
} }
} }
} }
@@ -103,3 +107,14 @@ pub struct ProviderHealth {
pub last_error: Option<String>, pub last_error: Option<String>,
pub updated_at: String, 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,
}
+62 -5
View File
@@ -173,14 +173,71 @@ impl ProviderService {
/// ///
/// Switch flow: /// Switch flow:
/// 1. Validate target provider exists /// 1. Validate target provider exists
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications /// 2. Check if proxy takeover mode is active AND proxy server is running
/// 3. Update local settings current_provider_xxx (device-level) /// 3. If takeover mode active: hot-switch proxy target only (no Live config write)
/// 4. Update database is_current (as default for new devices) /// 4. If normal mode:
/// 5. Write target provider config to live files /// a. **Backfill mechanism**: Backfill current live config to current provider
/// 6. Sync MCP configuration /// 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> { pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists // Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?; 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<String, Provider>,
) -> Result<(), AppError> {
let provider = providers let provider = providers
.get(id) .get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
+464 -1
View File
@@ -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::database::Database;
use crate::proxy::server::ProxyServer; use crate::proxy::server::ProxyServer;
use crate::proxy::types::*; use crate::proxy::types::*;
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -59,6 +63,196 @@ impl ProxyService {
Ok(info) Ok(info)
} }
/// 启动代理服务器(带 Live 配置接管)
pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {
// 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> { pub async fn stop(&self) -> Result<(), String> {
if let Some(server) = self.server.write().await.take() { if let Some(server) = self.server.write().await.take() {
@@ -66,6 +260,13 @@ impl ProxyService {
.stop() .stop()
.await .await
.map_err(|e| format!("停止代理服务器失败: {e}"))?; .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!("代理服务器已停止"); log::info!("代理服务器已停止");
Ok(()) Ok(())
} else { } 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<bool, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<ProxyStatus, String> { pub async fn get_status(&self) -> Result<ProxyStatus, String> {
if let Some(server) = self.server.read().await.as_ref() { if let Some(server) = self.server.read().await.as_ref() {
@@ -103,9 +565,10 @@ impl ProxyService {
.await .await
.map_err(|e| format!("获取代理配置失败: {e}"))?; .map_err(|e| format!("获取代理配置失败: {e}"))?;
// 保存到数据库(保持 enabled 状态不变) // 保存到数据库(保持 enabled 和 live_takeover_active 状态不变)
let mut new_config = config.clone(); let mut new_config = config.clone();
new_config.enabled = previous.enabled; new_config.enabled = previous.enabled;
new_config.live_takeover_active = previous.live_takeover_active;
self.db self.db
.update_proxy_config(new_config.clone()) .update_proxy_config(new_config.clone())
+12 -2
View File
@@ -25,6 +25,7 @@ import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions"; import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher"; import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList"; import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -33,6 +34,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsPage } from "@/components/settings/SettingsPage"; import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge"; import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
import UsageScriptModal from "@/components/UsageScriptModal"; import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel"; 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"; "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, { const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
@@ -321,6 +323,7 @@ function App() {
appId={activeApp} appId={activeApp}
isLoading={isLoading} isLoading={isLoading}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isTakeoverActive}
onSwitch={switchProvider} onSwitch={switchProvider}
onEdit={setEditingProvider} onEdit={setEditingProvider}
onDelete={setConfirmDelete} onDelete={setConfirmDelete}
@@ -415,7 +418,12 @@ function App() {
href="https://github.com/farion1231/cc-switch" href="https://github.com/farion1231/cc-switch"
target="_blank" target="_blank"
rel="noreferrer" 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 CC Switch
</a> </a>
@@ -482,6 +490,8 @@ function App() {
)} )}
{currentView === "providers" && ( {currentView === "providers" && (
<> <>
<ProxyToggle />
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} /> <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="bg-muted p-1 rounded-xl flex items-center gap-1"> <div className="bg-muted p-1 rounded-xl flex items-center gap-1">
+10 -3
View File
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
interface ProviderActionsProps { interface ProviderActionsProps {
isCurrent: boolean; isCurrent: boolean;
isTesting?: boolean; isTesting?: boolean;
isProxyTakeover?: boolean;
onSwitch: () => void; onSwitch: () => void;
onEdit: () => void; onEdit: () => void;
onDuplicate: () => void; onDuplicate: () => void;
@@ -30,6 +31,7 @@ interface ProviderActionsProps {
export function ProviderActions({ export function ProviderActions({
isCurrent, isCurrent,
isTesting, isTesting,
isProxyTakeover = false,
onSwitch, onSwitch,
onEdit, onEdit,
onDuplicate, onDuplicate,
@@ -54,6 +56,10 @@ export function ProviderActions({
"w-[4.5rem] px-2.5", "w-[4.5rem] px-2.5",
isCurrent && isCurrent &&
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700", "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 ? ( {isCurrent ? (
@@ -118,7 +124,8 @@ export function ProviderActions({
</Button> </Button>
{/* 重置熔断器按钮 - 代理目标启用时显示 */} {/* 重置熔断器按钮 - 代理目标启用时显示 */}
{onResetCircuitBreaker && isProxyTarget && ( {/* TODO: 暂时隐藏,后续根据故障转移功能启用 */}
{/* {onResetCircuitBreaker && isProxyTarget && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -141,7 +148,7 @@ export function ProviderActions({
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
</Button> </Button>
)} )} */}
<Button <Button
size="icon" size="icon"
@@ -151,7 +158,7 @@ export function ProviderActions({
className={cn( className={cn(
iconButtonClass, iconButtonClass,
!isCurrent && "hover:text-red-500 dark:hover:text-red-400", !isCurrent && "hover:text-red-500 dark:hover:text-red-400",
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground", isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground"
)} )}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
+24 -135
View File
@@ -11,13 +11,10 @@ import { cn } from "@/lib/utils";
import { ProviderActions } from "@/components/providers/ProviderActions"; import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon"; import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter"; import UsageFooter from "@/components/UsageFooter";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { import {
useProviderHealth, useProviderHealth,
useResetCircuitBreaker, useResetCircuitBreaker,
useSetProxyTarget,
} from "@/lib/query/failover"; } from "@/lib/query/failover";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -40,8 +37,7 @@ interface ProviderCardProps {
onTest?: (provider: Provider) => void; onTest?: (provider: Provider) => void;
isTesting?: boolean; isTesting?: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...) isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
dragHandleProps?: DragHandleProps; dragHandleProps?: DragHandleProps;
} }
@@ -93,8 +89,7 @@ export function ProviderCard({
onTest, onTest,
isTesting, isTesting,
isProxyRunning, isProxyRunning,
proxyPriority, isProxyTakeover = false,
allProviders,
dragHandleProps, dragHandleProps,
}: ProviderCardProps) { }: ProviderCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -102,77 +97,9 @@ export function ProviderCard({
// 获取供应商健康状态 // 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId); const { data: health } = useProviderHealth(provider.id, appId);
// 设置代理目标
const setProxyTargetMutation = useSetProxyTarget();
// 重置熔断器 // 重置熔断器
const resetCircuitBreaker = useResetCircuitBreaker(); 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 () => { const handleResetCircuitBreaker = async () => {
try { try {
await resetCircuitBreaker.mutateAsync({ await resetCircuitBreaker.mutateAsync({
@@ -230,13 +157,25 @@ export function ProviderCard({
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300", "relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"bg-card text-card-foreground group hover:border-border-active", "bg-card text-card-foreground group",
isCurrent ? "border-primary/50 shadow-sm" : "hover:shadow-sm", // 代理接管模式下 hover 使用绿色边框,否则使用蓝色
isProxyTakeover ? "hover:border-emerald-500/50" : "hover:border-border-active",
// 代理接管模式下当前供应商使用绿色边框
isProxyTakeover && isCurrent
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
: isCurrent
? "border-primary/50 shadow-sm"
: "hover:shadow-sm",
dragHandleProps?.isDragging && dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10", "cursor-grabbing border-primary shadow-lg scale-105 z-10",
)} )}
> >
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" /> <div className={cn(
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
// 代理接管模式下使用绿色渐变,否则使用蓝色主色调
isProxyTakeover && isCurrent ? "from-emerald-500/10" : "from-primary/10",
isCurrent ? "opacity-100" : "opacity-0"
)} />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
<button <button
@@ -270,25 +209,10 @@ export function ProviderCard({
</h3> </h3>
{/* 健康状态徽章和优先级 */} {/* 健康状态徽章和优先级 */}
{isProxyRunning && ( {isProxyRunning && health && (
<div className="flex items-center gap-1.5"> <ProviderHealthBadge
{/* 健康徽章:代理目标启用时始终显示,没有健康数据时默认为正常(0失败) */} consecutiveFailures={health.consecutive_failures}
{(provider.isProxyTarget || health) && ( />
<ProviderHealthBadge
consecutiveFailures={health?.consecutive_failures ?? 0}
isProxyTarget={provider.isProxyTarget ?? false}
/>
)}
{/* 优先级:仅在代理目标启用时显示 */}
{provider.isProxyTarget && proxyPriority && (
<span
className="text-xs text-muted-foreground"
title={`代理队列优先级:第${proxyPriority}`}
>
P{proxyPriority}
</span>
)}
</div>
)} )}
{provider.category === "third_party" && {provider.category === "third_party" &&
@@ -302,42 +226,6 @@ export function ProviderCard({
</span> </span>
)} )}
{/* 代理目标开关 - 仅在代理服务运行时显示 */}
{isProxyRunning && (
<div
className="flex items-center gap-2 ml-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => {
handleSetProxyTarget(checked);
}}
disabled={setProxyTargetMutation.isPending}
className="scale-75 data-[state=checked]:bg-green-500"
/>
{provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs font-medium text-green-600 dark:text-green-400 cursor-pointer"
>
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
</Label>
)}
{!provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
{t("provider.setAsProxyTarget", {
defaultValue: "设为代理",
})}
</Label>
)}
</div>
)}
</div> </div>
{displayUrl && ( {displayUrl && (
@@ -360,7 +248,7 @@ export function ProviderCard({
</div> </div>
<div className="relative flex items-center ml-auto"> <div className="relative flex items-center ml-auto">
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[12.25rem] group-focus-within:-translate-x-[12.25rem] sm:group-hover:-translate-x-[14.25rem] sm:group-focus-within:-translate-x-[14.25rem]"> <div className="ml-auto transition-transform duration-300 ease-out group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16.5rem] sm:group-focus-within:-translate-x-[16.5rem]">
<UsageFooter <UsageFooter
provider={provider} provider={provider}
providerId={provider.id} providerId={provider.id}
@@ -371,10 +259,11 @@ export function ProviderCard({
/> />
</div> </div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"> <div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-300 ease-out translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
<ProviderActions <ProviderActions
isCurrent={isCurrent} isCurrent={isCurrent}
isTesting={isTesting} isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
onSwitch={() => onSwitch(provider)} onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)} onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)} onDuplicate={() => onDuplicate(provider)}
@@ -3,7 +3,6 @@ import { ProviderHealthStatus } from "@/types/proxy";
interface ProviderHealthBadgeProps { interface ProviderHealthBadgeProps {
consecutiveFailures: number; consecutiveFailures: number;
isProxyTarget?: boolean;
className?: string; className?: string;
} }
@@ -13,14 +12,8 @@ interface ProviderHealthBadgeProps {
*/ */
export function ProviderHealthBadge({ export function ProviderHealthBadge({
consecutiveFailures, consecutiveFailures,
isProxyTarget,
className, className,
}: ProviderHealthBadgeProps) { }: ProviderHealthBadgeProps) {
// 如果代理目标已关闭但有失败记录,仍然显示(自动熔断场景)
// 如果代理目标启用,始终显示
// 如果代理目标关闭且无失败记录,隐藏
if (!isProxyTarget && consecutiveFailures === 0) return null;
// 根据失败次数计算状态 // 根据失败次数计算状态
const getStatus = () => { const getStatus = () => {
if (consecutiveFailures === 0) { if (consecutiveFailures === 0) {
+6 -30
View File
@@ -5,7 +5,6 @@ import {
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { useMemo } from "react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { Provider } from "@/types"; import type { Provider } from "@/types";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
@@ -27,6 +26,7 @@ interface ProviderListProps {
onCreate?: () => void; onCreate?: () => void;
isLoading?: boolean; isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态 isProxyRunning?: boolean; // 代理服务运行状态
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
} }
export function ProviderList({ export function ProviderList({
@@ -42,6 +42,7 @@ export function ProviderList({
onCreate, onCreate,
isLoading = false, isLoading = false,
isProxyRunning = false, // 默认值为 false isProxyRunning = false, // 默认值为 false
isProxyTakeover = false, // 默认值为 false
}: ProviderListProps) { }: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort( const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers, providers,
@@ -51,27 +52,6 @@ export function ProviderList({
// 流式健康检查 // 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId); 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<string, number>();
sortedTargets.forEach((provider, index) => {
map.set(provider.id, index + 1); // P1, P2, P3...
});
return map;
}, [sortedProviders]);
const handleTest = (provider: Provider) => { const handleTest = (provider: Provider) => {
checkProvider(provider.id, provider.name); checkProvider(provider.id, provider.name);
}; };
@@ -122,8 +102,7 @@ export function ProviderList({
onTest={handleTest} onTest={handleTest}
isTesting={isChecking(provider.id)} isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
proxyPriority={proxyPriorityMap.get(provider.id)} isProxyTakeover={isProxyTakeover}
allProviders={sortedProviders}
/> />
))} ))}
</div> </div>
@@ -145,8 +124,7 @@ interface SortableProviderCardProps {
onTest: (provider: Provider) => void; onTest: (provider: Provider) => void;
isTesting: boolean; isTesting: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...) isProxyTakeover: boolean;
allProviders?: Provider[]; // 所有供应商列表
} }
function SortableProviderCard({ function SortableProviderCard({
@@ -162,8 +140,7 @@ function SortableProviderCard({
onTest, onTest,
isTesting, isTesting,
isProxyRunning, isProxyRunning,
proxyPriority, isProxyTakeover,
allProviders,
}: SortableProviderCardProps) { }: SortableProviderCardProps) {
const { const {
setNodeRef, setNodeRef,
@@ -196,8 +173,7 @@ function SortableProviderCard({
onTest={onTest} onTest={onTest}
isTesting={isTesting} isTesting={isTesting}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
proxyPriority={proxyPriority} isProxyTakeover={isProxyTakeover}
allProviders={allProviders}
dragHandleProps={{ dragHandleProps={{
attributes, attributes,
listeners, listeners,
@@ -559,7 +559,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
<div <div
key={entry.id} key={entry.id}
onClick={() => handleSelect(entry.url)} onClick={() => 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 isSelected
? "border-primary/70 bg-primary/5 shadow-sm" ? "border-primary/70 bg-primary/5 shadow-sm"
: "border-border-default bg-background hover:bg-muted" : "border-border-default bg-background hover:bg-muted"
@@ -577,7 +577,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
{/* 内容 */} {/* 内容 */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100"> <div className="truncate text-sm text-foreground">
{entry.url} {entry.url}
</div> </div>
</div> </div>
@@ -18,7 +18,11 @@ export interface AutoFailoverConfigPanelProps {
export function AutoFailoverConfigPanel({ export function AutoFailoverConfigPanel({
enabled, enabled,
onEnabledChange: _onEnabledChange,
}: AutoFailoverConfigPanelProps) { }: AutoFailoverConfigPanelProps) {
// Note: onEnabledChange is currently unused but kept in the interface
// for potential future use by parent components
void _onEnabledChange;
const { t } = useTranslation(); const { t } = useTranslation();
const { data: config, isLoading, error } = useCircuitBreakerConfig(); const { data: config, isLoading, error } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig(); const updateConfig = useUpdateCircuitBreakerConfig();
+1 -2
View File
@@ -302,10 +302,9 @@ function ProviderQueueItem({
</span> </span>
)} )}
</div> </div>
{/* 健康徽章:队列中的代理目标始终显示,没有健康数据时默认为正常 */} {/* 健康徽章 */}
<ProviderHealthBadge <ProviderHealthBadge
consecutiveFailures={health?.consecutive_failures ?? 0} consecutiveFailures={health?.consecutive_failures ?? 0}
isProxyTarget={true}
/> />
</div> </div>
); );
+82
View File
@@ -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 (
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
isActive
? "bg-emerald-500/10 border border-emerald-500/30"
: "bg-muted/50 hover:bg-muted",
className,
)}
title={tooltipText}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
className={cn(
"h-4 w-4 transition-colors",
isActive
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<span
className={cn(
"text-sm font-medium transition-colors select-none",
isActive
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
Proxy
</span>
<Switch
checked={isActive}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
/>
</div>
);
}
+2 -2
View File
@@ -174,8 +174,8 @@ export function SettingsPage({
const { const {
isRunning, isRunning,
start: startProxy, startWithTakeover: startProxy,
stop: stopProxy, stopWithRestore: stopProxy,
isPending: isProxyPending, isPending: isProxyPending,
} = useProxyStatus(); } = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true); const [failoverEnabled, setFailoverEnabled] = useState(true);
+1 -1
View File
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50", "flex h-9 w-full rounded-md border border-border-default bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
ref={ref} ref={ref}
+83 -15
View File
@@ -5,13 +5,16 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy"; import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy";
import { extractErrorMessage } from "@/utils/errorUtils";
/** /**
* 代理服务状态管理 * 代理服务状态管理
*/ */
export function useProxyStatus() { export function useProxyStatus() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
// 查询状态(自动轮询) // 查询状态(自动轮询)
const { data: status, isLoading } = useQuery({ const { data: status, isLoading } = useQuery({
@@ -23,27 +26,71 @@ export function useProxyStatus() {
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
// 启动服务器 // 查询接管状态
const startMutation = useMutation({ const { data: isTakeoverActive } = useQuery({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"), queryKey: ["proxyTakeoverActive"],
queryFn: () => invoke<boolean>("is_live_takeover_active"),
});
// 启动服务器(带 Live 配置接管)
const startWithTakeoverMutation = useMutation({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_with_takeover"),
onSuccess: (info) => { 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: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(`启动失败: ${error.message}`); const detail = extractErrorMessage(error) || "未知错误";
toast.error(
t("proxy.startWithTakeoverFailed", {
defaultValue: `启动失败: ${detail}`,
}),
);
}, },
}); });
// 停止服务器 // 停止服务器(恢复 Live 配置)
const stopMutation = useMutation({ const stopWithRestoreMutation = useMutation({
mutationFn: () => invoke("stop_proxy_server"), 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: () => { onSuccess: () => {
toast.success("代理服务已停止");
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
}, },
onError: (error: Error) => { 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<boolean>("is_live_takeover_active");
} catch {
return false;
}
};
return { return {
status, status,
isLoading, isLoading,
isRunning: status?.running || false, isRunning: status?.running || false,
start: startMutation.mutateAsync, isTakeoverActive: isTakeoverActive || false,
stop: stopMutation.mutateAsync,
// 启动/停止(接管模式)
startWithTakeover: startWithTakeoverMutation.mutateAsync,
stopWithRestore: stopWithRestoreMutation.mutateAsync,
// 代理模式下切换供应商
switchProxyProvider: switchProxyProviderMutation.mutateAsync,
// 状态检查
checkRunning, checkRunning,
isStarting: startMutation.isPending, checkTakeoverActive,
isStopping: stopMutation.isPending,
isPending: startMutation.isPending || stopMutation.isPending, // 加载状态
isStarting: startWithTakeoverMutation.isPending,
isStopping: stopWithRestoreMutation.isPending,
isPending:
startWithTakeoverMutation.isPending || stopWithRestoreMutation.isPending,
}; };
} }
+1
View File
@@ -5,6 +5,7 @@ export interface ProxyConfig {
max_retries: number; max_retries: number;
request_timeout: number; request_timeout: number;
enable_logging: boolean; enable_logging: boolean;
live_takeover_active?: boolean;
} }
export interface ProxyStatus { export interface ProxyStatus {