From fb9e7dee5021088b58c2cce50b06d34ba070d3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=B0=B8=E5=AE=89?= Date: Tue, 20 Jan 2026 10:31:35 +0800 Subject: [PATCH 1/7] fix(provider): fix stale data shown when reopening edit dialog after save (#654) Add `open` to initialData useMemo dependencies to ensure latest provider data is read each time the dialog opens. --- src/components/providers/EditProviderDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 100d413d2..8a1f767e8 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -128,6 +128,7 @@ export function EditProviderDialog({ iconColor: provider.iconColor, }; }, [ + open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据 provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算 initialSettingsConfig, // 注意:不依赖 provider 的其他字段,防止表单重置 From 76897e2b97686e173873a71e9336f96b759d9cb8 Mon Sep 17 00:00:00 2001 From: kkkman22 <2594387207@qq.com> Date: Tue, 20 Jan 2026 10:32:35 +0800 Subject: [PATCH 2/7] =?UTF-8?q?:=20=E6=B7=BB=E5=8A=A0=20fnm=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=94=AF=E6=8C=81=20(#564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gruby Wang --- src-tauri/src/commands/misc.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 27d9f7544..f7f481570 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -322,6 +322,19 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs")); } + // 添加 fnm 路径支持 + let fnm_base = home.join(".local/state/fnm_multishells"); + if fnm_base.exists() { + if let Ok(entries) = std::fs::read_dir(&fnm_base) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.exists() { + search_paths.push(bin_path); + } + } + } + } + // 扫描 nvm 目录下的所有 node 版本 let nvm_base = home.join(".nvm/versions/node"); if nvm_base.exists() { From 7bb458eecbb94a12341ea66260743ae4e9207808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=B8=E8=9B=8B=E9=BB=84?= <102406915+xxk8@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:33:50 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20ESC=20?= =?UTF-8?q?=E9=94=AE=E5=BF=AB=E6=8D=B7=E8=BF=94=E5=9B=9E=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20(#670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加 ESC 键快捷返回功能 - FullScreenPanel 组件支持 ESC 键关闭 - App.tsx 主页面支持 ESC 键返回主界面 - 优化键盘事件处理,合并多个监听器 - 使用事件捕获阶段避免冲突 - 适用于所有子页面:MCP、设置、Prompts、Skills 等 - 跨平台兼容:macOS、Windows、Linux * perf: 优化 ESC 键处理逻辑 - 使用 useRef 避免闭包陷阱,提升性能 - 修复输入框中按 ESC 会关闭面板的问题 - 检测焦点元素,不干扰输入框的 ESC 行为 - 改进用户体验,避免意外关闭导致数据丢失 * fix: enhance global keyboard shortcuts and improve useModelState sync - App & FullScreenPanel: Use `isTextEditableTarget` to prevent shortcuts (ESC, etc.) from triggering while editing text. - useModelState: Prevent overwriting user input during config synchronization. - App: Add `Cmd/Ctrl + ,` shortcut to open settings. - Add `isTextEditableTarget` utility. --- src/App.tsx | 33 +++++++++++++++--- src/components/common/FullScreenPanel.tsx | 34 +++++++++++++++++++ .../providers/forms/hooks/useModelState.ts | 19 ++++++++++- src/utils/domUtils.ts | 11 ++++++ 4 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 src/utils/domUtils.ts diff --git a/src/App.tsx b/src/App.tsx index 17eecb645..04ec854eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { useProviderActions } from "@/hooks/useProviderActions"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { isTextEditableTarget } from "@/utils/domUtils"; import { cn } from "@/lib/utils"; import { isWindows, isLinux } from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; @@ -291,18 +292,40 @@ function App() { checkEnvOnSwitch(); }, [activeApp]); + // 全局键盘快捷键 + const currentViewRef = useRef(currentView); + useEffect(() => { - const handleGlobalShortcut = (event: KeyboardEvent) => { - if (event.key !== "," || !(event.metaKey || event.ctrlKey)) { + currentViewRef.current = currentView; + }, [currentView]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Cmd/Ctrl + , 打开设置 + if (event.key === "," && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + setCurrentView("settings"); return; } + + // ESC 键返回 + if (event.key !== "Escape" || event.defaultPrevented) return; + + // 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理 + if (document.body.style.overflow === "hidden") return; + + const view = currentViewRef.current; + if (view === "providers") return; + + if (isTextEditableTarget(event.target)) return; + event.preventDefault(); - setCurrentView("settings"); + setCurrentView(view === "skillsDiscovery" ? "skills" : "providers"); }; - window.addEventListener("keydown", handleGlobalShortcut); + window.addEventListener("keydown", handleKeyDown); return () => { - window.removeEventListener("keydown", handleGlobalShortcut); + window.removeEventListener("keydown", handleKeyDown); }; }, []); diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index ecd6e37d3..d4df73ed8 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { isWindows, isLinux } from "@/lib/platform"; +import { isTextEditableTarget } from "@/utils/domUtils"; interface FullScreenPanelProps { isOpen: boolean; @@ -37,6 +38,39 @@ export const FullScreenPanel: React.FC = ({ }; }, [isOpen]); + // ESC 键关闭面板 + const onCloseRef = React.useRef(onClose); + + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + React.useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // 子组件(例如 Radix 的 Select/Dialog/Dropdown)如果已经消费了 ESC,就不要再关闭整个面板 + if (event.defaultPrevented) { + return; + } + + if (isTextEditableTarget(event.target)) { + return; // 让输入框自己处理 ESC(比如清空、失焦等) + } + + event.stopPropagation(); // 阻止事件继续冒泡到 window,避免触发 App.tsx 的全局监听 + onCloseRef.current(); + } + }; + + // 使用冒泡阶段监听,让子组件(如 Radix UI)优先处理 ESC + window.addEventListener("keydown", handleKeyDown, false); + return () => { + window.removeEventListener("keydown", handleKeyDown, false); + }; + }, [isOpen]); + return createPortal( {isOpen && ( diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 07d7122dc..b852a10c4 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; interface UseModelStateProps { settingsConfig: string; @@ -19,12 +19,27 @@ export function useModelState({ const [defaultSonnetModel, setDefaultSonnetModel] = useState(""); const [defaultOpusModel, setDefaultOpusModel] = useState(""); + const isUserEditingRef = useRef(false); + const lastConfigRef = useRef(settingsConfig); + // 初始化读取:读新键;若缺失,按兼容优先级回退 // Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL // Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST // Opus: DEFAULT_OPUS || MODEL || SMALL_FAST // 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时) useEffect(() => { + if (lastConfigRef.current === settingsConfig) { + return; + } + + if (isUserEditingRef.current) { + isUserEditingRef.current = false; + lastConfigRef.current = settingsConfig; + return; + } + + lastConfigRef.current = settingsConfig; + try { const cfg = settingsConfig ? JSON.parse(settingsConfig) : {}; const env = cfg?.env || {}; @@ -71,6 +86,8 @@ export function useModelState({ | "ANTHROPIC_DEFAULT_OPUS_MODEL", value: string, ) => { + isUserEditingRef.current = true; + if (field === "ANTHROPIC_MODEL") setClaudeModel(value); if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value); if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") diff --git a/src/utils/domUtils.ts b/src/utils/domUtils.ts new file mode 100644 index 000000000..e74de4830 --- /dev/null +++ b/src/utils/domUtils.ts @@ -0,0 +1,11 @@ +export function isTextEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + + const tagName = target.tagName; + return ( + tagName === "INPUT" || + tagName === "TEXTAREA" || + tagName === "SELECT" || + target.isContentEditable + ); +} From e7badb1a248e0dbc9cf29694aec0dfd4059ef5dd Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Tue, 20 Jan 2026 21:02:44 +0800 Subject: [PATCH 4/7] Feat/provider individual config (#663) * refactor(ui): simplify UpdateBadge to minimal dot indicator * feat(provider): add individual test and proxy config for providers Add support for provider-specific model test and proxy configurations: - Add ProviderTestConfig and ProviderProxyConfig types in Rust and TypeScript - Create ProviderAdvancedConfig component with collapsible panels - Update stream_check service to merge provider config with global config - Proxy config UI follows global proxy style (single URL input) Provider-level configs stored in meta field, no database schema changes needed. * feat(ui): add failover toggle and improve proxy controls - Add FailoverToggle component with slide animation - Simplify ProxyToggle style to match FailoverToggle - Add usage statistics button when proxy is active - Fix i18n parameter passing for failover messages - Add missing failover translation keys (inQueue, addQueue, priority) - Replace AboutSection icon with app logo * fix(proxy): support system proxy fallback and provider-level proxy config - Remove no_proxy() calls in http_client.rs to allow system proxy fallback - Add get_for_provider() to build HTTP client with provider-specific proxy - Update forwarder.rs and stream_check.rs to use provider proxy config - Fix EditProviderDialog.tsx to include provider.meta in useMemo deps - Add useEffect in ProviderAdvancedConfig.tsx to sync expand state Fixes #636 Fixes #583 * fix(ui): sync toast theme with app setting * feat(settings): add log config management Fixes #612 Fixes #514 * fix(proxy): increase request body size limit to 200MB Fixes #666 * docs(proxy): update timeout config descriptions and defaults Fixes #612 * fix(proxy): filter x-goog-api-key header to prevent duplication * fix(proxy): prevent proxy recursion when system proxy points to localhost Detect if HTTP_PROXY, HTTPS_PROXY, or ALL_PROXY environment variables point to loopback addresses (localhost, 127.0.0.1), and bypass system proxy in such cases to avoid infinite request loops. * fix(i18n): add providerAdvanced i18n keys and fix failover toast parameter - Add providerAdvanced.* i18n keys to en.json, zh.json, and ja.json - Fix failover toggleFailed toast to pass detail parameter - Remove Chinese fallback text from UI for English/Japanese users * fix(tray): restore tray-provider events and enable Auto failover properly - Emit provider-switched event on tray provider click (backward compatibility) - Auto button now: starts proxy, takes over live config, enables failover * fix(log): enable dynamic log level and single file mode - Initialize log at Trace level for dynamic adjustment - Change rotation strategy to KeepSome(1) for single file - Set max file size to 1GB - Delete old log file on startup for clean start * fix(tray): fix clippy uninlined format args warning Use inline format arguments: {app_type_str} instead of {} * fix(provider): allow typing :// in endpoint URL inputs Change input type from "url" to "text" to prevent browser URL validation from blocking :// input. Closes #681 * fix(stream-check): use Gemini native streaming API format - Change endpoint from OpenAI-compatible to native streamGenerateContent - Add alt=sse parameter for SSE format response - Use x-goog-api-key header instead of Bearer token - Convert request body to Gemini contents/parts format * feat(proxy): add request logging for debugging Add debug logs for outgoing requests including URL and body content with byte size, matching the existing response logging format. * fix(log): prevent usize underflow in KeepSome rotation strategy KeepSome(n) internally computes n-2, so n=1 causes underflow. Use KeepSome(2) as the minimum safe value. --- src-tauri/src/commands/failover.rs | 12 +- src-tauri/src/commands/settings.rs | 27 ++ src-tauri/src/database/dao/proxy.rs | 47 ++ src-tauri/src/database/dao/settings.rs | 18 + src-tauri/src/lib.rs | 43 +- src-tauri/src/mcp/opencode.rs | 20 +- src-tauri/src/opencode_config.rs | 5 +- src-tauri/src/panic_hook.rs | 45 -- src-tauri/src/provider.rs | 55 +++ src-tauri/src/proxy/forwarder.rs | 17 +- src-tauri/src/proxy/http_client.rs | 209 +++++++- src-tauri/src/proxy/providers/streaming.rs | 1 - src-tauri/src/proxy/response_processor.rs | 45 +- src-tauri/src/proxy/server.rs | 3 + src-tauri/src/proxy/types.rs | 106 +++- src-tauri/src/services/provider/live.rs | 21 +- src-tauri/src/services/skill.rs | 28 +- src-tauri/src/services/stream_check.rs | 85 +++- src-tauri/src/tray.rs | 199 ++++++-- src/App.tsx | 84 +++- src/assets/icons/app-icon.png | Bin 0 -> 2258 bytes src/components/UpdateBadge.tsx | 67 +-- src/components/common/FullScreenPanel.tsx | 4 +- .../providers/AddProviderDialog.tsx | 8 +- .../providers/EditProviderDialog.tsx | 2 +- src/components/providers/ProviderActions.tsx | 3 +- .../providers/forms/BasicFormFields.tsx | 5 +- .../providers/forms/EndpointSpeedTest.tsx | 2 +- .../providers/forms/OpenCodeFormFields.tsx | 29 +- .../forms/ProviderAdvancedConfig.tsx | 455 ++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 145 ++++-- .../providers/forms/shared/EndpointField.tsx | 2 +- .../proxy/AutoFailoverConfigPanel.tsx | 24 +- src/components/proxy/FailoverToggle.tsx | 76 +++ src/components/proxy/ProxyToggle.tsx | 42 +- src/components/settings/AboutSection.tsx | 4 +- src/components/settings/LogConfigPanel.tsx | 119 +++++ src/components/settings/SettingsPage.tsx | 24 + src/components/ui/sonner.tsx | 9 +- src/i18n/locales/en.json | 53 ++ src/i18n/locales/ja.json | 53 ++ src/i18n/locales/zh.json | 53 ++ src/lib/api/settings.ts | 13 + src/lib/query/failover.ts | 38 +- src/lib/query/mutations.ts | 2 +- src/types.ts | 37 +- 46 files changed, 2008 insertions(+), 331 deletions(-) create mode 100644 src/assets/icons/app-icon.png create mode 100644 src/components/providers/forms/ProviderAdvancedConfig.tsx create mode 100644 src/components/proxy/FailoverToggle.tsx create mode 100644 src/components/settings/LogConfigPanel.tsx diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index 4c1e71d56..8269f5fc3 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -75,6 +75,7 @@ pub async fn get_auto_failover_enabled( /// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用 #[tauri::command] pub async fn set_auto_failover_enabled( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, app_type: String, enabled: bool, @@ -98,5 +99,14 @@ pub async fn set_auto_failover_enabled( .db .update_proxy_config_for_app(config) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // 刷新托盘菜单,确保状态同步 + if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) { + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_menu(Some(new_menu)); + } + } + + Ok(()) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 12cd64fff..2a6ca5039 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -80,3 +80,30 @@ pub async fn set_rectifier_config( .map_err(|e| e.to_string())?; Ok(true) } + +/// 获取日志配置 +#[tauri::command] +pub async fn get_log_config( + state: tauri::State<'_, crate::AppState>, +) -> Result { + state.db.get_log_config().map_err(|e| e.to_string()) +} + +/// 设置日志配置 +#[tauri::command] +pub async fn set_log_config( + state: tauri::State<'_, crate::AppState>, + config: crate::proxy::types::LogConfig, +) -> Result { + state + .db + .set_log_config(&config) + .map_err(|e| e.to_string())?; + log::set_max_level(config.to_level_filter()); + log::info!( + "日志配置已更新: enabled={}, level={}", + config.enabled, + config.level + ); + Ok(true) +} diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 806c14abb..009f24af7 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -614,4 +614,51 @@ impl Database { log::info!("已删除所有 Live 配置备份"); Ok(()) } + + // ==================== Sync Methods for Tray Menu ==================== + + /// 同步获取应用的 proxy 启用状态和自动故障转移状态 + /// + /// 用于托盘菜单构建等同步场景 + /// 返回 (enabled, auto_failover_enabled) + pub fn get_proxy_flags_sync(&self, app_type: &str) -> (bool, bool) { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return (false, false), + }; + + conn.query_row( + "SELECT enabled, auto_failover_enabled FROM proxy_config WHERE app_type = ?1", + [app_type], + |row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)), + ) + .unwrap_or((false, false)) + } + + /// 同步设置应用的 proxy 启用状态和自动故障转移状态 + /// + /// 用于托盘菜单点击等同步场景 + pub fn set_proxy_flags_sync( + &self, + app_type: &str, + enabled: bool, + auto_failover_enabled: bool, + ) -> Result<(), AppError> { + let conn = self + .conn + .lock() + .map_err(|e| AppError::Database(format!("Mutex lock failed: {e}")))?; + + conn.execute( + "UPDATE proxy_config SET enabled = ?2, auto_failover_enabled = ?3, updated_at = datetime('now') WHERE app_type = ?1", + rusqlite::params![ + app_type, + if enabled { 1 } else { 0 }, + if auto_failover_enabled { 1 } else { 0 }, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } } diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 57ecc720a..109e972dd 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -186,4 +186,22 @@ impl Database { .map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?; self.set_setting("rectifier_config", &json) } + + // --- 日志配置 --- + + /// 获取日志配置 + pub fn get_log_config(&self) -> Result { + match self.get_setting("log_config")? { + Some(json) => serde_json::from_str(&json) + .map_err(|e| AppError::Database(format!("解析日志配置失败: {e}"))), + None => Ok(crate::proxy::types::LogConfig::default()), + } + } + + /// 更新日志配置 + pub fn set_log_config(&self, config: &crate::proxy::types::LogConfig) -> Result<(), AppError> { + let json = serde_json::to_string(config) + .map_err(|e| AppError::Database(format!("序列化日志配置失败: {e}")))?; + self.set_setting("log_config", &json) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48abd4f50..06ee64564 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -266,33 +266,41 @@ pub fn run() { log::warn!("初始化 Updater 插件失败,已跳过:{e}"); } } - // 初始化日志(Debug 和 Release 模式都启用 Info 级别) - // 日志同时输出到控制台和文件(/logs/;若设置了覆盖则使用覆盖目录) + // 初始化日志(单文件输出到 /logs/cc-switch.log) { use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy}; let log_dir = panic_hook::get_log_dir(); + // 确保日志目录存在 + if let Err(e) = std::fs::create_dir_all(&log_dir) { + eprintln!("创建日志目录失败: {e}"); + } + + // 启动时删除旧日志文件,实现单文件覆盖效果 + let log_file_path = log_dir.join("cc-switch.log"); + let _ = std::fs::remove_file(&log_file_path); + app.handle().plugin( tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) + // 初始化为 Trace,允许后续通过 log::set_max_level() 动态调整级别 + .level(log::LevelFilter::Trace) .targets([ - // 输出到控制台 Target::new(TargetKind::Stdout), - // 输出到日志文件 Target::new(TargetKind::Folder { path: log_dir, file_name: Some("cc-switch".into()), }), ]) - .rotation_strategy(RotationStrategy::KeepAll) - .max_file_size(5_000_000) // 5MB 单文件上限 + // 单文件模式:启动时删除旧文件,达到大小时轮转 + // 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢 + // KeepSome(2) 是最小安全值,表示不保留轮转文件 + .rotation_strategy(RotationStrategy::KeepSome(2)) + // 单文件大小限制 1GB + .max_file_size(1024 * 1024 * 1024) .timezone_strategy(TimezoneStrategy::UseLocal) .build(), )?; - - // 清理旧日志文件,只保留最近 2 个 - panic_hook::cleanup_old_logs(); } // 初始化数据库 @@ -660,6 +668,19 @@ pub fn run() { // 将同一个实例注入到全局状态,避免重复创建导致的不一致 app.manage(app_state); + // 从数据库加载日志配置并应用 + { + let db = &app.state::().db; + if let Ok(log_config) = db.get_log_config() { + log::set_max_level(log_config.to_level_filter()); + log::info!( + "已加载日志配置: enabled={}, level={}", + log_config.enabled, + log_config.level + ); + } + } + // 初始化 SkillService let skill_service = SkillService::new(); app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); @@ -757,6 +778,8 @@ pub fn run() { commands::save_settings, commands::get_rectifier_config, commands::set_rectifier_config, + commands::get_log_config, + commands::set_log_config, commands::restart_app, commands::check_for_updates, commands::is_portable_mode, diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs index 0b5873fbb..687d5797c 100644 --- a/src-tauri/src/mcp/opencode.rs +++ b/src-tauri/src/mcp/opencode.rs @@ -96,10 +96,7 @@ pub fn convert_to_opencode_format(spec: &Value) -> Result { result.insert("enabled".into(), json!(true)); } _ => { - return Err(AppError::McpValidation(format!( - "Unknown MCP type: {}", - typ - ))); + return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}"))); } } @@ -171,8 +168,7 @@ pub fn convert_from_opencode_format(spec: &Value) -> Result { } _ => { return Err(AppError::McpValidation(format!( - "Unknown OpenCode MCP type: {}", - typ + "Unknown OpenCode MCP type: {typ}" ))); } } @@ -230,16 +226,16 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result s, Err(e) => { - log::warn!("Skip invalid OpenCode MCP server '{}': {}", id, e); - errors.push(format!("{}: {}", id, e)); + log::warn!("Skip invalid OpenCode MCP server '{id}': {e}"); + errors.push(format!("{id}: {e}")); continue; } }; // Validate the converted spec if let Err(e) = validate_server_spec(&unified_spec) { - log::warn!("Skip invalid MCP server '{}' after conversion: {}", id, e); - errors.push(format!("{}: {}", id, e)); + log::warn!("Skip invalid MCP server '{id}' after conversion: {e}"); + errors.push(format!("{id}: {e}")); continue; } @@ -248,7 +244,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result Result Result<(), AppError> { // 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况) write_json_file(&path, config)?; - log::debug!("OpenCode config written to {:?}", path); + log::debug!("OpenCode config written to {path:?}"); Ok(()) } @@ -165,7 +165,7 @@ pub fn get_typed_providers() -> Result, result.insert(id, config); } Err(e) => { - log::warn!("Failed to parse provider '{}': {}", id, e); + log::warn!("Failed to parse provider '{id}': {e}"); // Skip invalid providers but continue } } @@ -219,4 +219,3 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } - diff --git a/src-tauri/src/panic_hook.rs b/src-tauri/src/panic_hook.rs index b157e3bf1..dacbd54e8 100644 --- a/src-tauri/src/panic_hook.rs +++ b/src-tauri/src/panic_hook.rs @@ -12,9 +12,6 @@ use std::sync::OnceLock; /// 应用版本号(从 Cargo.toml 读取) const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// 日志文件保留数量 -const LOG_FILES_TO_KEEP: usize = 2; - static APP_CONFIG_DIR: OnceLock = OnceLock::new(); pub fn init_app_config_dir(dir: PathBuf) { @@ -46,48 +43,6 @@ pub fn get_log_dir() -> PathBuf { get_app_config_dir().join("logs") } -/// 清理旧日志文件,只保留最近 N 个 -/// -/// 在应用启动时调用,确保日志文件不会无限增长。 -pub fn cleanup_old_logs() { - let log_dir = get_log_dir(); - - if !log_dir.exists() { - return; - } - - // 读取目录中的所有 .log 文件 - let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) { - Ok(entries) => entries - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false)) - .collect(), - Err(_) => return, - }; - - // 如果文件数量不超过保留数量,无需清理 - if log_files.len() <= LOG_FILES_TO_KEEP { - return; - } - - // 按修改时间排序(最新的在前) - log_files.sort_by(|a, b| { - let time_a = a.metadata().and_then(|m| m.modified()).ok(); - let time_b = b.metadata().and_then(|m| m.modified()).ok(); - time_b.cmp(&time_a) // 降序 - }); - - // 删除多余的旧文件 - for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) { - if let Err(e) = std::fs::remove_file(&old_file) { - log::warn!("清理旧日志文件失败 {}: {e}", old_file.display()); - } else { - log::info!("已清理旧日志文件: {}", old_file.display()); - } - } -} - /// 安全获取环境信息(不会 panic) fn get_system_info() -> String { let os = std::env::consts::OS; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 5e9ab7328..c1bcf3040 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -142,6 +142,55 @@ pub struct UsageResult { pub error: Option, } +/// 供应商单独的模型测试配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderTestConfig { + /// 是否启用单独配置(false 时使用全局配置) + #[serde(default)] + pub enabled: bool, + /// 测试用的模型名称(覆盖全局配置) + #[serde(rename = "testModel", skip_serializing_if = "Option::is_none")] + pub test_model: Option, + /// 超时时间(秒) + #[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, + /// 测试提示词 + #[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")] + pub test_prompt: Option, + /// 降级阈值(毫秒) + #[serde( + rename = "degradedThresholdMs", + skip_serializing_if = "Option::is_none" + )] + pub degraded_threshold_ms: Option, + /// 最大重试次数 + #[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")] + pub max_retries: Option, +} + +/// 供应商单独的代理配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderProxyConfig { + /// 是否启用单独配置(false 时使用全局/系统代理) + #[serde(default)] + pub enabled: bool, + /// 代理类型:http, https, socks5 + #[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")] + pub proxy_type: Option, + /// 代理主机 + #[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")] + pub proxy_host: Option, + /// 代理端口 + #[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")] + pub proxy_port: Option, + /// 代理用户名(可选) + #[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")] + pub proxy_username: Option, + /// 代理密码(可选) + #[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")] + pub proxy_password: Option, +} + /// 供应商元数据 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderMeta { @@ -172,6 +221,12 @@ pub struct ProviderMeta { /// 每月消费限额(USD) #[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")] pub limit_monthly_usd: Option, + /// 供应商单独的模型测试配置 + #[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")] + pub test_config: Option, + /// 供应商单独的代理配置 + #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")] + pub proxy_config: Option, } impl ProviderManager { diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 7758d4b07..9c0a0fc96 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -28,6 +28,7 @@ const HEADER_BLACKLIST: &[&str] = &[ // 认证类(会被覆盖) "authorization", "x-api-key", + "x-goog-api-key", // 连接类(由 HTTP 客户端管理) "host", "content-length", @@ -585,8 +586,9 @@ impl RequestForwarder { // 默认使用空白名单,过滤所有 _ 前缀字段 let filtered_body = filter_private_params_with_whitelist(request_body, &[]); - // 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置) - let client = super::http_client::get(); + // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 + let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); + let client = super::http_client::get_for_provider(proxy_config); let mut request = client.post(&url); // 只有当 timeout > 0 时才设置请求超时 @@ -662,6 +664,17 @@ impl RequestForwarder { request = request.header("anthropic-version", version_str); } + // 输出请求信息日志 + let tag = adapter.name(); + log::debug!("[{tag}] >>> 请求 URL: {url}"); + if let Ok(body_str) = serde_json::to_string(&filtered_body) { + log::debug!( + "[{tag}] >>> 请求体内容 ({}字节): {}", + body_str.len(), + body_str + ); + } + // 发送请求 let response = request.json(&filtered_body).send().await.map_err(|e| { if e.is_timeout() { diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs index b4be31277..0489e9083 100644 --- a/src-tauri/src/proxy/http_client.rs +++ b/src-tauri/src/proxy/http_client.rs @@ -3,8 +3,11 @@ //! 提供支持全局代理配置的 HTTP 客户端。 //! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。 +use crate::provider::ProviderProxyConfig; use once_cell::sync::OnceCell; use reqwest::Client; +use std::env; +use std::net::IpAddr; use std::sync::RwLock; use std::time::Duration; @@ -155,23 +158,15 @@ pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { /// 获取全局 HTTP 客户端 /// -/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。 +/// 返回配置了代理的客户端(如果已配置代理),否则返回跟随系统代理的客户端。 pub fn get() -> Client { GLOBAL_CLIENT .get() .and_then(|lock| lock.read().ok()) .map(|c| c.clone()) .unwrap_or_else(|| { - // 如果还没初始化,创建一个默认客户端(配置与 build_client 一致) log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback"); - Client::builder() - .timeout(Duration::from_secs(600)) - .connect_timeout(Duration::from_secs(30)) - .pool_max_idle_per_host(10) - .tcp_keepalive(Duration::from_secs(60)) - .no_proxy() - .build() - .unwrap_or_default() + build_client(None).unwrap_or_default() }) } @@ -199,7 +194,7 @@ fn build_client(proxy_url: Option<&str>) -> Result { .pool_max_idle_per_host(10) .tcp_keepalive(Duration::from_secs(60)); - // 有代理地址则使用代理,否则直连 + // 有代理地址则使用代理,否则跟随系统代理 if let Some(url) = proxy_url { // 先验证 URL 格式和 scheme let parsed = url::Url::parse(url) @@ -219,8 +214,16 @@ fn build_client(proxy_url: Option<&str>) -> Result { builder = builder.proxy(proxy); log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url)); } else { - builder = builder.no_proxy(); - log::debug!("[GlobalProxy] Direct connection (no proxy)"); + // 未设置全局代理时,让 reqwest 自动检测系统代理(环境变量) + // 若系统代理指向本机,禁用系统代理避免自环 + if system_proxy_points_to_loopback() { + builder = builder.no_proxy(); + log::warn!( + "[GlobalProxy] System proxy points to localhost, bypassing to avoid recursion" + ); + } else { + log::debug!("[GlobalProxy] Following system proxy (no explicit proxy configured)"); + } } builder @@ -228,6 +231,50 @@ fn build_client(proxy_url: Option<&str>) -> Result { .map_err(|e| format!("Failed to build HTTP client: {e}")) } +fn system_proxy_points_to_loopback() -> bool { + const KEYS: [&str; 6] = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ]; + + KEYS.iter() + .filter_map(|key| env::var(key).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .any(|value| proxy_points_to_loopback(&value)) +} + +fn proxy_points_to_loopback(value: &str) -> bool { + fn host_is_loopback(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) + } + + if let Ok(parsed) = url::Url::parse(value) { + if let Some(host) = parsed.host_str() { + return host_is_loopback(host); + } + return false; + } + + let with_scheme = format!("http://{value}"); + if let Ok(parsed) = url::Url::parse(&with_scheme) { + if let Some(host) = parsed.host_str() { + return host_is_loopback(host); + } + } + + false +} + /// 隐藏 URL 中的敏感信息(用于日志) pub fn mask_url(url: &str) -> String { if let Ok(parsed) = url::Url::parse(url) { @@ -247,9 +294,109 @@ pub fn mask_url(url: &str) -> String { } } +/// 根据供应商单独代理配置构建代理 URL +/// +/// 将 ProviderProxyConfig 转换为代理 URL 字符串 +fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option { + let proxy_type = config.proxy_type.as_deref().unwrap_or("http"); + let host = config.proxy_host.as_deref()?; + let port = config.proxy_port?; + + // 构建带认证的代理 URL + if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) { + if !username.is_empty() && !password.is_empty() { + return Some(format!( + "{proxy_type}://{username}:{password}@{host}:{port}" + )); + } + } + + Some(format!("{proxy_type}://{host}:{port}")) +} + +/// 根据供应商单独代理配置构建 HTTP 客户端 +/// +/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端; +/// 否则返回 None,调用方应使用全局客户端。 +/// +/// # Arguments +/// * `proxy_config` - 供应商的代理配置 +/// +/// # Returns +/// 如果配置有效则返回 Some(Client),否则返回 None +pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option { + let config = proxy_config.filter(|c| c.enabled)?; + + let proxy_url = build_proxy_url_from_config(config)?; + + log::debug!( + "[ProviderProxy] Building client with proxy: {}", + mask_url(&proxy_url) + ); + + // 构建带代理的客户端 + let proxy = match reqwest::Proxy::all(&proxy_url) { + Ok(p) => p, + Err(e) => { + log::error!( + "[ProviderProxy] Failed to create proxy from '{}': {}", + mask_url(&proxy_url), + e + ); + return None; + } + }; + + match Client::builder() + .timeout(Duration::from_secs(600)) + .connect_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(60)) + .proxy(proxy) + .build() + { + Ok(client) => { + log::info!( + "[ProviderProxy] Client built with proxy: {}", + mask_url(&proxy_url) + ); + Some(client) + } + Err(e) => { + log::error!("[ProviderProxy] Failed to build client: {e}"); + None + } + } +} + +/// 获取供应商专用的 HTTP 客户端 +/// +/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。 +/// +/// # Arguments +/// * `proxy_config` - 供应商的代理配置 +/// +/// # Returns +/// 返回适合该供应商的 HTTP 客户端 +pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client { + // 优先使用供应商单独代理 + if let Some(client) = build_client_for_provider(proxy_config) { + return client; + } + + // 回退到全局客户端 + get() +} + #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } #[test] fn test_mask_url() { @@ -298,4 +445,40 @@ mod tests { let result = build_client(Some("invalid-scheme://127.0.0.1:7890")); assert!(result.is_err(), "Should reject invalid proxy scheme"); } + + #[test] + fn test_proxy_points_to_loopback() { + assert!(proxy_points_to_loopback("http://127.0.0.1:7890")); + assert!(proxy_points_to_loopback("socks5://localhost:1080")); + assert!(proxy_points_to_loopback("127.0.0.1:7890")); + assert!(!proxy_points_to_loopback("http://192.168.1.10:7890")); + } + + #[test] + fn test_system_proxy_points_to_loopback() { + let _guard = env_lock().lock().unwrap(); + + let keys = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ]; + + for key in &keys { + std::env::remove_var(key); + } + + std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890"); + assert!(system_proxy_points_to_loopback()); + + std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890"); + assert!(!system_proxy_points_to_loopback()); + + for key in &keys { + std::env::remove_var(key); + } + } } diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index 30e261301..a3fd590c2 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -104,7 +104,6 @@ pub fn create_anthropic_sse_stream( } if let Ok(chunk) = serde_json::from_str::(data) { - // 仅在 DEBUG 级别简短记录 SSE 事件 log::debug!("[Claude/OpenRouter] <<< SSE chunk received"); if message_id.is_none() { diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index f6a2eeb8a..b7af43524 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -12,6 +12,7 @@ use super::{ use axum::response::{IntoResponse, Response}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; +use reqwest::header::HeaderMap; use rust_decimal::Decimal; use serde_json::Value; use std::{ @@ -47,6 +48,12 @@ pub async fn handle_streaming( parser_config: &UsageParserConfig, ) -> Response { let status = response.status(); + log::debug!( + "[{}] 已接收上游流式响应: status={}, headers={}", + ctx.tag, + status.as_u16(), + format_headers(response.headers()) + ); let mut builder = axum::response::Response::builder().status(status); // 复制响应头 @@ -94,6 +101,19 @@ pub async fn handle_non_streaming( log::error!("[{}] 读取响应失败: {e}", ctx.tag); ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) })?; + log::debug!( + "[{}] 已接收上游响应体: status={}, bytes={}, headers={}", + ctx.tag, + status.as_u16(), + body_bytes.len(), + format_headers(&response_headers) + ); + + log::debug!( + "[{}] 上游响应体内容: {}", + ctx.tag, + String::from_utf8_lossy(&body_bytes) + ); // 解析并记录使用量 if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { @@ -470,6 +490,12 @@ pub fn create_logged_passthrough_stream( match chunk_result { Some(Ok(bytes)) => { + if is_first_chunk { + log::debug!( + "[{tag}] 已接收上游流式首包: bytes={}", + bytes.len() + ); + } is_first_chunk = false; let text = String::from_utf8_lossy(&bytes); buffer.push_str(&text); @@ -488,13 +514,9 @@ pub fn create_logged_passthrough_stream( if let Some(c) = &collector { c.push(json_value.clone()).await; } - log::debug!( - "[{}] <<< SSE 事件: {}", - tag, - data.chars().take(100).collect::() - ); + log::debug!("[{tag}] <<< SSE 事件: {data}"); } else { - log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::()); + log::debug!("[{tag}] <<< SSE 数据: {data}"); } } else { log::debug!("[{tag}] <<< SSE: [DONE]"); @@ -523,3 +545,14 @@ pub fn create_logged_passthrough_stream( } } } + +fn format_headers(headers: &HeaderMap) -> String { + headers + .iter() + .map(|(key, value)| { + let value_str = value.to_str().unwrap_or(""); + format!("{key}={value_str}") + }) + .collect::>() + .join(", ") +} diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 8cd742182..bfd13ee2d 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -8,6 +8,7 @@ use super::{ }; use crate::database::Database; use axum::{ + extract::DefaultBodyLimit, routing::{get, post}, Router, }; @@ -224,6 +225,8 @@ impl ProxyServer { // Gemini API (支持带前缀和不带前缀) .route("/v1beta/*path", post(handlers::handle_gemini)) .route("/gemini/v1beta/*path", post(handlers::handle_gemini)) + // 提高默认请求体大小限制(避免 413 Payload Too Large) + .layer(DefaultBodyLimit::max(200 * 1024 * 1024)) .layer(cors) .with_state(self.state.clone()) } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index f49963927..91943c546 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -16,13 +16,13 @@ pub struct ProxyConfig { /// 是否正在接管 Live 配置 #[serde(default)] pub live_takeover_active: bool, - /// 流式首字超时(秒)- 等待首个数据块的最大时间 + /// 流式首字超时(秒)- 等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒 #[serde(default = "default_streaming_first_byte_timeout")] pub streaming_first_byte_timeout: u64, - /// 流式静默超时(秒)- 两个数据块之间的最大间隔 + /// 流式静默超时(秒)- 两个数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住) #[serde(default = "default_streaming_idle_timeout")] pub streaming_idle_timeout: u64, - /// 非流式总超时(秒)- 非流式请求的总超时时间 + /// 非流式总超时(秒)- 非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟) #[serde(default = "default_non_streaming_timeout")] pub non_streaming_timeout: u64, } @@ -221,6 +221,50 @@ fn default_true() -> bool { true } +fn default_log_level() -> String { + "info".to_string() +} + +/// 日志配置 +/// +/// 存储在 settings 表的 log_config 字段中(JSON 格式) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogConfig { + /// 总开关:是否启用日志 + #[serde(default = "default_true")] + pub enabled: bool, + /// 日志级别: error, warn, info, debug, trace + #[serde(default = "default_log_level")] + pub level: String, +} + +impl Default for LogConfig { + fn default() -> Self { + Self { + enabled: true, + level: "info".to_string(), + } + } +} + +impl LogConfig { + /// 将配置转换为 log::LevelFilter + pub fn to_level_filter(&self) -> log::LevelFilter { + if !self.enabled { + return log::LevelFilter::Off; + } + match self.level.to_lowercase().as_str() { + "error" => log::LevelFilter::Error, + "warn" => log::LevelFilter::Warn, + "info" => log::LevelFilter::Info, + "debug" => log::LevelFilter::Debug, + "trace" => log::LevelFilter::Trace, + _ => log::LevelFilter::Info, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -254,4 +298,60 @@ mod tests { assert!(!config.enabled); assert!(!config.request_thinking_signature); } + + #[test] + fn test_log_config_default() { + let config = LogConfig::default(); + assert!(config.enabled); + assert_eq!(config.level, "info"); + } + + #[test] + fn test_log_config_serde_default() { + let json = "{}"; + let config: LogConfig = serde_json::from_str(json).unwrap(); + assert!(config.enabled); + assert_eq!(config.level, "info"); + } + + #[test] + fn test_log_config_to_level_filter() { + let mut config = LogConfig::default(); + + config.level = "error".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Error); + + config.level = "warn".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Warn); + + config.level = "info".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Info); + + config.level = "debug".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Debug); + + config.level = "trace".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Trace); + + // 无效级别回退到 info + config.level = "invalid".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Info); + + // 禁用时返回 Off + config.enabled = false; + config.level = "debug".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Off); + } + + #[test] + fn test_log_config_serde_roundtrip() { + let config = LogConfig { + enabled: true, + level: "debug".to_string(), + }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: LogConfig = serde_json::from_str(&json).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.level, "debug"); + } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 9585d3fb3..54ff9d61a 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -500,18 +500,12 @@ pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<() // Check if OpenCode config directory exists if !opencode_config::get_opencode_dir().exists() { - log::debug!( - "OpenCode config directory doesn't exist, skipping removal of '{}'", - provider_id - ); + log::debug!("OpenCode config directory doesn't exist, skipping removal of '{provider_id}'"); return Ok(()); } opencode_config::remove_provider(provider_id)?; - log::info!( - "OpenCode provider '{}' removed from live config", - provider_id - ); + log::info!("OpenCode provider '{provider_id}' removed from live config"); Ok(()) } @@ -535,10 +529,7 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result Result v, Err(e) => { - log::warn!("Failed to serialize OpenCode provider '{}': {}", id, e); + log::warn!("Failed to serialize OpenCode provider '{id}': {e}"); continue; } }; @@ -561,12 +552,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result = HashMap::new(); - for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { let app_dir = match Self::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, @@ -464,7 +474,12 @@ impl SkillService { let mut source_path: Option = None; let mut found_in: Vec = Vec::new(); - for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { if let Ok(app_dir) = Self::get_app_skills_dir(&app) { let skill_path = app_dir.join(&dir_name); if skill_path.exists() { @@ -985,7 +1000,12 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { let mut discovered: HashMap = HashMap::new(); // 扫描各应用目录 - for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { let app_dir = match SkillService::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 3a77fad07..38ca76523 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -78,15 +78,19 @@ pub struct StreamCheckService; impl StreamCheckService { /// 执行流式健康检查(带重试) + /// + /// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置 pub async fn check_with_retry( app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, ) -> Result { + // 合并供应商单独配置和全局配置 + let effective_config = Self::merge_provider_config(provider, config); let mut last_result = None; - for attempt in 0..=config.max_retries { - let result = Self::check_once(app_type, provider, config).await; + for attempt in 0..=effective_config.max_retries { + let result = Self::check_once(app_type, provider, &effective_config).await; match &result { Ok(r) if r.success => { @@ -97,7 +101,7 @@ impl StreamCheckService { } Ok(r) => { // 失败但非异常,判断是否重试 - if Self::should_retry(&r.message) && attempt < config.max_retries { + if Self::should_retry(&r.message) && attempt < effective_config.max_retries { last_result = Some(r.clone()); continue; } @@ -107,7 +111,8 @@ impl StreamCheckService { }); } Err(e) => { - if Self::should_retry(&e.to_string()) && attempt < config.max_retries { + if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries + { continue; } return Err(AppError::Message(e.to_string())); @@ -123,10 +128,51 @@ impl StreamCheckService { http_status: None, model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), - retry_count: config.max_retries, + retry_count: effective_config.max_retries, })) } + /// 合并供应商单独配置和全局配置 + /// + /// 如果供应商配置了 meta.testConfig 且 enabled 为 true,则使用供应商配置覆盖全局配置 + fn merge_provider_config( + provider: &Provider, + global_config: &StreamCheckConfig, + ) -> StreamCheckConfig { + let test_config = provider + .meta + .as_ref() + .and_then(|m| m.test_config.as_ref()) + .filter(|tc| tc.enabled); + + match test_config { + Some(tc) => StreamCheckConfig { + timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs), + max_retries: tc.max_retries.unwrap_or(global_config.max_retries), + degraded_threshold_ms: tc + .degraded_threshold_ms + .unwrap_or(global_config.degraded_threshold_ms), + claude_model: tc + .test_model + .clone() + .unwrap_or_else(|| global_config.claude_model.clone()), + codex_model: tc + .test_model + .clone() + .unwrap_or_else(|| global_config.codex_model.clone()), + gemini_model: tc + .test_model + .clone() + .unwrap_or_else(|| global_config.gemini_model.clone()), + test_prompt: tc + .test_prompt + .clone() + .unwrap_or_else(|| global_config.test_prompt.clone()), + }, + None => global_config.clone(), + } + } + /// 单次流式检查 async fn check_once( app_type: &AppType, @@ -144,8 +190,9 @@ impl StreamCheckService { .extract_auth(provider) .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; - // 使用全局 HTTP 客户端(已包含代理配置) - let client = crate::proxy::http_client::get(); + // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 + let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); + let client = crate::proxy::http_client::get_for_provider(proxy_config); let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); @@ -389,6 +436,8 @@ impl StreamCheckService { } /// Gemini 流式检查 + /// + /// 使用 Gemini 原生 API 格式 (streamGenerateContent) async fn check_gemini_stream( client: &Client, base_url: &str, @@ -398,20 +447,28 @@ impl StreamCheckService { timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); - let url = format!("{base}/v1/chat/completions"); + // Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse + // 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta + // alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组 + let url = if base.contains("/v1beta") || base.contains("/v1/") { + format!("{base}/models/{model}:streamGenerateContent?alt=sse") + } else { + format!("{base}/v1beta/models/{model}:streamGenerateContent?alt=sse") + }; + // Gemini 原生请求体格式 let body = json!({ - "model": model, - "messages": [{ "role": "user", "content": test_prompt }], - "max_tokens": 1, - "temperature": 0, - "stream": true + "contents": [{ + "role": "user", + "parts": [{ "text": test_prompt }] + }] }); let response = client .post(&url) - .header("Authorization", format!("Bearer {}", auth.api_key)) + .header("x-goog-api-key", &auth.api_key) .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") .timeout(timeout) .json(&body) .send() diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 054574edc..715051f6f 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -15,6 +15,7 @@ pub struct TrayTexts { pub show_main: &'static str, pub no_provider_hint: &'static str, pub quit: &'static str, + pub auto_label: &'static str, } impl TrayTexts { @@ -24,17 +25,20 @@ impl TrayTexts { show_main: "Open main window", no_provider_hint: " (No providers yet, please add them from the main window)", quit: "Quit", + auto_label: "Auto (Failover)", }, "ja" => Self { show_main: "メインウィンドウを開く", no_provider_hint: " (プロバイダーがまだありません。メイン画面から追加してください)", quit: "終了", + auto_label: "自動 (フェイルオーバー)", }, _ => Self { show_main: "打开主界面", no_provider_hint: " (无供应商,请在主界面添加)", quit: "退出", + auto_label: "自动 (故障转移)", }, } } @@ -50,6 +54,9 @@ pub struct TrayAppSection { pub log_name: &'static str, } +/// Auto 菜单项后缀 +pub const AUTO_SUFFIX: &str = "auto"; + pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ TrayAppSection { app_type: AppType::Claude, @@ -84,6 +91,7 @@ fn append_provider_section<'a>( manager: Option<&crate::provider::ProviderManager>, section: &TrayAppSection, tray_texts: &TrayTexts, + app_state: &AppState, ) -> Result>, AppError> { let Some(manager) = manager else { return Ok(menu_builder); @@ -111,6 +119,23 @@ fn append_provider_section<'a>( return Ok(menu_builder.item(&empty_hint)); } + // 获取 proxy 状态,决定 Auto 是否选中 + let (proxy_enabled, auto_failover) = + app_state.db.get_proxy_flags_sync(section.app_type.as_str()); + let auto_mode = proxy_enabled && auto_failover; + + // 添加 Auto 菜单项(始终显示在供应商列表前) + let auto_item = CheckMenuItem::with_id( + app, + format!("{}{}", section.prefix, AUTO_SUFFIX), + tray_texts.auto_label, + true, + auto_mode, + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?; + menu_builder = menu_builder.item(&auto_item); + let mut sorted_providers: Vec<_> = manager.providers.iter().collect(); sorted_providers.sort_by(|(_, a), (_, b)| { match (a.sort_index, b.sort_index) { @@ -131,7 +156,8 @@ fn append_provider_section<'a>( }); for (id, provider) in sorted_providers { - let is_current = manager.current == *id; + // Auto 模式下所有供应商都不选中 + let is_current = !auto_mode && manager.current == *id; let item = CheckMenuItem::with_id( app, format!("{}{}", section.prefix, id), @@ -150,13 +176,27 @@ fn append_provider_section<'a>( /// 处理供应商托盘事件 pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { for section in TRAY_SECTIONS.iter() { - if let Some(provider_id) = event_id.strip_prefix(section.prefix) { - log::info!("切换到{}供应商: {provider_id}", section.log_name); + if let Some(suffix) = event_id.strip_prefix(section.prefix) { + // 处理 Auto 点击 + if suffix == AUTO_SUFFIX { + log::info!("切换到{} Auto模式", section.log_name); + let app_handle = app.clone(); + let app_type = section.app_type.clone(); + tauri::async_runtime::spawn_blocking(move || { + if let Err(e) = handle_auto_click(&app_handle, &app_type) { + log::error!("切换{}Auto模式失败: {e}", section.log_name); + } + }); + return true; + } + + // 处理供应商点击 + log::info!("切换到{}供应商: {suffix}", section.log_name); let app_handle = app.clone(); - let provider_id = provider_id.to_string(); + let provider_id = suffix.to_string(); let app_type = section.app_type.clone(); tauri::async_runtime::spawn_blocking(move || { - if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) { + if let Err(e) = handle_provider_click(&app_handle, &app_type, &provider_id) { log::error!("切换{}供应商失败: {e}", section.log_name); } }); @@ -166,6 +206,110 @@ pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> boo false } +/// 处理 Auto 点击:启用 proxy 和 auto_failover +fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), AppError> { + if let Some(app_state) = app.try_state::() { + let app_type_str = app_type.as_str(); + + // 真正启用 failover:启动代理服务 + 执行接管 + 开启 auto_failover + let proxy_service = &app_state.proxy_service; + + // 1) 确保代理服务运行(会自动设置 proxy_enabled = true) + let is_running = futures::executor::block_on(proxy_service.is_running()); + if !is_running { + log::info!("[Tray] Auto 模式:启动代理服务"); + if let Err(e) = futures::executor::block_on(proxy_service.start()) { + log::error!("[Tray] 启动代理服务失败: {e}"); + return Err(AppError::Message(format!("启动代理服务失败: {e}"))); + } + } + + // 2) 执行 Live 配置接管(确保该 app 被代理接管) + log::info!("[Tray] Auto 模式:对 {app_type_str} 执行接管"); + if let Err(e) = + futures::executor::block_on(proxy_service.set_takeover_for_app(app_type_str, true)) + { + log::error!("[Tray] 执行接管失败: {e}"); + return Err(AppError::Message(format!("执行接管失败: {e}"))); + } + + // 3) 设置 auto_failover_enabled = true + app_state + .db + .set_proxy_flags_sync(app_type_str, true, true)?; + + // 4) 更新托盘菜单 + if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_menu(Some(new_menu)); + } + } + + // 5) 发射事件到前端 + let event_data = serde_json::json!({ + "appType": app_type_str, + "proxyEnabled": true, + "autoFailoverEnabled": true + }); + if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) { + log::error!("发射 proxy-flags-changed 事件失败: {e}"); + } + // 发射 provider-switched 事件(保持向后兼容,Auto 切换也算一种切换) + if let Err(e) = app.emit("provider-switched", event_data) { + log::error!("发射 provider-switched 事件失败: {e}"); + } + } + Ok(()) +} + +/// 处理供应商点击:关闭 auto_failover + 切换供应商 +fn handle_provider_click( + app: &tauri::AppHandle, + app_type: &AppType, + provider_id: &str, +) -> Result<(), AppError> { + if let Some(app_state) = app.try_state::() { + let app_type_str = app_type.as_str(); + + // 获取当前 proxy 状态,保持 enabled 不变,只关闭 auto_failover + let (proxy_enabled, _) = app_state.db.get_proxy_flags_sync(app_type_str); + app_state + .db + .set_proxy_flags_sync(app_type_str, proxy_enabled, false)?; + + // 切换供应商 + crate::commands::switch_provider( + app_state.clone(), + app_type_str.to_string(), + provider_id.to_string(), + ) + .map_err(AppError::Message)?; + + // 更新托盘菜单 + if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_menu(Some(new_menu)); + } + } + + // 发射事件到前端 + let event_data = serde_json::json!({ + "appType": app_type_str, + "proxyEnabled": proxy_enabled, + "autoFailoverEnabled": false, + "providerId": provider_id + }); + if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) { + log::error!("发射 proxy-flags-changed 事件失败: {e}"); + } + // 发射 provider-switched 事件(保持向后兼容) + if let Err(e) = app.emit("provider-switched", event_data) { + log::error!("发射 provider-switched 事件失败: {e}"); + } + } + Ok(()) +} + /// 创建动态托盘菜单 pub fn create_tray_menu( app: &tauri::AppHandle, @@ -197,8 +341,14 @@ pub fn create_tray_menu( current: current_id, }; - menu_builder = - append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?; + menu_builder = append_provider_section( + app, + menu_builder, + Some(&manager), + section, + &tray_texts, + app_state, + )?; } // 分隔符和退出菜单 @@ -263,38 +413,3 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { } } } - -/// 内部切换供应商函数 -pub fn switch_provider_internal( - app: &tauri::AppHandle, - app_type: AppType, - provider_id: String, -) -> Result<(), AppError> { - if let Some(app_state) = app.try_state::() { - // 在使用前先保存需要的值 - let app_type_str = app_type.as_str().to_string(); - let provider_id_clone = provider_id.clone(); - - crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id) - .map_err(AppError::Message)?; - - // 切换成功后重新创建托盘菜单 - if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { - if let Some(tray) = app.tray_by_id("main") { - if let Err(e) = tray.set_menu(Some(new_menu)) { - log::error!("更新托盘菜单失败: {e}"); - } - } - } - - // 发射事件到前端,通知供应商已切换 - let event_data = serde_json::json!({ - "appType": app_type_str, - "providerId": provider_id_clone - }); - if let Err(e) = app.emit("provider-switched", event_data) { - log::error!("发射供应商切换事件失败: {e}"); - } - } - Ok(()) -} diff --git a/src/App.tsx b/src/App.tsx index 04ec854eb..20c31968d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { RefreshCw, Search, Download, + BarChart2, } from "lucide-react"; import type { Provider } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -42,6 +43,7 @@ import { SettingsPage } from "@/components/settings/SettingsPage"; import { UpdateBadge } from "@/components/UpdateBadge"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import { ProxyToggle } from "@/components/proxy/ProxyToggle"; +import { FailoverToggle } from "@/components/proxy/FailoverToggle"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; @@ -376,7 +378,10 @@ function App() { }; // Generate a unique provider key for OpenCode duplication - const generateUniqueOpencodeKey = (originalKey: string, existingKeys: string[]): string => { + const generateUniqueOpencodeKey = ( + originalKey: string, + existingKeys: string[], + ): string => { const baseKey = `${originalKey}-copy`; if (!existingKeys.includes(baseKey)) { @@ -397,7 +402,9 @@ function App() { const newSortIndex = provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; - const duplicatedProvider: Omit & { providerKey?: string } = { + const duplicatedProvider: Omit & { + providerKey?: string; + } = { name: `${provider.name} copy`, settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 websiteUrl: provider.websiteUrl, @@ -413,7 +420,10 @@ function App() { // OpenCode: generate unique provider key (used as ID) if (activeApp === "opencode") { const existingKeys = Object.keys(providers); - duplicatedProvider.providerKey = generateUniqueOpencodeKey(provider.id, existingKeys); + duplicatedProvider.providerKey = generateUniqueOpencodeKey( + provider.id, + existingKeys, + ); } // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 @@ -522,7 +532,12 @@ function App() { /> ); case "skillsDiscovery": - return ; + return ( + + ); case "mcp": return (
@@ -695,8 +710,8 @@ function App() {
) : ( - <> -
+
+
CC Switch + { + setSettingsDefaultTab("about"); + setCurrentView("settings"); + }} + className="absolute -top-4 -right-4" + /> +
+ + {isCurrentAppTakeoverActive && ( -
- { - setSettingsDefaultTab("about"); - setCurrentView("settings"); - }} - /> - + )} +
)}
{currentView === "prompts" && ( @@ -817,7 +849,19 @@ function App() { {currentView === "providers" && ( <> {activeApp !== "opencode" && ( - + <> + +
+ +
+ )} diff --git a/src/assets/icons/app-icon.png b/src/assets/icons/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6ea9e37ce4d55a30388036a3a0c9337159ce2387 GIT binary patch literal 2258 zcmV;@2rc)CP)Y3l$MT12R=jAJX5Iyz!IUIR|2 z=%|I(3+qTJBFBtYthBTejwT#Qfy4kg2;|x%yV>mdyWacty-ij*8jwyq{iDC#o&CM< z`@Qe^`5r$G{Esj0|6T!jI}bAq1Bd?@crBfN0Yh)$b$hqE*xOXti2qY9V#3MS_=-+% z464w7#RKk#R{d(pZ??9lOemOPkPAY{Qh@>oxetCg>!aGI<~6!=9d5$_2K5Al0CFtD z{aXse{LHc(VH5L`H8nMR;-UrX9WGbSl^gJirbKH})lYuBGkdywZqz@di&iTa@OU~&h-`vMvif|1?$zxdcix>Uxcw^9 zBnh#+MT6!91i~m9c0X~dxTQyKkMouUhLcr8r(mS1dV>c>iVC1Ople-G<<`SB@2kYp z%(BusNY#KK3W+R)BGM5y3vA-I*xn3LiPECLQm9fldjip(s-K^j+~!d(S4~Q+SaFwg z`Ex6?p53%2dvjw)WZNfkjF|+$j`!)rrXmMrIw{tT13}B|#5S#2g zIC6O4aMk+8JE#JPgoL}g@K6@7EG>U?)y%w=-ms!-I#y*J$qF(QpLg&Q?mj4EBMXEv z%M7uSk)wDCiVcWH44@EGG=NrAj9Y7UVxR#TXqpaqG@Jt7_K0`y5#OHSj4+IAx*;}( z3Da^MGX;(V2_hC42?&ImK(}%n2^og20VC8wjiuTW0T3tzZOjO!V#UUQY$#yhP!#EA zD<=%9^5q@Jy59cqc>jkDEpk)5dC@9)4F+iKrVQHpgXc;1BzE~DU4s!N5K%N)Ll_f* zd{`qWAOdybeuY$M;`$L3#~LC1kT5bt2^J!i18PJtw4jNRur(<+DXE}ftaSop41C2G zy%JA(=K8i8M`!%92&~D(vt|`x`Ti*x_dmO+2`{p`Z$iEs;# zRFWnPqmMkw5g3xSfk=w8EM}mfD8D=2{*I1V_Kt$&yVLBJtOQ9AS>iRG;8oSJGHM;T zzQ<2YPb_?OS^4&vuH@+f1p$*Tg9bj?Xc3%XeyBP2>}TdY?AARnNecr7Lu9cjY@g=u zc=Vm7@Bih@P))AWoKuu$E1sI|m~q7yZr^^a{jInR_GZE#pIxxw?Ufb#GKd8_*-?T) z|6VqGV0*@F+c?E*OqIJJ0V00i>4xWgYI8OASg|1Tnha5T8k4m8J1GyD1w-0>bYRcL zzR-owFZ3O1xZ*#3({4!I9;0npQuf-GrPF?ih;L024^Eu#{QP?ddmsHI)ota8v-4DM z%zb`F#GE98+8}7%bg1PShnXiC#bAH02}gn|dSdmsCy%^5>2QWaNTqzq@FLu59*Bj- zZWhKc0!N2D{EPkd8|VJ^_&2%v%hoxpCYy=O7^yfy&l-L)fn!h@+*4q=^R>FptvhSm zb|u>+UR8J`dy?4bprTbyu~nVgR?^@~6SGPDRoV|mor+04pk)I*xwroD3!XspXJ7Ol zINjkpIpg6qTXQFlFAtKTL+WllYgc_)SP<+Hta-DHUTb-B&^&d5qk7eQbq|z`b4<%i zNpuDMCLKyPXtIVtYPvKw*~UBDdX$b>#TqU?Y+BqJZG19J3~6+-%mEao2|C?7F#@EQJG~9<*{=6(fgvLj*23~;XymQ z=fKde`+jzIerKvL#{ib&Ilk<{m9JBUIZ*_Mf4yy43X1UN1as{4 zpH6k|H+ou{9D)~A7Yc3~!E;@K#ArK54$(g_4%H`e~84obKU;a z0aXouS{FD#yIrqW?G8jxFjnJ7(n?JoBW^ezkibp!?z_eYn5GP8g%|zKz$iFNI&flv9 gV`L4lUVq)|pSM;gdKoTVk^lez07*qoM6N<$fk literal 0 HcmV?d00001 diff --git a/src/components/UpdateBadge.tsx b/src/components/UpdateBadge.tsx index 3e9efea25..cb1faac41 100644 --- a/src/components/UpdateBadge.tsx +++ b/src/components/UpdateBadge.tsx @@ -1,6 +1,6 @@ -import { X, Download } from "lucide-react"; import { useUpdate } from "@/contexts/UpdateContext"; import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; interface UpdateBadgeProps { className?: string; @@ -8,56 +8,39 @@ interface UpdateBadgeProps { } export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) { - const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate(); + const { hasUpdate, updateInfo } = useUpdate(); const { t } = useTranslation(); + const isActive = hasUpdate && updateInfo; + const title = isActive + ? t("settings.updateAvailable", { + version: updateInfo?.availableVersion ?? "", + }) + : t("settings.checkForUpdates"); - // 如果没有更新或已关闭,不显示 - if (!hasUpdate || isDismissed || !updateInfo) { + if (!isActive) { return null; } return ( -
{ - if (!onClick) return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); - } - }} > - - - {t("settings.updateBadge")} - - -
+ + ); } diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index d4df73ed8..49083ab21 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -128,9 +128,7 @@ export const FullScreenPanel: React.FC = ({ {/* Content */}
-
- {children} -
+
{children}
{/* Footer */} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index c3ff9e578..14b7ec086 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -24,7 +24,9 @@ interface AddProviderDialogProps { open: boolean; onOpenChange: (open: boolean) => void; appId: AppId; - onSubmit: (provider: Omit & { providerKey?: string }) => Promise | void; + onSubmit: ( + provider: Omit & { providerKey?: string }, + ) => Promise | void; } export function AddProviderDialog({ @@ -186,7 +188,9 @@ export function AddProviderDialog({ } } else if (appId === "opencode") { // OpenCode uses options.baseURL - const options = parsedConfig.options as Record | undefined; + const options = parsedConfig.options as + | Record + | undefined; if (options?.baseURL) { addUrl(options.baseURL); } diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 8a1f767e8..672848134 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -130,8 +130,8 @@ export function EditProviderDialog({ }, [ open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据 provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算 + provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig initialSettingsConfig, - // 注意:不依赖 provider 的其他字段,防止表单重置 ]); const handleSubmit = useCallback( diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index aa5b75187..6b4057a8f 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -64,7 +64,8 @@ export function ProviderActions({ const isOpenCodeMode = appId === "opencode"; // 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移) - const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover; + const isFailoverMode = + !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover; // 处理主按钮点击 const handleMainButtonClick = () => { diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 0bdaf5b18..1c8d090e3 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -29,7 +29,10 @@ interface BasicFormFieldsProps { beforeNameSlot?: ReactNode; } -export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps) { +export function BasicFormFields({ + form, + beforeNameSlot, +}: BasicFormFieldsProps) { const { t } = useTranslation(); const [iconDialogOpen, setIconDialogOpen] = useState(false); diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 5c2172512..1a0b39f99 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -525,7 +525,7 @@ const EndpointSpeedTest: React.FC = ({
setCustomUrl(event.target.value)} diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index b6fafc049..49f08e25b 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -265,7 +265,7 @@ export function OpenCodeFormFields({ const handleModelOptionKeyChange = ( modelKey: string, oldKey: string, - newKey: string + newKey: string, ) => { if (!newKey.trim() || oldKey === newKey) return; const model = models[modelKey]; @@ -283,7 +283,7 @@ export function OpenCodeFormFields({ const handleModelOptionValueChange = ( modelKey: string, optionKey: string, - value: string + value: string, ) => { const model = models[modelKey]; let parsedValue: unknown; @@ -443,7 +443,9 @@ export function OpenCodeFormFields({ /> handleExtraOptionValueChange(key, e.target.value)} + onChange={(e) => + handleExtraOptionValueChange(key, e.target.value) + } placeholder={t("opencode.extraOptionValuePlaceholder", { defaultValue: "600000", })} @@ -521,7 +523,7 @@ export function OpenCodeFormFields({ @@ -575,17 +577,24 @@ export function OpenCodeFormFields({ <> {Object.entries(model.options || {}).map( ([optKey, optValue]) => ( -
+
- handleModelOptionKeyChange(key, optKey, newKey) + handleModelOptionKeyChange( + key, + optKey, + newKey, + ) } placeholder={t( "opencode.modelOptionKeyPlaceholder", { defaultValue: "provider", - } + }, )} /> @@ -621,7 +630,7 @@ export function OpenCodeFormFields({
- ) + ), )}
+
+
+

+ {t("providerAdvanced.testConfigDesc", { + defaultValue: + "为此供应商配置单独的模型测试参数,不启用时使用全局配置。", + })} +

+
+
+ + + onTestConfigChange({ + ...testConfig, + testModel: e.target.value || undefined, + }) + } + placeholder={t("providerAdvanced.testModelPlaceholder", { + defaultValue: "留空使用全局配置", + })} + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + timeoutSecs: e.target.value + ? parseInt(e.target.value, 10) + : undefined, + }) + } + placeholder="45" + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + testPrompt: e.target.value || undefined, + }) + } + placeholder="Who are you?" + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + degradedThresholdMs: e.target.value + ? parseInt(e.target.value, 10) + : undefined, + }) + } + placeholder="6000" + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + maxRetries: e.target.value + ? parseInt(e.target.value, 10) + : undefined, + }) + } + placeholder="2" + disabled={!testConfig.enabled} + /> +
+
+
+
+
+ + {/* 代理配置 */} +
+ +
+
+

+ {t("providerAdvanced.proxyConfigDesc", { + defaultValue: + "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。", + })} +

+ + {/* 代理地址输入框(仿照全局代理样式) */} +
+ handleProxyUrlChange(e.target.value)} + onBlur={handleProxyUrlBlur} + className="font-mono text-sm flex-1" + disabled={!proxyConfig.enabled} + /> + +
+ + {/* 认证信息:用户名 + 密码(可选) */} +
+ + onProxyConfigChange({ + ...proxyConfig, + proxyUsername: e.target.value || undefined, + }) + } + className="font-mono text-sm flex-1" + disabled={!proxyConfig.enabled} + /> +
+ + onProxyConfigChange({ + ...proxyConfig, + proxyPassword: e.target.value || undefined, + }) + } + className="font-mono text-sm pr-10" + disabled={!proxyConfig.enabled} + /> + +
+
+
+
+
+
+ ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 2c366a16a..d2cc70efa 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -8,7 +8,12 @@ import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import type { AppId } from "@/lib/api"; -import type { ProviderCategory, ProviderMeta } from "@/types"; +import type { + ProviderCategory, + ProviderMeta, + ProviderTestConfig, + ProviderProxyConfig, +} from "@/types"; import { providerPresets, type ProviderPreset, @@ -41,6 +46,7 @@ import { BasicFormFields } from "./BasicFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields"; import { CodexFormFields } from "./CodexFormFields"; import { GeminiFormFields } from "./GeminiFormFields"; +import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig"; import { useProviderCategory, useApiKeyState, @@ -87,7 +93,11 @@ const OPENCODE_DEFAULT_CONFIG = JSON.stringify( type PresetEntry = { id: string; - preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset; + preset: + | ProviderPreset + | CodexProviderPreset + | GeminiProviderPreset + | OpenCodeProviderPreset; }; interface ProviderFormProps { @@ -151,6 +161,14 @@ export function ProviderForm({ () => initialData?.meta?.endpointAutoSelect ?? true, ); + // 高级配置:模型测试和代理配置 + const [testConfig, setTestConfig] = useState( + () => initialData?.meta?.testConfig ?? { enabled: false }, + ); + const [proxyConfig, setProxyConfig] = useState( + () => initialData?.meta?.proxyConfig ?? { enabled: false }, + ); + // 使用 category hook const { category } = useProviderCategory({ appId, @@ -168,6 +186,8 @@ export function ProviderForm({ setDraftCustomEndpoints([]); } setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); + setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); + setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false }); }, [appId, initialData]); const defaultValues: ProviderFormData = useMemo( @@ -506,7 +526,7 @@ export function ProviderForm({ if (!opencodeProvidersData?.providers) return []; // Exclude current provider ID when in edit mode return Object.keys(opencodeProvidersData.providers).filter( - (k) => k !== providerId + (k) => k !== providerId, ); }, [opencodeProvidersData?.providers, providerId]); @@ -521,7 +541,11 @@ export function ProviderForm({ const [opencodeNpm, setOpencodeNpm] = useState(() => { if (appId !== "opencode") return "@ai-sdk/openai-compatible"; try { - const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCODE_DEFAULT_CONFIG, + ); return config.npm || "@ai-sdk/openai-compatible"; } catch { return "@ai-sdk/openai-compatible"; @@ -531,7 +555,11 @@ export function ProviderForm({ const [opencodeApiKey, setOpencodeApiKey] = useState(() => { if (appId !== "opencode") return ""; try { - const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCODE_DEFAULT_CONFIG, + ); return config.options?.apiKey || ""; } catch { return ""; @@ -541,17 +569,27 @@ export function ProviderForm({ const [opencodeBaseUrl, setOpencodeBaseUrl] = useState(() => { if (appId !== "opencode") return ""; try { - const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCODE_DEFAULT_CONFIG, + ); return config.options?.baseURL || ""; } catch { return ""; } }); - const [opencodeModels, setOpencodeModels] = useState>(() => { + const [opencodeModels, setOpencodeModels] = useState< + Record + >(() => { if (appId !== "opencode") return {}; try { - const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCODE_DEFAULT_CONFIG, + ); return config.models || {}; } catch { return {}; @@ -559,10 +597,16 @@ export function ProviderForm({ }); // OpenCode extra options state (e.g., timeout, setCacheKey) - const [opencodeExtraOptions, setOpencodeExtraOptions] = useState>(() => { + const [opencodeExtraOptions, setOpencodeExtraOptions] = useState< + Record + >(() => { if (appId !== "opencode") return {}; try { - const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCODE_DEFAULT_CONFIG, + ); const options = config.options || {}; const extra: Record = {}; const knownKeys = ["baseURL", "apiKey", "headers"]; @@ -583,7 +627,9 @@ export function ProviderForm({ (npm: string) => { setOpencodeNpm(npm); try { - const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG, + ); config.npm = npm; form.setValue("settingsConfig", JSON.stringify(config, null, 2)); } catch { @@ -597,7 +643,9 @@ export function ProviderForm({ (apiKey: string) => { setOpencodeApiKey(apiKey); try { - const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG, + ); if (!config.options) config.options = {}; config.options.apiKey = apiKey; form.setValue("settingsConfig", JSON.stringify(config, null, 2)); @@ -612,7 +660,9 @@ export function ProviderForm({ (baseUrl: string) => { setOpencodeBaseUrl(baseUrl); try { - const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG, + ); if (!config.options) config.options = {}; config.options.baseURL = baseUrl.trim().replace(/\/+$/, ""); form.setValue("settingsConfig", JSON.stringify(config, null, 2)); @@ -627,7 +677,9 @@ export function ProviderForm({ (models: Record) => { setOpencodeModels(models); try { - const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG, + ); config.models = models; form.setValue("settingsConfig", JSON.stringify(config, null, 2)); } catch { @@ -641,7 +693,9 @@ export function ProviderForm({ (options: Record) => { setOpencodeExtraOptions(options); try { - const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG, + ); if (!config.options) config.options = {}; // Remove old extra options (keep only known keys) @@ -883,6 +937,9 @@ export function ProviderForm({ payload.meta = { ...(baseMeta ?? {}), endpointAutoSelect, + // 添加高级配置 + testConfig: testConfig.enabled ? testConfig : undefined, + proxyConfig: proxyConfig.enabled ? proxyConfig : undefined, }; onSubmit(payload); @@ -1122,32 +1179,44 @@ export function ProviderForm({ setOpencodeProviderKey(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} + onChange={(e) => + setOpencodeProviderKey( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ) + } placeholder={t("opencode.providerKeyPlaceholder")} disabled={isEditMode} className={ - (existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) || - (opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) + (existingOpencodeKeys.includes(opencodeProviderKey) && + !isEditMode) || + (opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) ? "border-destructive" : "" } /> - {existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode && ( -

- {t("opencode.providerKeyDuplicate")} -

- )} - {opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && ( -

- {t("opencode.providerKeyInvalid")} -

- )} - {!(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) && - (opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && ( -

- {t("opencode.providerKeyHint")} -

- )} + {existingOpencodeKeys.includes(opencodeProviderKey) && + !isEditMode && ( +

+ {t("opencode.providerKeyDuplicate")} +

+ )} + {opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && ( +

+ {t("opencode.providerKeyInvalid")} +

+ )} + {!( + existingOpencodeKeys.includes(opencodeProviderKey) && + !isEditMode + ) && + (opencodeProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && ( +

+ {t("opencode.providerKeyHint")} +

+ )}
) : undefined } @@ -1391,6 +1460,14 @@ export function ProviderForm({ )} + {/* 高级配置:模型测试和代理配置 */} + + {showButtons && (
onChange(e.target.value)} placeholder={placeholder} diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 4186e358e..fe258b8d4 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -25,9 +25,9 @@ export function AutoFailoverConfigPanel({ const [formData, setFormData] = useState({ autoFailoverEnabled: false, maxRetries: "3", - streamingFirstByteTimeout: "30", - streamingIdleTimeout: "60", - nonStreamingTimeout: "300", + streamingFirstByteTimeout: "60", + streamingIdleTimeout: "120", + nonStreamingTimeout: "600", circuitFailureThreshold: "5", circuitSuccessThreshold: "2", circuitTimeoutSeconds: "60", @@ -67,9 +67,9 @@ export function AutoFailoverConfigPanel({ // 定义各字段的有效范围 const ranges = { maxRetries: { min: 0, max: 10 }, - streamingFirstByteTimeout: { min: 0, max: 180 }, + streamingFirstByteTimeout: { min: 1, max: 120 }, streamingIdleTimeout: { min: 0, max: 600 }, - nonStreamingTimeout: { min: 0, max: 1800 }, + nonStreamingTimeout: { min: 60, max: 1200 }, circuitFailureThreshold: { min: 1, max: 20 }, circuitSuccessThreshold: { min: 1, max: 10 }, circuitTimeoutSeconds: { min: 0, max: 300 }, @@ -307,8 +307,8 @@ export function AutoFailoverConfigPanel({ setFormData({ @@ -321,7 +321,7 @@ export function AutoFailoverConfigPanel({

{t( "proxy.autoFailover.streamingFirstByteHint", - "等待首个数据块的最大时间", + "等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒", )}

@@ -347,7 +347,7 @@ export function AutoFailoverConfigPanel({

{t( "proxy.autoFailover.streamingIdleHint", - "数据块之间的最大间隔", + "数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)", )}

@@ -359,8 +359,8 @@ export function AutoFailoverConfigPanel({ setFormData({ @@ -373,7 +373,7 @@ export function AutoFailoverConfigPanel({

{t( "proxy.autoFailover.nonStreamingHint", - "非流式请求的总超时时间", + "非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)", )}

diff --git a/src/components/proxy/FailoverToggle.tsx b/src/components/proxy/FailoverToggle.tsx new file mode 100644 index 000000000..9ec4c0222 --- /dev/null +++ b/src/components/proxy/FailoverToggle.tsx @@ -0,0 +1,76 @@ +/** + * 故障转移切换开关组件 + * + * 放置在主界面头部,用于一键启用/关闭自动故障转移 + */ + +import { Shuffle, Loader2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { + useAutoFailoverEnabled, + useSetAutoFailoverEnabled, +} from "@/lib/query/failover"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import type { AppId } from "@/lib/api"; + +interface FailoverToggleProps { + className?: string; + activeApp: AppId; +} + +export function FailoverToggle({ className, activeApp }: FailoverToggleProps) { + const { t } = useTranslation(); + const { data: isEnabled = false, isLoading } = + useAutoFailoverEnabled(activeApp); + const setEnabled = useSetAutoFailoverEnabled(); + + const handleToggle = (checked: boolean) => { + setEnabled.mutate({ appType: activeApp, enabled: checked }); + }; + + const appLabel = + activeApp === "claude" + ? "Claude" + : activeApp === "codex" + ? "Codex" + : "Gemini"; + + const tooltipText = isEnabled + ? t("failover.tooltip.enabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已启用\n自动切换到下一个可用供应商`, + }) + : t("failover.tooltip.disabled", { + app: appLabel, + defaultValue: `启用 ${appLabel} 故障转移\n当当前供应商失败时自动切换`, + }); + + return ( +
+ {setEnabled.isPending || isLoading ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index ca526eb4b..462c3d9bb 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -55,39 +55,29 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { return (
-
- {isPending ? ( - - ) : ( - - )} - + ) : ( + - Proxy - - -
+ )} +
); } diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index 6db2fd83d..6194c3bc6 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -9,7 +9,6 @@ import { Terminal, CheckCircle2, AlertCircle, - Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; @@ -20,6 +19,7 @@ import { useUpdate } from "@/contexts/UpdateContext"; import { relaunchApp } from "@/lib/updater"; import { Badge } from "@/components/ui/badge"; import { motion } from "framer-motion"; +import appIcon from "@/assets/icons/app-icon.png"; interface AboutSectionProps { isPortable: boolean; @@ -204,7 +204,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
- + CC Switch

CC Switch

diff --git a/src/components/settings/LogConfigPanel.tsx b/src/components/settings/LogConfigPanel.tsx new file mode 100644 index 000000000..fa71885d0 --- /dev/null +++ b/src/components/settings/LogConfigPanel.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { settingsApi, type LogConfig } from "@/lib/api/settings"; + +const LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] as const; + +export function LogConfigPanel() { + const { t } = useTranslation(); + const [config, setConfig] = useState({ + enabled: true, + level: "info", + }); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + settingsApi + .getLogConfig() + .then(setConfig) + .catch((e) => console.error("Failed to load log config:", e)) + .finally(() => setIsLoading(false)); + }, []); + + const handleChange = async (updates: Partial) => { + const newConfig = { ...config, ...updates }; + setConfig(newConfig); + try { + await settingsApi.setLogConfig(newConfig); + } catch (e) { + console.error("Failed to save log config:", e); + toast.error(String(e)); + setConfig(config); + } + }; + + if (isLoading) return null; + + return ( +
+
+
+ +

+ {t("settings.advanced.logConfig.enabledDescription")} +

+
+ handleChange({ enabled: checked })} + /> +
+ +
+
+ +

+ {t("settings.advanced.logConfig.levelDescription")} +

+
+ +
+ + {/* 日志级别说明 */} +
+

+ {t("settings.advanced.logConfig.levelHint")} +

+
+

+ error -{" "} + {t("settings.advanced.logConfig.levelDesc.error")} +

+

+ warn -{" "} + {t("settings.advanced.logConfig.levelDesc.warn")} +

+

+ info -{" "} + {t("settings.advanced.logConfig.levelDesc.info")} +

+

+ debug -{" "} + {t("settings.advanced.logConfig.levelDesc.debug")} +

+

+ trace -{" "} + {t("settings.advanced.logConfig.levelDesc.trace")} +

+
+
+
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 6fd63e0b5..3f12a471d 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -11,6 +11,7 @@ import { ChevronDown, Zap, Globe, + ScrollText, } from "lucide-react"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { toast } from "sonner"; @@ -44,6 +45,7 @@ import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPa import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel"; +import { LogConfigPanel } from "@/components/settings/LogConfigPanel"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -574,6 +576,28 @@ export function SettingsPage({ + + + +
+ +
+

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

+

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

+
+
+
+ + + +
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 289e65654..9cd7cc1c3 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,11 +1,18 @@ import { Toaster as SonnerToaster } from "sonner"; +import { useTheme } from "@/components/theme-provider"; export function Toaster() { + const { theme } = useTheme(); + + // 将应用主题映射到 Sonner 的主题 + // 如果是 "system",Sonner 会自己处理 + const sonnerTheme = theme === "system" ? "system" : theme; + return ( { return await invoke("set_rectifier_config", { config }); }, + + async getLogConfig(): Promise { + return await invoke("get_log_config"); + }, + + async setLogConfig(config: LogConfig): Promise { + return await invoke("set_log_config", { config }); + }, }; export interface RectifierConfig { enabled: boolean; requestThinkingSignature: boolean; } + +export interface LogConfig { + enabled: boolean; + level: "error" | "warn" | "info" | "debug" | "trace"; +} diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 4ba57bcfa..10049eb80 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -1,5 +1,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { failoverApi } from "@/lib/api/failover"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { extractErrorMessage } from "@/utils/errorUtils"; // ========== 熔断器 Hooks ========== @@ -197,6 +200,7 @@ export function useAutoFailoverEnabled(appType: string) { */ export function useSetAutoFailoverEnabled() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) => @@ -217,14 +221,46 @@ export function useSetAutoFailoverEnabled() { return { previousValue, appType }; }, + onSuccess: (_data, variables) => { + const appLabel = + variables.appType === "claude" + ? "Claude" + : variables.appType === "codex" + ? "Codex" + : "Gemini"; + + toast.success( + variables.enabled + ? t("failover.enabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已启用`, + }) + : t("failover.disabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已关闭`, + }), + { closeButton: true }, + ); + }, + // 错误时回滚 - onError: (_error, _variables, context) => { + onError: (error: Error, _variables, context) => { if (context?.previousValue !== undefined) { queryClient.setQueryData( ["autoFailoverEnabled", context.appType], context.previousValue, ); } + + const detail = + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); + toast.error( + t("failover.toggleFailed", { + detail, + defaultValue: `操作失败: ${detail}`, + }), + ); }, // 无论成功失败,都重新获取 diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 73d75c854..0aa84356d 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -12,7 +12,7 @@ export const useAddProviderMutation = (appId: AppId) => { return useMutation({ mutationFn: async ( - providerInput: Omit & { providerKey?: string } + providerInput: Omit & { providerKey?: string }, ) => { let id: string; diff --git a/src/types.ts b/src/types.ts index dcf0b5347..9fd73774a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,38 @@ export interface UsageResult { error?: string; } +// 供应商单独的模型测试配置 +export interface ProviderTestConfig { + // 是否启用单独配置(false 时使用全局配置) + enabled: boolean; + // 测试用的模型名称(覆盖全局配置) + testModel?: string; + // 超时时间(秒) + timeoutSecs?: number; + // 测试提示词 + testPrompt?: string; + // 降级阈值(毫秒) + degradedThresholdMs?: number; + // 最大重试次数 + maxRetries?: number; +} + +// 供应商单独的代理配置 +export interface ProviderProxyConfig { + // 是否启用单独配置(false 时使用全局/系统代理) + enabled: boolean; + // 代理类型:http, https, socks5 + proxyType?: "http" | "https" | "socks5"; + // 代理主机 + proxyHost?: string; + // 代理端口 + proxyPort?: number; + // 代理用户名(可选) + proxyUsername?: string; + // 代理密码(可选) + proxyPassword?: string; +} + // 供应商元数据(字段名与后端一致,保持 snake_case) export interface ProviderMeta { // 自定义端点:以 URL 为键,值为端点信息 @@ -99,6 +131,10 @@ export interface ProviderMeta { isPartner?: boolean; // 合作伙伴促销 key(用于后端识别 PackyCode 等) partnerPromotionKey?: string; + // 供应商单独的模型测试配置 + testConfig?: ProviderTestConfig; + // 供应商单独的代理配置 + proxyConfig?: ProviderProxyConfig; } // 应用设置类型(用于设置对话框与 Tauri API) @@ -294,4 +330,3 @@ export interface OpenCodeMcpServerSpec { // 通用字段 enabled?: boolean; } - From 30009ad5f120ca61fb100d29a27352091e52c967 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 20 Jan 2026 12:28:45 +0800 Subject: [PATCH 5/7] feat(settings): set Gemini visibility to false by default New users will see Claude, Codex, and OpenCode by default, with Gemini hidden. --- .../settings/AppVisibilitySettings.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/components/settings/AppVisibilitySettings.tsx diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx new file mode 100644 index 000000000..a610c3a1c --- /dev/null +++ b/src/components/settings/AppVisibilitySettings.tsx @@ -0,0 +1,118 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ProviderIcon } from "@/components/ProviderIcon"; +import type { SettingsFormState } from "@/hooks/useSettings"; +import type { VisibleApps } from "@/types"; +import type { AppId } from "@/lib/api"; + +interface AppVisibilitySettingsProps { + settings: SettingsFormState; + onChange: (updates: Partial) => void; +} + +const APP_CONFIG: Array<{ + id: AppId; + icon: string; + nameKey: string; +}> = [ + { id: "claude", icon: "claude", nameKey: "apps.claude" }, + { id: "codex", icon: "openai", nameKey: "apps.codex" }, + { id: "gemini", icon: "gemini", nameKey: "apps.gemini" }, + { id: "opencode", icon: "opencode", nameKey: "apps.opencode" }, +]; + +export function AppVisibilitySettings({ settings, onChange }: AppVisibilitySettingsProps) { + const { t } = useTranslation(); + + const visibleApps: VisibleApps = settings.visibleApps ?? { + claude: true, + codex: true, + gemini: false, + opencode: true, + }; + + // Count how many apps are currently visible + const visibleCount = Object.values(visibleApps).filter(Boolean).length; + + const handleToggle = (appId: AppId) => { + const isCurrentlyVisible = visibleApps[appId]; + // Prevent disabling the last visible app + if (isCurrentlyVisible && visibleCount <= 1) return; + + onChange({ + visibleApps: { + ...visibleApps, + [appId]: !isCurrentlyVisible, + }, + }); + }; + + return ( +
+
+

{t("settings.appVisibility.title")}

+

+ {t("settings.appVisibility.description")} +

+
+
+ {APP_CONFIG.map((app) => { + const isVisible = visibleApps[app.id]; + // Disable button if this is the last visible app + const isDisabled = isVisible && visibleCount <= 1; + + return ( + handleToggle(app.id)} + icon={app.icon} + name={t(app.nameKey)} + > + {t(app.nameKey)} + + ); + })} +
+
+ ); +} + +interface AppButtonProps { + active: boolean; + disabled?: boolean; + onClick: () => void; + icon: string; + name: string; + children: React.ReactNode; +} + +function AppButton({ + active, + disabled, + onClick, + icon, + name, + children, +}: AppButtonProps) { + return ( + + ); +} From eab1d0852727a13f9e469c3a0723512893cae8ec Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 20 Jan 2026 16:03:49 +0800 Subject: [PATCH 6/7] feat(settings): add app visibility settings Allow users to choose which apps (Claude, Codex, Gemini, OpenCode) to display on the homepage. - Add VisibleApps type and settings field in both frontend and backend - Refactor AppSwitcher to render apps dynamically based on visibility - Extract ToggleRow component for reuse - Add i18n support for app visibility settings --- src-tauri/src/settings.rs | 38 ++++++- src/App.tsx | 31 +++++- src/components/AppSwitcher.tsx | 123 ++++++--------------- src/components/settings/SettingsPage.tsx | 5 + src/components/settings/WindowSettings.tsx | 39 +------ src/components/ui/toggle-row.tsx | 41 +++++++ src/i18n/locales/en.json | 10 +- src/i18n/locales/ja.json | 10 +- src/i18n/locales/zh.json | 10 +- src/types.ts | 11 ++ 10 files changed, 182 insertions(+), 136 deletions(-) create mode 100644 src/components/ui/toggle-row.tsx diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 88f087283..01de0af27 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -16,6 +16,35 @@ pub struct CustomEndpoint { pub last_used: Option, } +fn default_true() -> bool { + true +} + +/// 主页面显示的应用配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VisibleApps { + #[serde(default = "default_true")] + pub claude: bool, + #[serde(default = "default_true")] + pub codex: bool, + #[serde(default = "default_true")] + pub gemini: bool, + #[serde(default = "default_true")] + pub opencode: bool, +} + +impl Default for VisibleApps { + fn default() -> Self { + Self { + claude: true, + codex: true, + gemini: true, + opencode: true, + } + } +} + /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 @@ -40,6 +69,10 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + // ===== 主页面显示的应用 ===== + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visible_apps: Option, + // ===== 设备级目录覆盖 ===== #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_config_dir: Option, @@ -73,10 +106,6 @@ fn default_minimize_to_tray_on_close() -> bool { true } -fn default_true() -> bool { - true -} - impl Default for AppSettings { fn default() -> Self { Self { @@ -86,6 +115,7 @@ impl Default for AppSettings { skip_claude_onboarding: true, launch_on_startup: false, language: None, + visible_apps: None, claude_config_dir: None, codex_config_dir: None, gemini_config_dir: None, diff --git a/src/App.tsx b/src/App.tsx index 20c31968d..7b92c8fd4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,9 +17,9 @@ import { Download, BarChart2, } from "lucide-react"; -import type { Provider } from "@/types"; +import type { Provider, VisibleApps } from "@/types"; import type { EnvConflict } from "@/types/env"; -import { useProvidersQuery } from "@/lib/query"; +import { useProvidersQuery, useSettingsQuery } from "@/lib/query"; import { providersApi, settingsApi, @@ -78,6 +78,31 @@ function App() { const [settingsDefaultTab, setSettingsDefaultTab] = useState("general"); const [isAddOpen, setIsAddOpen] = useState(false); + // Get settings for visibleApps + const { data: settingsData } = useSettingsQuery(); + const visibleApps: VisibleApps = settingsData?.visibleApps ?? { + claude: true, + codex: true, + gemini: true, + opencode: true, + }; + + // Get first visible app for fallback + const getFirstVisibleApp = (): AppId => { + if (visibleApps.claude) return "claude"; + if (visibleApps.codex) return "codex"; + if (visibleApps.gemini) return "gemini"; + if (visibleApps.opencode) return "opencode"; + return "claude"; // fallback + }; + + // If current active app is hidden, switch to first visible app + useEffect(() => { + if (!visibleApps[activeApp]) { + setActiveApp(getFirstVisibleApp()); + } + }, [visibleApps, activeApp]); + const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); // Confirm action state: 'remove' = remove from live config, 'delete' = delete from database @@ -864,7 +889,7 @@ function App() { )} - +
- - - - - - + {appsToShow.map((app) => ( + + ))}
); } diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 3f12a471d..2eaf48089 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -34,6 +34,7 @@ import { settingsApi } from "@/lib/api"; import { LanguageSettings } from "@/components/settings/LanguageSettings"; import { ThemeSettings } from "@/components/settings/ThemeSettings"; import { WindowSettings } from "@/components/settings/WindowSettings"; +import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { ImportExportSection } from "@/components/settings/ImportExportSection"; import { AboutSection } from "@/components/settings/AboutSection"; @@ -240,6 +241,10 @@ export function SettingsPage({ onChange={(lang) => handleAutoSave({ language: lang })} /> + ); } - -interface ToggleRowProps { - icon: React.ReactNode; - title: string; - description?: string; - checked: boolean; - onCheckedChange: (value: boolean) => void; -} - -function ToggleRow({ - icon, - title, - description, - checked, - onCheckedChange, -}: ToggleRowProps) { - return ( -
-
-
- {icon} -
-
-

{title}

- {description ? ( -

{description}

- ) : null} -
-
- -
- ); -} diff --git a/src/components/ui/toggle-row.tsx b/src/components/ui/toggle-row.tsx new file mode 100644 index 000000000..6df8a37e3 --- /dev/null +++ b/src/components/ui/toggle-row.tsx @@ -0,0 +1,41 @@ +import { Switch } from "@/components/ui/switch"; + +export interface ToggleRowProps { + icon: React.ReactNode; + title: string; + description?: string; + checked: boolean; + onCheckedChange: (value: boolean) => void; + disabled?: boolean; +} + +export function ToggleRow({ + icon, + title, + description, + checked, + onCheckedChange, + disabled, +}: ToggleRowProps) { + return ( +
+
+
+ {icon} +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+
+ +
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 25b07f0a3..bfccd61cc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -272,6 +272,14 @@ "enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app", "skipClaudeOnboarding": "Skip Claude Code first-run confirmation", "skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation", + "appVisibility": { + "title": "Homepage Display", + "description": "Choose which apps to show on the homepage", + "claudeDesc": "Anthropic Claude Code CLI", + "codexDesc": "OpenAI Codex CLI", + "geminiDesc": "Google Gemini CLI", + "opencodeDesc": "OpenCode CLI" + }, "configDirectoryOverride": "Configuration Directory Override (Advanced)", "configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.", "appConfigDir": "CC Switch Configuration Directory", @@ -333,7 +341,7 @@ } }, "apps": { - "claude": "Claude Code", + "claude": "Claude", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1d16de8cf..7784a9104 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -272,6 +272,14 @@ "enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します", "skipClaudeOnboarding": "Claude Code の初回確認をスキップ", "skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします", + "appVisibility": { + "title": "ホームページ表示", + "description": "ホームページに表示するアプリを選択", + "claudeDesc": "Anthropic Claude Code CLI", + "codexDesc": "OpenAI Codex CLI", + "geminiDesc": "Google Gemini CLI", + "opencodeDesc": "OpenCode CLI" + }, "configDirectoryOverride": "設定ディレクトリの上書き(詳細)", "configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。", "appConfigDir": "CC Switch 設定ディレクトリ", @@ -333,7 +341,7 @@ } }, "apps": { - "claude": "Claude Code", + "claude": "Claude", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode" diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5462b38e2..d36f1638a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -272,6 +272,14 @@ "enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换", "skipClaudeOnboarding": "跳过 Claude Code 初次安装确认", "skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认", + "appVisibility": { + "title": "主页面显示", + "description": "选择在主页面显示的应用", + "claudeDesc": "Anthropic Claude Code CLI", + "codexDesc": "OpenAI Codex CLI", + "geminiDesc": "Google Gemini CLI", + "opencodeDesc": "OpenCode CLI" + }, "configDirectoryOverride": "配置目录覆盖(高级)", "configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。", "appConfigDir": "CC Switch 配置目录", @@ -333,7 +341,7 @@ } }, "apps": { - "claude": "Claude Code", + "claude": "Claude", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode" diff --git a/src/types.ts b/src/types.ts index 9fd73774a..007674302 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,6 +137,14 @@ export interface ProviderMeta { proxyConfig?: ProviderProxyConfig; } +// 主页面显示的应用配置 +export interface VisibleApps { + claude: boolean; + codex: boolean; + gemini: boolean; + opencode: boolean; +} + // 应用设置类型(用于设置对话框与 Tauri API) // 存储在本地 ~/.cc-switch/settings.json,不随数据库同步 export interface Settings { @@ -154,6 +162,9 @@ export interface Settings { // 首选语言(可选,默认中文) language?: "en" | "zh" | "ja"; + // 主页面显示的应用(默认全部显示) + visibleApps?: VisibleApps; + // ===== 设备级目录覆盖 ===== // 覆盖 Claude Code 配置目录(可选) claudeConfigDir?: string; From c9e85e8cacd3580582b5213cdf4809ccfd2602f5 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 20 Jan 2026 19:37:07 +0800 Subject: [PATCH 7/7] feat(tray): sync tray menu with app visibility settings Apply visibleApps setting to filter tray menu sections, so hidden apps no longer appear in the system tray menu. --- src-tauri/src/settings.rs | 12 ++++++++++++ src-tauri/src/tray.rs | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 01de0af27..00c6a771c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -45,6 +45,18 @@ impl Default for VisibleApps { } } +impl VisibleApps { + /// Check if the specified app is visible + pub fn is_visible(&self, app: &AppType) -> bool { + match app { + AppType::Claude => self.claude, + AppType::Codex => self.codex, + AppType::Gemini => self.gemini, + AppType::OpenCode => self.opencode, + } + } +} + /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 715051f6f..10b85fa0c 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -318,6 +318,9 @@ pub fn create_tray_menu( let app_settings = crate::settings::get_settings(); let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh")); + // Get visible apps setting, default to all visible + let visible_apps = app_settings.visible_apps.unwrap_or_default(); + let mut menu_builder = MenuBuilder::new(app); // 顶部:打开主界面 @@ -327,7 +330,13 @@ pub fn create_tray_menu( menu_builder = menu_builder.item(&show_main_item).separator(); // 直接添加所有供应商到主菜单(扁平化结构,更简单可靠) + // Only add visible app sections for section in TRAY_SECTIONS.iter() { + // Skip hidden apps + if !visible_apps.is_visible(§ion.app_type) { + continue; + } + let app_type_str = section.app_type.as_str(); let providers = app_state.db.get_all_providers(app_type_str)?;