diff --git a/CHANGELOG.md b/CHANGELOG.md index af37f440b..8f8ff5481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.10.0] - 2026-01-21 + +### Feature Release + +This release introduces OpenCode support and brings improvements across proxy, usage tracking, and overall UX. + +### Added + +- **OpenCode Support** - Manage OpenCode providers, MCP servers, and Skills, with first-launch import and full internationalization (#695) +- **Global Proxy** - Add global proxy settings for outbound network requests (#596) +- **Claude Rectifier** - Add thinking signature rectifier for Claude API (#595) +- **Health Check Enhancements** - Configurable prompt and CLI-compatible requests for stream health check (#623) +- **Per-Provider Config** - Support provider-specific configuration and persistence (#663) +- **App Visibility Controls** - Show/hide apps and keep tray menu in sync (Gemini hidden by default) +- **Takeover Compact Mode** - Use a compact AppSwitcher layout when showing 3+ visible apps +- **Keyboard Shortcut** - Press `ESC` to quickly go back/close panels (#670) +- **Terminal Improvements** - Provider-specific terminal button, `fnm` path support, and safer cross-platform launching (#564) +- **WSL Tool Detection** - Detect tool versions in WSL with additional security hardening (#627) +- **Skills Presets** - Add `baoyu-skills` preset repo and auto-supplement missing default repos + +### Changed + +- **Proxy Logging** - Simplify proxy log output (#585) +- **Pricing Editor UX** - Unify pricing edit modal with `FullScreenPanel` +- **Advanced Settings Layout** - Move rectifier section below failover for better flow +- **OpenRouter Compat Mode** - Disable OpenRouter compatibility mode by default and hide UI toggle + +### Fixed + +- **Auto Failover** - Switch to P1 immediately when enabling auto failover +- **Provider Edit Dialog** - Fix stale data when reopening provider editor after save (#654) +- **Deeplink** - Support multiple endpoints and prioritize `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL` (#597) +- **MCP (WSL)** - Skip `cmd /c` wrapper for WSL target paths (#592) +- **Usage Templates** - Add variable hints and validation fixes; prevent config leaking between providers (#628) +- **Gemini Timeout Format** - Convert timeout params to Gemini CLI format (#580) +- **UI** - Fix Select dropdown rendering in `FullScreenPanel`; auto-apply default icon color when unset +- **Usage UI** - Auto-adapt usage block offset based on action buttons width (#613) +- **Provider Endpoint** - Persist endpoint auto-select state (#611) +- **Provider Form** - Reset baseUrl and apiKey states when switching presets + +--- + ## [3.9.1] - 2026-01-09 ### Bug Fix Release diff --git a/README.md b/README.md index ee5fa2867..69f858cbc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # All-in-One Assistant for Claude Code, Codex & Gemini CLI -[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases) +[![Version](https://img.shields.io/badge/version-3.10.0-blue.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/) [![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) @@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM ## Features -### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md) +### Current Version: v3.10.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md) **v3.8.0 Major Update (2025-11-28)** diff --git a/README_JA.md b/README_JA.md index 063559cf6..ac22f4eef 100644 --- a/README_JA.md +++ b/README_JA.md @@ -2,7 +2,7 @@ # Claude Code / Codex / Gemini CLI オールインワン・アシスタント -[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases) +[![Version](https://img.shields.io/badge/version-3.10.0-blue.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/) [![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) @@ -52,7 +52,7 @@ ## 特長 -### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md) +### 現在のバージョン:v3.10.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md) **v3.8.0 メジャーアップデート (2025-11-28)** diff --git a/README_ZH.md b/README_ZH.md index a25b86af7..b34195e49 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -2,7 +2,7 @@ # Claude Code / Codex / Gemini CLI 全方位辅助工具 -[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases) +[![Version](https://img.shields.io/badge/version-3.10.0-blue.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/) [![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) @@ -52,7 +52,7 @@ ## 功能特性 -### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md) +### 当前版本:v3.10.0 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md) **v3.8.0 重大更新(2025-11-28)** diff --git a/package.json b/package.json index fe0f9fad5..e54f2aa60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.9.1", + "version": "3.10.0", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e48e0090e..a727dfbdf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.9.1" +version = "3.10.0" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d767a28e6..06f359de7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.9.1" +version = "3.10.0" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index 8269f5fc3..d0aad3900 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -5,6 +5,8 @@ use crate::database::FailoverQueueItem; use crate::provider::Provider; use crate::store::AppState; +use std::str::FromStr; +use tauri::Emitter; /// 获取故障转移队列 #[tauri::command] @@ -84,6 +86,47 @@ pub async fn set_auto_failover_enabled( "[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}" ); + // 强一致语义:开启故障转移后立即切到队列 P1(并确保队列非空) + // + // 说明: + // - 仅在 enabled=true 时执行“切到 P1” + // - 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户在 UI 上陷入死锁(无法先加队列再开启) + let p1_provider_id = if enabled { + let mut queue = state + .db + .get_failover_queue(&app_type) + .map_err(|e| e.to_string())?; + + if queue.is_empty() { + let app_enum = crate::app_config::AppType::from_str(&app_type) + .map_err(|_| format!("无效的应用类型: {app_type}"))?; + + let current_id = crate::settings::get_effective_current_provider(&state.db, &app_enum) + .map_err(|e| e.to_string())?; + + let Some(current_id) = current_id else { + return Err("故障转移队列为空,且未设置当前供应商,无法开启故障转移".to_string()); + }; + + state + .db + .add_to_failover_queue(&app_type, ¤t_id) + .map_err(|e| e.to_string())?; + + queue = state + .db + .get_failover_queue(&app_type) + .map_err(|e| e.to_string())?; + } + + queue + .first() + .map(|item| item.provider_id.clone()) + .ok_or_else(|| "故障转移队列为空,无法开启故障转移".to_string())? + } else { + String::new() + }; + // 读取当前配置 let mut config = state .db @@ -101,6 +144,22 @@ pub async fn set_auto_failover_enabled( .await .map_err(|e| e.to_string())?; + // 开启后立即切到 P1:更新 is_current + 本地 settings + Live 备份(接管模式下) + if enabled { + state + .proxy_service + .switch_proxy_target(&app_type, &p1_provider_id) + .await?; + + // 发射 provider-switched 事件(让前端刷新当前供应商) + let event_data = serde_json::json!({ + "appType": app_type, + "providerId": p1_provider_id, + "source": "failoverEnabled" + }); + let _ = app.emit("provider-switched", event_data); + } + // 刷新托盘菜单,确保状态同步 if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) { if let Some(tray) = app.tray_by_id("main") { diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 67df0d2c1..2254cb061 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -203,24 +203,32 @@ impl Database { Ok(()) } - /// 初始化默认的 Skill 仓库(首次启动时调用) + /// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库) pub fn init_default_skill_repos(&self) -> Result { - // 检查是否已有仓库 + // 获取已有仓库列表 let existing = self.get_skill_repos()?; - if !existing.is_empty() { - return Ok(0); - } + let existing_keys: std::collections::HashSet<(String, String)> = existing + .iter() + .map(|r| (r.owner.clone(), r.name.clone())) + .collect(); // 获取默认仓库列表 let default_store = crate::services::skill::SkillStore::default(); let mut count = 0; + // 仅插入缺失的默认仓库 for repo in &default_store.repos { - self.save_skill_repo(repo)?; - count += 1; + let key = (repo.owner.clone(), repo.name.clone()); + if !existing_keys.contains(&key) { + self.save_skill_repo(repo)?; + count += 1; + log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name); + } } - log::info!("初始化默认 Skill 仓库完成,共 {count} 个"); + if count > 0 { + log::info!("补充默认 Skill 仓库完成,新增 {count} 个"); + } Ok(count) } } diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 5f3a315fd..cf9f0748c 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -2,11 +2,13 @@ //! //! 负责选择和管理代理目标供应商,实现智能故障转移 +use crate::app_config::AppType; use crate::database::Database; use crate::error::AppError; use crate::provider::Provider; use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig}; use std::collections::HashMap; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; @@ -31,7 +33,7 @@ impl ProviderRouter { /// /// 返回按优先级排序的可用供应商列表: /// - 故障转移关闭时:仅返回当前供应商 - /// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置 + /// - 故障转移开启时:仅使用故障转移队列,按队列顺序依次尝试(P1 → P2 → ...) pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { let mut result = Vec::new(); let mut total_providers = 0usize; @@ -47,12 +49,25 @@ impl ProviderRouter { }; if auto_failover_enabled { - // 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序 - let failover_providers = self.db.get_failover_providers(app_type)?; - total_providers = failover_providers.len(); + // 故障转移开启:仅按队列顺序依次尝试(P1 → P2 → ...) + let all_providers = self.db.get_all_providers(app_type)?; - for provider in failover_providers { - let circuit_key = format!("{}:{}", app_type, provider.id); + // 使用 DAO 返回的排序结果,确保和前端展示一致 + let ordered_ids: Vec = self + .db + .get_failover_queue(app_type)? + .into_iter() + .map(|item| item.provider_id) + .collect(); + + total_providers = ordered_ids.len(); + + for provider_id in ordered_ids { + let Some(provider) = all_providers.get(&provider_id).cloned() else { + continue; + }; + + let circuit_key = format!("{app_type}:{}", provider.id); let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; if breaker.is_available().await { @@ -63,7 +78,16 @@ impl ProviderRouter { } } else { // 故障转移关闭:仅使用当前供应商,跳过熔断器检查 - if let Some(current_id) = self.db.get_current_provider(app_type)? { + let current_id = AppType::from_str(app_type) + .ok() + .and_then(|app_enum| { + crate::settings::get_effective_current_provider(&self.db, &app_enum) + .ok() + .flatten() + }) + .or_else(|| self.db.get_current_provider(app_type).ok().flatten()); + + if let Some(current_id) = current_id { if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? { total_providers = 1; result.push(current); @@ -239,9 +263,53 @@ mod tests { use super::*; use crate::database::Database; use serde_json::json; + use serial_test::serial; + use std::env; + use tempfile::TempDir; + + struct TempHome { + #[allow(dead_code)] + dir: TempDir, + original_home: Option, + original_userprofile: Option, + } + + impl TempHome { + fn new() -> Self { + let dir = TempDir::new().expect("failed to create temp home"); + let original_home = env::var("HOME").ok(); + let original_userprofile = env::var("USERPROFILE").ok(); + + env::set_var("HOME", dir.path()); + env::set_var("USERPROFILE", dir.path()); + crate::settings::reload_settings().expect("reload settings"); + + Self { + dir, + original_home, + original_userprofile, + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + match &self.original_home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + + match &self.original_userprofile { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + } + } #[tokio::test] + #[serial] async fn test_provider_router_creation() { + let _home = TempHome::new(); let db = Arc::new(Database::memory().unwrap()); let router = ProviderRouter::new(db); @@ -250,7 +318,9 @@ mod tests { } #[tokio::test] + #[serial] async fn test_failover_disabled_uses_current_provider() { + let _home = TempHome::new(); let db = Arc::new(Database::memory().unwrap()); let provider_a = @@ -271,7 +341,9 @@ mod tests { } #[tokio::test] - async fn test_failover_enabled_uses_queue_order() { + #[serial] + async fn test_failover_enabled_uses_queue_order_ignoring_current() { + let _home = TempHome::new(); let db = Arc::new(Database::memory().unwrap()); // 设置 sort_index 来控制顺序:b=1, a=2 @@ -298,13 +370,45 @@ mod tests { let providers = router.select_providers("claude").await.unwrap(); assert_eq!(providers.len(), 2); - // 按 sort_index 排序:b(1) 在前,a(2) 在后 + // 故障转移开启时:仅按队列顺序选择(忽略当前供应商) assert_eq!(providers[0].id, "b"); assert_eq!(providers[1].id, "a"); } #[tokio::test] + #[serial] + async fn test_failover_enabled_uses_queue_only_even_if_current_not_in_queue() { + let _home = TempHome::new(); + let db = Arc::new(Database::memory().unwrap()); + + let provider_a = + Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None); + let mut provider_b = + Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None); + provider_b.sort_index = Some(1); + + db.save_provider("claude", &provider_a).unwrap(); + db.save_provider("claude", &provider_b).unwrap(); + db.set_current_provider("claude", "a").unwrap(); + + // 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置) + db.add_to_failover_queue("claude", "b").unwrap(); + + let mut config = db.get_proxy_config_for_app("claude").await.unwrap(); + config.auto_failover_enabled = true; + db.update_proxy_config_for_app(config).await.unwrap(); + + let router = ProviderRouter::new(db.clone()); + let providers = router.select_providers("claude").await.unwrap(); + + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].id, "b"); + } + + #[tokio::test] + #[serial] async fn test_select_providers_does_not_consume_half_open_permit() { + let _home = TempHome::new(); let db = Arc::new(Database::memory().unwrap()); db.update_circuit_breaker_config(&CircuitBreakerConfig { @@ -345,7 +449,9 @@ mod tests { } #[tokio::test] + #[serial] async fn test_release_permit_neutral_frees_half_open_slot() { + let _home = TempHome::new(); let db = Arc::new(Database::memory().unwrap()); // 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index bfd13ee2d..1a75083a9 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -190,6 +190,18 @@ impl ProxyServer { status } + /// 更新某个应用类型当前“目标供应商”(用于 UI 展示 active_targets) + /// + /// 注意:这不代表该供应商一定已经处理过请求,而是用于“热切换/启用故障转移立即切 P1” + /// 等场景下,让 UI 能立刻反映最新目标。 + pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) { + let mut current_providers = self.state.current_providers.write().await; + current_providers.insert( + app_type.to_string(), + (provider_id.to_string(), provider_name.to_string()), + ); + } + fn build_router(&self) -> Router { let cors = CorsLayer::new() .allow_origin(Any) diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 823bbfce8..b22043ad2 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1530,7 +1530,10 @@ impl ProxyService { app_type: &str, provider_id: &str, ) -> Result<(), String> { - // 更新数据库中的 is_current 标记 + // 代理模式切换供应商(热切换): + // - 更新 SSOT(数据库 is_current) + // - 同步本地 settings(设备级 current_provider_*) + // - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复) let app_type_enum = AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?; @@ -1538,6 +1541,35 @@ impl ProxyService { .set_current_provider(app_type_enum.as_str(), provider_id) .map_err(|e| format!("更新当前供应商失败: {e}"))?; + // 同步本地 settings(设备级优先) + crate::settings::set_current_provider(&app_type_enum, Some(provider_id)) + .map_err(|e| format!("更新本地当前供应商失败: {e}"))?; + + // 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live + let has_backup = self + .db + .get_live_backup(app_type_enum.as_str()) + .await + .ok() + .flatten() + .is_some(); + let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum); + + if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) { + // 同步更新 Live 备份(用于 stop_with_restore 恢复) + if has_backup || live_taken_over { + self.update_live_backup_from_provider(app_type, &provider) + .await?; + } + + // 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标) + if let Some(server) = self.server.read().await.as_ref() { + server + .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name) + .await; + } + } + log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}"); Ok(()) } @@ -2058,4 +2090,66 @@ model = "gpt-5.1-codex" "should not add ANTHROPIC_AUTH_TOKEN when absent" ); } + + #[tokio::test] + #[serial] + async fn switch_proxy_target_updates_live_backup_when_taken_over() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + let provider_a = Provider::with_id( + "a".to_string(), + "A".to_string(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "a-key" + } + }), + None, + ); + let provider_b = Provider::with_id( + "b".to_string(), + "B".to_string(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "b-key" + } + }), + None, + ); + db.save_provider("claude", &provider_a) + .expect("save provider a"); + db.save_provider("claude", &provider_b) + .expect("save provider b"); + db.set_current_provider("claude", "a") + .expect("set current provider"); + + // 模拟“已接管”状态:存在 Live 备份(内容不重要,会被热切换更新) + db.save_live_backup("claude", "{\"env\":{}}") + .await + .expect("seed live backup"); + + service + .switch_proxy_target("claude", "b") + .await + .expect("switch proxy target"); + + // 断言:本地 settings 的 current provider 已同步 + assert_eq!( + crate::settings::get_current_provider(&AppType::Claude).as_deref(), + Some("b") + ); + + // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复) + let backup = db + .get_live_backup("claude") + .await + .expect("get live backup") + .expect("backup exists"); + let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); + assert_eq!(backup.original_config, expected); + } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 78feec7e5..85b661d8d 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -128,6 +128,12 @@ impl Default for SkillStore { branch: "master".to_string(), enabled: true, }, + SkillRepo { + owner: "JimLiu".to_string(), + name: "baoyu-skills".to_string(), + branch: "main".to_string(), + enabled: true, + }, ], } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 10b85fa0c..a3c151bf9 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -211,6 +211,28 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A if let Some(app_state) = app.try_state::() { let app_type_str = app_type.as_str(); + // 强一致语义:Auto 模式开启后立即切到队列 P1(P1→P2→...) + // 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户陷入无法开启的死锁。 + let mut queue = app_state.db.get_failover_queue(app_type_str)?; + if queue.is_empty() { + let current_id = + crate::settings::get_effective_current_provider(&app_state.db, app_type)?; + let Some(current_id) = current_id else { + return Err(AppError::Message( + "故障转移队列为空,且未设置当前供应商,无法启用 Auto 模式".to_string(), + )); + }; + app_state + .db + .add_to_failover_queue(app_type_str, ¤t_id)?; + queue = app_state.db.get_failover_queue(app_type_str)?; + } + + let p1_provider_id = queue + .first() + .map(|item| item.provider_id.clone()) + .ok_or_else(|| AppError::Message("故障转移队列为空,无法启用 Auto 模式".to_string()))?; + // 真正启用 failover:启动代理服务 + 执行接管 + 开启 auto_failover let proxy_service = &app_state.proxy_service; @@ -238,6 +260,16 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A .db .set_proxy_flags_sync(app_type_str, true, true)?; + // 3.1) 立即切到队列 P1(热切换:不写 Live,仅更新 DB/settings/备份) + if let Err(e) = futures::executor::block_on( + proxy_service.switch_proxy_target(app_type_str, &p1_provider_id), + ) { + log::error!("[Tray] Auto 模式切换到队列 P1 失败: {e}"); + return Err(AppError::Message(format!( + "Auto 模式切换到队列 P1 失败: {e}" + ))); + } + // 4) 更新托盘菜单 if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { if let Some(tray) = app.tray_by_id("main") { @@ -249,7 +281,8 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A let event_data = serde_json::json!({ "appType": app_type_str, "proxyEnabled": true, - "autoFailoverEnabled": true + "autoFailoverEnabled": true, + "providerId": p1_provider_id }); if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) { log::error!("发射 proxy-flags-changed 事件失败: {e}"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5b784f1dd..dc8fd6dd4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Switch", - "version": "3.9.1", + "version": "3.10.0", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index 29be74cb7..498b29568 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,6 @@ import { // Bot, // TODO: Agents 功能开发中,暂时不需要 Book, Wrench, - Server, RefreshCw, Search, Download, @@ -52,6 +51,7 @@ import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { UniversalProviderPanel } from "@/components/universal"; +import { McpIcon } from "@/components/BrandIcons"; import { Button } from "@/components/ui/button"; type View = @@ -943,7 +943,7 @@ function App() { className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" title={t("mcp.title")} > - + diff --git a/src/components/BrandIcons.tsx b/src/components/BrandIcons.tsx index 54ead109d..f6a68c67a 100644 --- a/src/components/BrandIcons.tsx +++ b/src/components/BrandIcons.tsx @@ -46,3 +46,21 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) { /> ); } + +// MCP icon uses inline SVG to support currentColor for hover effects +export function McpIcon({ size = 16, className = "" }: IconProps) { + return ( + + + + + ); +} diff --git a/src/components/ProviderIcon.tsx b/src/components/ProviderIcon.tsx index f76f13ff2..0348d7f94 100644 --- a/src/components/ProviderIcon.tsx +++ b/src/components/ProviderIcon.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { getIcon, hasIcon } from "@/icons/extracted"; +import { getIcon, hasIcon, getIconMetadata } from "@/icons/extracted"; import { cn } from "@/lib/utils"; interface ProviderIconProps { @@ -39,6 +39,19 @@ export const ProviderIcon: React.FC = ({ }; }, [size]); + // 获取有效颜色:优先使用传入的 color,否则从元数据获取 defaultColor + const effectiveColor = useMemo(() => { + if (color) return color; + if (icon) { + const metadata = getIconMetadata(icon); + // 只有当 defaultColor 不是 currentColor 时才使用 + if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") { + return metadata.defaultColor; + } + } + return undefined; + }, [color, icon]); + // 如果有图标,显示图标 if (iconSvg) { return ( @@ -47,7 +60,7 @@ export const ProviderIcon: React.FC = ({ "inline-flex items-center justify-center flex-shrink-0", className, )} - style={{ ...sizeStyle, color }} + style={{ ...sizeStyle, color: effectiveColor }} dangerouslySetInnerHTML={{ __html: iconSvg }} /> ); diff --git a/src/components/proxy/FailoverQueueManager.tsx b/src/components/proxy/FailoverQueueManager.tsx index 5dc7120a2..9d56475dc 100644 --- a/src/components/proxy/FailoverQueueManager.tsx +++ b/src/components/proxy/FailoverQueueManager.tsx @@ -140,7 +140,8 @@ export function FailoverQueueManager({

{t("proxy.failover.autoSwitchDescription", { - defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商", + defaultValue: + "开启后将立即切换到队列 P1,并在请求失败时自动切换到队列中的下一个供应商", })}

diff --git a/src/components/proxy/FailoverToggle.tsx b/src/components/proxy/FailoverToggle.tsx index 9ec4c0222..9e6e23121 100644 --- a/src/components/proxy/FailoverToggle.tsx +++ b/src/components/proxy/FailoverToggle.tsx @@ -39,11 +39,11 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) { const tooltipText = isEnabled ? t("failover.tooltip.enabled", { app: appLabel, - defaultValue: `${appLabel} 故障转移已启用\n自动切换到下一个可用供应商`, + defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`, }) : t("failover.tooltip.disabled", { app: appLabel, - defaultValue: `启用 ${appLabel} 故障转移\n当当前供应商失败时自动切换`, + defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`, }); return ( diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 84e5858b0..eda4692a8 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -50,8 +50,8 @@ export function ProxyPanel() { } }, [globalConfig]); - // 获取所有三个应用类型的故障转移队列(不包含当前供应商) - // 当前供应商始终优先,队列仅用于失败后的备用顺序 + // 获取所有三个应用类型的故障转移队列 + // 启用自动故障转移后,将按队列优先级(P1→P2→...)选择供应商 const { data: claudeQueue = [] } = useFailoverQueue("claude"); const { data: codexQueue = [] } = useFailoverQueue("codex"); const { data: geminiQueue = [] } = useFailoverQueue("gemini"); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 2eaf48089..8d2a66f66 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -462,6 +462,28 @@ export function SettingsPage({ + + +
+ +
+

+ {t("settings.advanced.rectifier.title")} +

+

+ {t("settings.advanced.rectifier.description")} +

+
+
+
+ + + +
+ - - -
- -
-

- {t("settings.advanced.rectifier.title")} -

-

- {t("settings.advanced.rectifier.description")} -

-
-
-
- - - -
- KwaiKAT \ No newline at end of file diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 1545b6e1b..0ac9282d9 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -50,10 +50,14 @@ export const icons: Record = { zeroone: `01.AI`, zhipu: `Zhipu`, openrouter: `OpenRouter`, + rc: `RightCode`, longcat: `LongCat`, modelscope: `ModelScope`, aihubmix: `AiHubMix`, opencode: `OpenCode`, + siliconflow: `SiliconCloud`, + catcoder: `KwaiKAT`, + mcp: `ModelContextProtocol`, }; export const iconList = Object.keys(icons); @@ -65,3 +69,5 @@ export function getIcon(name: string): string { export function hasIcon(name: string): boolean { return name.toLowerCase() in icons; } + +export { getIconMetadata } from "./metadata"; diff --git a/src/icons/extracted/mcp.svg b/src/icons/extracted/mcp.svg new file mode 100644 index 000000000..5cd83a8bf --- /dev/null +++ b/src/icons/extracted/mcp.svg @@ -0,0 +1 @@ +ModelContextProtocol \ No newline at end of file diff --git a/src/icons/extracted/rc.svg b/src/icons/extracted/rc.svg new file mode 100644 index 000000000..b8089148b --- /dev/null +++ b/src/icons/extracted/rc.svg @@ -0,0 +1,6 @@ + + RightCode + + + + diff --git a/src/icons/extracted/siliconflow.svg b/src/icons/extracted/siliconflow.svg new file mode 100644 index 000000000..6b5f6d805 --- /dev/null +++ b/src/icons/extracted/siliconflow.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 10049eb80..a2a8be7c8 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -268,6 +268,21 @@ export function useSetAutoFailoverEnabled() { queryClient.invalidateQueries({ queryKey: ["autoFailoverEnabled", variables.appType], }); + // 启用/关闭故障转移可能触发: + // - 立即切到队列 P1(当前供应商变化) + // - 队列为空时自动把当前供应商加入队列(队列内容变化) + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + queryClient.invalidateQueries({ + queryKey: ["availableProvidersForFailover", variables.appType], + }); + queryClient.invalidateQueries({ + queryKey: ["providers", variables.appType], + }); + queryClient.invalidateQueries({ + queryKey: ["proxyStatus"], + }); }, }); }