From 5bce6d602079b9a316a4f16454cfd26f421c6624 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Fri, 19 Dec 2025 20:40:11 +0800 Subject: [PATCH 001/126] Fix/about section UI (#419) * fix(ui): improve AboutSection styling and version detection - Add framer-motion animations for smooth page transitions - Unify button sizes and add icons for consistency - Add gradient backgrounds and hover effects to cards - Add notInstalled i18n translations (zh/en/ja) - Fix version detection when stdout/stderr is empty * fix(proxy): persist per-app takeover state across app restarts - Fix proxy toggle color to reflect current app's takeover state only - Restore proxy service on startup if Live config is still in takeover state - Preserve per-app backup records instead of clearing all on restart - Only recover Live config when proxy service fails to start --- src-tauri/src/commands/misc.rs | 20 +- src-tauri/src/lib.rs | 91 ++++----- src/App.tsx | 14 +- src/components/settings/AboutSection.tsx | 232 ++++++++++++++++------- src/i18n/locales/en.json | 11 +- src/i18n/locales/ja.json | 11 +- src/i18n/locales/zh.json | 11 +- 7 files changed, 270 insertions(+), 120 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 50f4f7f0f..612cba56f 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -155,11 +155,17 @@ fn try_get_version(tool: &str) -> (Option, Option) { match output { Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if out.status.success() { - let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); - (Some(extract_version(&raw)), None) + let raw = if stdout.is_empty() { &stderr } else { &stdout }; + if raw.is_empty() { + (None, Some("未安装或无法执行".to_string())) + } else { + (Some(extract_version(raw)), None) + } } else { - let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let err = if stderr.is_empty() { stdout } else { stderr }; ( None, Some(if err.is_empty() { @@ -239,9 +245,13 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { .output(); if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if out.status.success() { - let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); - return (Some(extract_version(&raw)), None); + let raw = if stdout.is_empty() { &stderr } else { &stdout }; + if !raw.is_empty() { + return (Some(extract_version(raw)), None); + } } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a4f3a758..b393e4461 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -529,61 +529,64 @@ pub fn run() { tauri::async_runtime::spawn(async move { let state = app_handle.state::(); - // 1. 检测异常退出并恢复 Live 配置 - let is_proxy_running = state.proxy_service.is_running().await; - if !is_proxy_running { - let takeover_flag = match state.db.is_live_takeover_active().await { - Ok(active) => active, - Err(e) => { - log::error!("检查接管状态失败: {e}"); - false - } - }; + // 检查是否有 Live 备份(表示上次有接管状态) + let has_backups = match state.db.has_any_live_backup().await { + Ok(v) => v, + Err(e) => { + log::error!("检查 Live 备份失败: {e}"); + false + } + }; - let has_backups = match state.db.has_any_live_backup().await { - Ok(v) => v, - Err(e) => { - log::error!("检查 Live 备份失败: {e}"); - false - } - }; + // 获取代理配置 + let proxy_config = match state.db.get_proxy_config().await { + Ok(config) => Some(config), + Err(e) => { + log::error!("启动时获取代理配置失败: {e}"); + None + } + }; - // 兜底检测:旧版本/极端窗口期可能出现“标志未写入,但 Live 已被写成占位符”的残留状态。 - // 只有在存在备份时才检查占位符,避免误判覆盖用户正常配置。 - let live_taken_over = - has_backups && state.proxy_service.detect_takeover_in_live_configs(); + if has_backups { + // 有备份说明上次退出时有接管状态 + // 检查 Live 配置是否仍处于被接管状态(包含占位符) + let live_taken_over = state.proxy_service.detect_takeover_in_live_configs(); - if takeover_flag || live_taken_over { - log::warn!("检测到上次异常退出或残留接管状态,正在恢复 Live 配置..."); - if let Err(e) = state.proxy_service.recover_from_crash().await { - log::error!("恢复 Live 配置失败: {e}"); - } else { - log::info!("Live 配置已从异常退出中恢复"); + if live_taken_over { + // Live 配置仍是接管状态,尝试重新启动代理服务以恢复接管 + log::info!("检测到上次接管状态,正在重新启动代理服务..."); + match state.proxy_service.start(false).await { + Ok(info) => { + log::info!("代理服务器已恢复启动: {}:{}", info.address, info.port); + } + Err(e) => { + // 启动失败,恢复 Live 配置 + log::error!("恢复代理服务失败: {e},正在恢复 Live 配置..."); + if let Err(e) = state.proxy_service.recover_from_crash().await { + log::error!("恢复 Live 配置失败: {e}"); + } else { + log::info!("Live 配置已恢复"); + } + } } - } else if has_backups { - // 备份残留但 Live 未处于接管状态:清理敏感备份,避免长期存储 Token + } else { + // Live 配置已经是正常状态,清理残留备份 + log::info!("Live 配置已是正常状态,清理残留备份..."); if let Err(e) = state.db.delete_all_live_backups().await { log::warn!("清理残留 Live 备份失败: {e}"); } } - } - - // 2. 自动启动代理服务器(如果配置为启用) - match state.db.get_proxy_config().await { - Ok(config) => { - if config.enabled { - log::info!("代理服务配置为启用,正在启动..."); - match state.proxy_service.start(true).await { - Ok(info) => log::info!( - "代理服务器自动启动成功: {}:{}", - info.address, - info.port - ), - Err(e) => log::error!("代理服务器自动启动失败: {e}"), + } else if let Some(config) = proxy_config { + // 没有备份,检查是否需要自动启动代理服务器(总开关) + if config.enabled { + log::info!("代理服务配置为启用,正在启动..."); + match state.proxy_service.start(true).await { + Ok(info) => { + log::info!("代理服务器自动启动成功: {}:{}", info.address, info.port) } + Err(e) => log::error!("代理服务器自动启动失败: {e}"), } } - Err(e) => log::error!("启动时获取代理配置失败: {e}"), } }); diff --git a/src/App.tsx b/src/App.tsx index cb418c438..32fbf8d51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,7 +65,15 @@ function App() { "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; // 获取代理服务状态 - const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus(); + const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus(); + // 当前应用的代理是否开启 + const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; + // 任意应用的代理是否开启 + const isTakeoverActive = + takeoverStatus?.claude || + takeoverStatus?.codex || + takeoverStatus?.gemini || + false; // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { @@ -324,7 +332,7 @@ function App() { appId={activeApp} isLoading={isLoading} isProxyRunning={isProxyRunning} - isProxyTakeover={isProxyRunning && isTakeoverActive} + isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} onSwitch={switchProvider} onEdit={setEditingProvider} onDelete={setConfirmDelete} @@ -421,7 +429,7 @@ function App() { rel="noreferrer" className={cn( "text-xl font-semibold transition-colors", - isProxyRunning && isTakeoverActive + isProxyRunning && isCurrentAppTakeoverActive ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300" : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300", )} diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index cf9465ec4..d40233f97 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { Download, + Copy, ExternalLink, Info, Loader2, @@ -8,6 +9,7 @@ import { Terminal, CheckCircle2, AlertCircle, + Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; @@ -17,6 +19,7 @@ import { settingsApi } from "@/lib/api"; import { useUpdate } from "@/contexts/UpdateContext"; import { relaunchApp } from "@/lib/updater"; import { Badge } from "@/components/ui/badge"; +import { motion } from "framer-motion"; interface AboutSectionProps { isPortable: boolean; @@ -29,6 +32,10 @@ interface ToolVersion { error: string | null; } +const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest +npm i -g @openai/codex@latest +npm i -g @google/gemini-cli@latest`; + export function AboutSection({ isPortable }: AboutSectionProps) { // ... (use hooks as before) ... const { t } = useTranslation(); @@ -47,6 +54,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) { isChecking, } = useUpdate(); + const loadToolVersions = useCallback(async () => { + setIsLoadingTools(true); + try { + const tools = await settingsApi.getToolVersions(); + setToolVersions(tools); + } catch (error) { + console.error("[AboutSection] Failed to load tool versions", error); + } finally { + setIsLoadingTools(false); + } + }, []); + useEffect(() => { let active = true; const load = async () => { @@ -150,10 +169,25 @@ export function AboutSection({ isPortable }: AboutSectionProps) { } }, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]); + const handleCopyInstallCommands = useCallback(async () => { + try { + await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS); + toast.success(t("settings.installCommandsCopied"), { closeButton: true }); + } catch (error) { + console.error("[AboutSection] Failed to copy install commands", error); + toast.error(t("settings.installCommandsCopyFailed")); + } + }, [t]); + const displayVersion = version ?? t("common.unknown"); return ( -
+

{t("common.about")}

@@ -161,12 +195,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {

-
+
-

CC Switch

- + +

+ CC Switch +

+
+
+ {t("common.version")} @@ -185,15 +229,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
-
+
{hasUpdate && updateInfo && ( -
+

{t("settings.updateAvailable", { version: updateInfo.availableVersion, @@ -239,60 +290,111 @@ export function AboutSection({ isPortable }: AboutSectionProps) { {updateInfo.notes}

)} -
+ )} -
+
-

- 本地环境检查 -

+
+

{t("settings.localEnvCheck")}

+ +
- {isLoadingTools - ? Array.from({ length: 3 }).map((_, i) => ( -
- )) - : toolVersions.map((tool) => ( -
-
-
- - - {tool.name} - -
- {tool.version ? ( -
- {tool.latest_version && - tool.version !== tool.latest_version && ( - - Update: {tool.latest_version} - - )} - -
- ) : ( - - )} + {["claude", "codex", "gemini"].map((toolName, index) => { + const tool = toolVersions.find((item) => item.name === toolName); + const displayName = tool?.name ?? toolName; + const title = tool?.version || tool?.error || t("common.unknown"); + + return ( + +
+
+ + + {displayName} +
-
-
- {tool.version ? tool.version : tool.error || "未安装"} + {isLoadingTools ? ( + + ) : tool?.version ? ( +
+ {tool.latest_version && + tool.version !== tool.latest_version && ( + + {tool.latest_version} + + )} +
-
+ ) : ( + + )}
- ))} +
+ {isLoadingTools + ? t("common.loading") + : tool?.version + ? tool.version + : tool?.error || t("common.notInstalled")} +
+ + ); + })}
-
+ + +

+ {t("settings.oneClickInstall")} +

+
+
+

+ {t("settings.oneClickInstallHint")} +

+ +
+
+            {ONE_CLICK_INSTALL_COMMANDS}
+          
+
+
+ ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2226b977b..911e446e9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -17,6 +17,7 @@ "about": "About", "version": "Version", "loading": "Loading...", + "notInstalled": "Not installed", "success": "Success", "error": "Error", "unknown": "Unknown", @@ -28,7 +29,10 @@ "formatError": "Format failed: {{error}}", "copy": "Copy", "view": "View", - "back": "Back" + "back": "Back", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "notInstalled": "Not installed" }, "apiKeyInput": { "placeholder": "Enter API Key", @@ -205,6 +209,11 @@ "releaseNotes": "Release Notes", "viewReleaseNotes": "View release notes for this version", "viewCurrentReleaseNotes": "View current version release notes", + "oneClickInstall": "One-click Install", + "oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI", + "localEnvCheck": "Local environment check", + "installCommandsCopied": "Install commands copied", + "installCommandsCopyFailed": "Copy failed, please copy manually.", "importFailedError": "Import config failed: {{message}}", "exportFailedError": "Export config failed:", "restartRequired": "Restart Required", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 07f1a32ca..6f9ff89f0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -17,6 +17,7 @@ "about": "バージョン情報", "version": "バージョン", "loading": "読み込み中...", + "notInstalled": "未インストール", "success": "成功", "error": "エラー", "unknown": "不明", @@ -28,7 +29,10 @@ "formatError": "整形に失敗しました: {{error}}", "copy": "コピー", "view": "表示", - "back": "戻る" + "back": "戻る", + "refresh": "更新", + "refreshing": "更新中...", + "notInstalled": "未インストール" }, "apiKeyInput": { "placeholder": "API Key を入力", @@ -205,6 +209,11 @@ "releaseNotes": "リリースノート", "viewReleaseNotes": "このバージョンのリリースノートを見る", "viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る", + "oneClickInstall": "ワンクリックインストール", + "oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール", + "localEnvCheck": "ローカル環境チェック", + "installCommandsCopied": "インストールコマンドをコピーしました", + "installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。", "importFailedError": "設定のインポートに失敗しました: {{message}}", "exportFailedError": "設定のエクスポートに失敗しました:", "restartRequired": "再起動が必要です", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c63bbc74c..12eabee5a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -17,6 +17,7 @@ "about": "关于", "version": "版本", "loading": "加载中...", + "notInstalled": "未安装", "success": "成功", "error": "错误", "unknown": "未知", @@ -28,7 +29,10 @@ "formatError": "格式化失败:{{error}}", "copy": "复制", "view": "查看", - "back": "返回" + "back": "返回", + "refresh": "刷新", + "refreshing": "刷新中...", + "notInstalled": "未安装" }, "apiKeyInput": { "placeholder": "请输入API Key", @@ -205,6 +209,11 @@ "releaseNotes": "更新日志", "viewReleaseNotes": "查看该版本更新日志", "viewCurrentReleaseNotes": "查看当前版本更新日志", + "oneClickInstall": "一键安装", + "oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI", + "localEnvCheck": "本地环境检查", + "installCommandsCopied": "安装命令已复制", + "installCommandsCopyFailed": "复制失败,请手动复制。", "importFailedError": "导入配置失败:{{message}}", "exportFailedError": "导出配置失败:", "restartRequired": "需要重启应用", From 1706c9a26ffc29e418da084faa02885781bdfa25 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 19 Dec 2025 09:57:35 +0800 Subject: [PATCH 002/126] fix(backup): restrict SQL import to CC Switch exported backups only - Add validation to reject SQL files without CC Switch export header - Remove redundant sanitize_import_sql (sqlite_* objects already excluded at export time) - Fix backup filename collision by appending counter suffix - Update i18n hints to clarify import restriction --- src-tauri/src/database/backup.rs | 45 +++++++++-------- src-tauri/tests/import_export_sync.rs | 73 +++++++++++++++++++++++++++ src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- 5 files changed, 99 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 631bc30e7..bd3df4181 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -13,6 +13,8 @@ use std::fs; use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; +const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; + impl Database { /// 导出为 SQLite 兼容的 SQL 文本 pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { @@ -36,7 +38,8 @@ impl Database { } let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; - let sql_content = Self::sanitize_import_sql(&sql_raw); + let sql_content = sql_raw.trim_start_matches('\u{feff}'); + Self::validate_cc_switch_sql_export(sql_content)?; // 导入前备份现有数据库 let backup_path = self.backup_database_file()?; @@ -51,7 +54,7 @@ impl Database { Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; temp_conn - .execute_batch(&sql_content) + .execute_batch(sql_content) .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; // 补齐缺失表/索引并进行基础校验 @@ -93,26 +96,17 @@ impl Database { Ok(snapshot) } - /// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错 - fn sanitize_import_sql(sql: &str) -> String { - let mut cleaned = String::new(); - let lower_keyword = "sqlite_sequence"; - - for stmt in sql.split(';') { - let trimmed = stmt.trim(); - if trimmed.is_empty() { - continue; - } - - if trimmed.to_ascii_lowercase().contains(lower_keyword) { - continue; - } - - cleaned.push_str(trimmed); - cleaned.push_str(";\n"); + fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> { + let trimmed = sql.trim_start(); + if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) { + return Ok(()); } - cleaned + Err(AppError::localized( + "backup.sql.invalid_format", + "仅支持导入由 CC Switch 导出的 SQL 备份文件。", + "Only SQL backups exported by CC Switch are supported.", + )) } /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) @@ -129,8 +123,15 @@ impl Database { fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; - let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); - let backup_path = backup_dir.join(format!("{backup_id}.db")); + let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); + let mut backup_id = base_id.clone(); + let mut backup_path = backup_dir.join(format!("{backup_id}.db")); + let mut counter = 1; + while backup_path.exists() { + backup_id = format!("{base_id}_{counter}"); + backup_path = backup_dir.join(format!("{backup_id}.db")); + counter += 1; + } { let conn = lock_conn!(self.conn); diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index a5ff40e3b..aaaeb6f13 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -1003,3 +1003,76 @@ fn export_sql_returns_error_for_invalid_path() { other => panic!("expected IoContext or Io error, got {other:?}"), } } + +#[test] +fn import_sql_rejects_non_cc_switch_backup() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + let import_path = home.join("not-cc-switch.sql"); + fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql"); + + let err = state + .db + .import_sql(&import_path) + .expect_err("non-cc-switch sql should be rejected"); + + match err { + AppError::Localized { key, .. } => { + assert_eq!(key, "backup.sql.invalid_format"); + } + other => panic!("expected Localized error, got {other:?}"), + } +} + +#[test] +fn import_sql_accepts_cc_switch_exported_backup() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + // Create a database with some data and export it. + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "test-provider".to_string(); + manager.providers.insert( + "test-provider".to_string(), + Provider::with_id( + "test-provider".to_string(), + "Test Provider".to_string(), + json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}), + None, + ), + ); + } + + let state = create_test_state_with_config(&config).expect("create test state"); + let export_path = home.join("cc-switch-export.sql"); + state + .db + .export_sql(&export_path) + .expect("export should succeed"); + + // Reset database, then import into a fresh one. + reset_test_fs(); + let state = create_test_state().expect("create test state"); + state + .db + .import_sql(&export_path) + .expect("import should succeed"); + + let providers = state + .db + .get_all_providers(AppType::Claude.as_str()) + .expect("load providers"); + assert!( + providers.contains_key("test-provider"), + "imported providers should contain test-provider" + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 911e446e9..c1049742c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -150,7 +150,7 @@ "themeDark": "Dark", "themeSystem": "System", "importExport": "SQL Import/Export", - "importExportHint": "Import or export database SQL backups for migration or restore.", + "importExportHint": "Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).", "exportConfig": "Export SQL Backup", "selectConfigFile": "Select SQL File", "noFileSelected": "No configuration file selected.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6f9ff89f0..d8cde7d12 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -150,7 +150,7 @@ "themeDark": "ダーク", "themeSystem": "システム", "importExport": "SQL インポート/エクスポート", - "importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。", + "importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。", "exportConfig": "SQL バックアップをエクスポート", "selectConfigFile": "SQL ファイルを選択", "noFileSelected": "ファイルが選択されていません。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 12eabee5a..9e5e7bec4 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -150,7 +150,7 @@ "themeDark": "深色", "themeSystem": "跟随系统", "importExport": "SQL 导入导出", - "importExportHint": "导入/导出数据库 SQL 备份,便于备份或迁移。", + "importExportHint": "导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。", "exportConfig": "导出 SQL 备份", "selectConfigFile": "选择 SQL 文件", "noFileSelected": "尚未选择配置文件。", From b6ff721d67ca8f7cc68844c201e041bde5bf85f6 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 19 Dec 2025 11:18:22 +0800 Subject: [PATCH 003/126] fix(import): refresh all providers immediately after SQL import - Remove setTimeout delay that could be cancelled on component unmount - Invalidate all providers cache (not just current app) since import affects all apps - Call onImportSuccess before sync to ensure UI refresh even if sync fails - Update i18n: "Data refreshed" (past tense, reflecting immediate action) --- src/App.tsx | 17 ++++++++++++++++- src/hooks/useImportExport.ts | 19 +++++-------------- src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- tests/hooks/useImportExport.test.tsx | 6 ------ 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 32fbf8d51..15255766f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { invoke } from "@tauri-apps/api/core"; +import { useQueryClient } from "@tanstack/react-query"; import { Plus, Settings, @@ -47,6 +48,7 @@ type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; function App() { const { t } = useTranslation(); + const queryClient = useQueryClient(); const [activeApp, setActiveApp] = useState("claude"); const [currentView, setCurrentView] = useState("providers"); @@ -276,7 +278,20 @@ function App() { // 导入配置成功后刷新 const handleImportSuccess = async () => { - await refetch(); + try { + // 导入会影响所有应用的供应商数据:刷新所有 providers 缓存 + await queryClient.invalidateQueries({ + queryKey: ["providers"], + refetchType: "all", + }); + await queryClient.refetchQueries({ + queryKey: ["providers"], + type: "all", + }); + } catch (error) { + console.error("[App] Failed to refresh providers after import", error); + await refetch(); + } try { await providersApi.updateTrayMenu(); } catch (error) { diff --git a/src/hooks/useImportExport.ts b/src/hooks/useImportExport.ts index 3fa573aaa..62703df6e 100644 --- a/src/hooks/useImportExport.ts +++ b/src/hooks/useImportExport.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { settingsApi } from "@/lib/api"; @@ -39,15 +39,6 @@ export function useImportExport( const [errorMessage, setErrorMessage] = useState(null); const [backupId, setBackupId] = useState(null); const [isImporting, setIsImporting] = useState(false); - const successTimerRef = useRef(null); - - useEffect(() => { - return () => { - if (successTimerRef.current) { - window.clearTimeout(successTimerRef.current); - } - }; - }, []); const clearSelection = useCallback(() => { setSelectedFile(""); @@ -105,6 +96,10 @@ export function useImportExport( } setBackupId(result.backupId ?? null); + // 导入成功后立即触发外部刷新(与 live 同步结果解耦) + // - 避免 sync 失败时 UI 不刷新 + // - 避免依赖 setTimeout(组件卸载会取消) + void onImportSuccess?.(); const syncResult = await syncCurrentProvidersLiveSafe(); if (syncResult.ok) { @@ -115,10 +110,6 @@ export function useImportExport( }), { closeButton: true }, ); - - successTimerRef.current = window.setTimeout(() => { - void onImportSuccess?.(); - }, 1500); } else { console.error( "[useImportExport] Failed to sync live config", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c1049742c..03d8f3cf7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -166,7 +166,7 @@ "selectFileFailed": "Please choose a valid SQL backup file", "configCorrupted": "SQL file may be corrupted or invalid", "backupId": "Backup ID", - "autoReload": "Data will refresh automatically in 2 seconds...", + "autoReload": "Data refreshed", "languageOptionChinese": "中文", "languageOptionEnglish": "English", "languageOptionJapanese": "日本語", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d8cde7d12..36076dbf1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -166,7 +166,7 @@ "selectFileFailed": "有効な SQL バックアップファイルを選択してください", "configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります", "backupId": "バックアップ ID", - "autoReload": "2 秒後に自動で再読み込みします...", + "autoReload": "データを更新しました", "languageOptionChinese": "中文", "languageOptionEnglish": "English", "languageOptionJapanese": "日本語", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9e5e7bec4..5e6e458f3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -166,7 +166,7 @@ "selectFileFailed": "请选择有效的 SQL 备份文件", "configCorrupted": "SQL 文件可能已损坏或格式不正确", "backupId": "备份ID", - "autoReload": "数据将在2秒后自动刷新...", + "autoReload": "数据已刷新", "languageOptionChinese": "中文", "languageOptionEnglish": "English", "languageOptionJapanese": "日本語", diff --git a/tests/hooks/useImportExport.test.tsx b/tests/hooks/useImportExport.test.tsx index 030c228aa..b35237926 100644 --- a/tests/hooks/useImportExport.test.tsx +++ b/tests/hooks/useImportExport.test.tsx @@ -110,12 +110,6 @@ describe("useImportExport Hook", () => { expect(result.current.status).toBe("success"); expect(result.current.backupId).toBe("backup-123"); expect(toastSuccessMock).toHaveBeenCalledTimes(1); - - // Skip delay to execute callback - await act(async () => { - vi.runOnlyPendingTimers(); - }); - expect(onImportSuccess).toHaveBeenCalledTimes(1); }); From ba59483b3374340b617f72ac26d7751824a78114 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 08:48:59 +0800 Subject: [PATCH 004/126] refactor(proxy): remove global auto-start flag - Remove global proxy auto-start flag from config and UI. - Simplify per-app takeover start/stop and stop server when the last takeover is disabled. - Restore live takeover detection used for crash recovery. - Keep proxy_config.enabled column but always write 0 for compatibility. - Tests: not run (not requested). --- src-tauri/src/commands/proxy.rs | 2 +- src-tauri/src/database/dao/proxy.rs | 17 ++-- src-tauri/src/lib.rs | 90 ++++++-------------- src-tauri/src/proxy/types.rs | 3 - src-tauri/src/services/proxy.rs | 69 +++------------ src/components/proxy/ProxySettingsDialog.tsx | 20 +++-- src/types/proxy.ts | 1 - 7 files changed, 60 insertions(+), 142 deletions(-) diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index b10ce6b2e..05a7b18e0 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -11,7 +11,7 @@ use crate::store::AppState; pub async fn start_proxy_server( state: tauri::State<'_, AppState>, ) -> Result { - state.proxy_service.start(true).await + state.proxy_service.start().await } /// 启动代理服务器(带 Live 配置接管) diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index ef3dc36f9..53bffeeeb 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -16,19 +16,18 @@ impl Database { let result = { let conn = lock_conn!(self.conn); conn.query_row( - "SELECT enabled, listen_address, listen_port, max_retries, + "SELECT listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active FROM proxy_config WHERE id = 1", [], |row| { Ok(ProxyConfig { - enabled: row.get::<_, i32>(0)? != 0, - listen_address: row.get(1)?, - listen_port: row.get::<_, i32>(2)? as u16, - max_retries: row.get::<_, i32>(3)? as u8, - request_timeout: row.get::<_, i32>(4)? as u64, - enable_logging: row.get::<_, i32>(5)? != 0, - live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0, + listen_address: row.get(0)?, + listen_port: row.get::<_, i32>(1)? as u16, + max_retries: row.get::<_, i32>(2)? as u8, + request_timeout: row.get::<_, i32>(3)? as u64, + enable_logging: row.get::<_, i32>(4)? != 0, + live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0, }) }, ) @@ -57,7 +56,7 @@ impl Database { COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), datetime('now'))", rusqlite::params![ - if config.enabled { 1 } else { 0 }, + 0, // 已移除自动启用逻辑,保留列但固定为 0 config.listen_address, config.listen_port as i32, config.max_retries as i32, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b393e4461..7c6a3d723 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -524,12 +524,12 @@ pub fn run() { } } - // 异常退出恢复 + 自动启动代理服务器 + // 异常退出恢复 let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { let state = app_handle.state::(); - // 检查是否有 Live 备份(表示上次有接管状态) + // 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态) let has_backups = match state.db.has_any_live_backup().await { Ok(v) => v, Err(e) => { @@ -537,55 +537,15 @@ pub fn run() { false } }; + // 检查 Live 配置是否仍处于被接管状态(包含占位符) + let live_taken_over = state.proxy_service.detect_takeover_in_live_configs(); - // 获取代理配置 - let proxy_config = match state.db.get_proxy_config().await { - Ok(config) => Some(config), - Err(e) => { - log::error!("启动时获取代理配置失败: {e}"); - None - } - }; - - if has_backups { - // 有备份说明上次退出时有接管状态 - // 检查 Live 配置是否仍处于被接管状态(包含占位符) - let live_taken_over = state.proxy_service.detect_takeover_in_live_configs(); - - if live_taken_over { - // Live 配置仍是接管状态,尝试重新启动代理服务以恢复接管 - log::info!("检测到上次接管状态,正在重新启动代理服务..."); - match state.proxy_service.start(false).await { - Ok(info) => { - log::info!("代理服务器已恢复启动: {}:{}", info.address, info.port); - } - Err(e) => { - // 启动失败,恢复 Live 配置 - log::error!("恢复代理服务失败: {e},正在恢复 Live 配置..."); - if let Err(e) = state.proxy_service.recover_from_crash().await { - log::error!("恢复 Live 配置失败: {e}"); - } else { - log::info!("Live 配置已恢复"); - } - } - } + if has_backups || live_taken_over { + log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置..."); + if let Err(e) = state.proxy_service.recover_from_crash().await { + log::error!("恢复 Live 配置失败: {e}"); } else { - // Live 配置已经是正常状态,清理残留备份 - log::info!("Live 配置已是正常状态,清理残留备份..."); - if let Err(e) = state.db.delete_all_live_backups().await { - log::warn!("清理残留 Live 备份失败: {e}"); - } - } - } else if let Some(config) = proxy_config { - // 没有备份,检查是否需要自动启动代理服务器(总开关) - if config.enabled { - log::info!("代理服务配置为启用,正在启动..."); - match state.proxy_service.start(true).await { - Ok(info) => { - log::info!("代理服务器自动启动成功: {}:{}", info.address, info.port) - } - Err(e) => log::error!("代理服务器自动启动失败: {e}"), - } + log::info!("Live 配置已恢复"); } } }); @@ -855,20 +815,26 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { if proxy_service.is_running().await { log::info!("检测到代理服务器正在运行,开始清理..."); - // 检查是否处于 Live 接管模式 - if let Ok(is_takeover) = state.db.is_live_takeover_active().await { - if is_takeover { - // 接管模式:停止并恢复配置 - if let Err(e) = proxy_service.stop_with_restore().await { - log::error!("退出时恢复 Live 配置失败: {e}"); - } else { - log::info!("已恢复 Live 配置"); - } + // 检查是否存在 Live 备份(有则视为接管) + let has_backups = match state.db.has_any_live_backup().await { + Ok(v) => v, + Err(e) => { + log::error!("退出时检查 Live 备份失败: {e}"); + false + } + }; + + if has_backups { + // 接管模式:停止并恢复配置 + if let Err(e) = proxy_service.stop_with_restore().await { + log::error!("退出时恢复 Live 配置失败: {e}"); } else { - // 非接管模式:仅停止代理 - if let Err(e) = proxy_service.stop().await { - log::error!("退出时停止代理失败: {e}"); - } + log::info!("已恢复 Live 配置"); + } + } else { + // 非接管模式:仅停止代理 + if let Err(e) = proxy_service.stop().await { + log::error!("退出时停止代理失败: {e}"); } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 0b1c73aa5..f00af8de5 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -3,8 +3,6 @@ use serde::{Deserialize, Serialize}; /// 代理服务器配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxyConfig { - /// 是否启用代理服务 - pub enabled: bool, /// 监听地址 pub listen_address: String, /// 监听端口 @@ -23,7 +21,6 @@ pub struct ProxyConfig { impl Default for ProxyConfig { fn default() -> Self { Self { - enabled: false, listen_address: "127.0.0.1".to_string(), listen_port: 15721, // 使用较少占用的高位端口 max_retries: 3, diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 8b867f7c8..a21fbeaf2 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -41,31 +41,16 @@ impl ProxyService { } /// 启动代理服务器 - /// - /// - `persist_enabled = true`:将 `proxy_config.enabled` 持久化为启用(用于“总开关”) - /// - `persist_enabled = false`:仅在当前进程启动代理服务(用于“按 App 接管”自动启动) - pub async fn start(&self, persist_enabled: bool) -> Result { + pub async fn start(&self) -> Result { // 1. 获取配置 - let mut config = self + let config = self .db .get_proxy_config() .await .map_err(|e| format!("获取代理配置失败: {e}"))?; - // 2. 仅在需要时持久化 enabled(避免“按 App 接管”自动启动时误打开总开关) - if persist_enabled { - config.enabled = true; - } - // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息 if let Some(server) = self.server.read().await.as_ref() { - if persist_enabled { - self.db - .update_proxy_config(config) - .await - .map_err(|e| format!("保存代理配置失败: {e}"))?; - } - let status = server.get_status().await; return Ok(ProxyServerInfo { address: status.address, @@ -86,14 +71,6 @@ impl ProxyService { // 5. 保存服务器实例 *self.server.write().await = Some(server); - // 6. 持久化 enabled 状态(仅总开关) - if persist_enabled { - self.db - .update_proxy_config(config) - .await - .map_err(|e| format!("保存代理配置失败: {e}"))?; - } - log::info!("代理服务器已启动: {}:{}", info.address, info.port); Ok(info) } @@ -138,7 +115,7 @@ impl ProxyService { } // 5. 启动代理服务器 - match self.start(true).await { + match self.start().await { Ok(info) => Ok(info), Err(e) => { // 启动失败,恢复原始配置 @@ -187,16 +164,16 @@ impl ProxyService { /// 为指定应用开启/关闭 Live 接管 /// - /// - 开启:自动启动代理服务(不影响总开关持久化),仅接管当前 app 的 Live 配置 - /// - 关闭:仅恢复当前 app 的 Live 配置;若总开关未开启且无其它接管,则自动停止代理服务 + /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置 + /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务 pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> { let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let app_type_str = app.as_str(); if enabled { - // 1) 代理服务未运行则自动启动(不持久化总开关) + // 1) 代理服务未运行则自动启动 if !self.is_running().await { - self.start(false).await?; + self.start().await?; } // 2) 已接管则直接返回(幂等) @@ -252,7 +229,7 @@ impl ProxyService { .await .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; - // 3) 若无其它接管,更新旧标志,并在总开关未开启时停止代理服务 + // 3) 若无其它接管,更新旧标志,并停止代理服务 let has_any_backup = self .db .has_any_live_backup() @@ -261,13 +238,7 @@ impl ProxyService { if !has_any_backup { let _ = self.db.set_live_takeover_active(false).await; - let master_enabled = self - .db - .get_proxy_config() - .await - .map_err(|e| format!("获取代理配置失败: {e}"))? - .enabled; - if !master_enabled && self.is_running().await { + if self.is_running().await { // 此时没有任何 app 处于接管状态,停止服务即可 let _ = self.stop().await; } @@ -495,12 +466,6 @@ impl ProxyService { .await .map_err(|e| format!("停止代理服务器失败: {e}"))?; - // 将 enabled 设为 false,避免下次启动时自动开启 - if let Ok(mut config) = self.db.get_proxy_config().await { - config.enabled = false; - let _ = self.db.update_proxy_config(config).await; - } - log::info!("代理服务器已停止"); Ok(()) } else { @@ -513,15 +478,6 @@ impl ProxyService { // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) if let Err(e) = self.stop().await { log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); - - // stop() 只有在 server 实例存在时才会把 enabled 设为 false; - // 这里兜底确保“总开关关闭”能落盘关闭状态。 - if let Ok(mut config) = self.db.get_proxy_config().await { - if config.enabled { - config.enabled = false; - let _ = self.db.update_proxy_config(config).await; - } - } } // 2. 恢复原始 Live 配置 @@ -946,7 +902,7 @@ impl ProxyService { /// 从异常退出中恢复(启动时调用) /// - /// 检测到 live_takeover_active=true 但代理未运行时调用此方法。 + /// 检测到 Live 备份残留时调用此方法。 /// 会恢复 Live 配置、清除接管标志、删除备份。 pub async fn recover_from_crash(&self) -> Result<(), String> { // 1. 恢复 Live 配置 @@ -970,7 +926,7 @@ impl ProxyService { /// 检测 Live 配置是否处于“被接管”的残留状态 /// - /// 用于兜底处理:当数据库标志未写入成功(或旧版本遗留)但 Live 文件已经写成代理占位符时, + /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时, /// 启动流程可以据此触发恢复逻辑。 pub fn detect_takeover_in_live_configs(&self) -> bool { if let Ok(config) = self.read_claude_live() { @@ -1255,9 +1211,8 @@ impl ProxyService { .await .map_err(|e| format!("获取代理配置失败: {e}"))?; - // 保存到数据库(保持 enabled 和 live_takeover_active 状态不变) + // 保存到数据库(保持 live_takeover_active 状态不变) let mut new_config = config.clone(); - new_config.enabled = previous.enabled; new_config.live_takeover_active = previous.live_takeover_active; self.db diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx index 572e529b0..0a5b8afdb 100644 --- a/src/components/proxy/ProxySettingsDialog.tsx +++ b/src/components/proxy/ProxySettingsDialog.tsx @@ -26,8 +26,11 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import type { ProxyConfig } from "@/types/proxy"; -// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理) -type ProxyConfigForm = Omit; +// 表单数据类型(仅包含可编辑字段) +type ProxyConfigForm = Pick< + ProxyConfig, + "listen_address" | "listen_port" | "max_retries" | "request_timeout" | "enable_logging" +>; const createProxyConfigSchema = (t: TFunction) => { const requestTimeoutSchema = z @@ -120,19 +123,18 @@ export function ProxySettingsDialog({ useEffect(() => { if (config) { form.reset({ - ...config, + listen_address: config.listen_address, + listen_port: config.listen_port, + max_retries: config.max_retries, + request_timeout: config.request_timeout, + enable_logging: config.enable_logging, }); } }, [config, form]); const onSubmit = async (data: ProxyConfigForm) => { try { - // 添加 enabled 字段(从当前配置中获取,保持不变) - const configToSave: ProxyConfig = { - ...data, - enabled: config?.enabled ?? true, - }; - await updateConfig(configToSave); + await updateConfig(data); closePanel(); } catch (error) { console.error("Save config failed:", error); diff --git a/src/types/proxy.ts b/src/types/proxy.ts index b4eec165e..3a5997e3d 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -1,5 +1,4 @@ export interface ProxyConfig { - enabled: boolean; listen_address: string; listen_port: number; max_retries: number; From 3e8f84481d614bfb4ceeaf655cfa5489eb81a10a Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 10:07:04 +0800 Subject: [PATCH 005/126] fix(proxy): add fallback recovery for orphaned takeover state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect takeover residue in Live configs even when proxy is not running - Implement 3-tier fallback: backup → SSOT → cleanup placeholders - Only delete backup after successful restore to prevent data loss - Fix EditProviderDialog to check current app's takeover status only --- src-tauri/src/lib.rs | 50 +++--- src-tauri/src/services/proxy.rs | 288 +++++++++++++++++++++++++++++--- src/App.tsx | 8 +- 3 files changed, 295 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c6a3d723..ce38de831 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -811,33 +811,33 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { if let Some(state) = app_handle.try_state::() { let proxy_service = &state.proxy_service; - // 检查代理是否在运行 - if proxy_service.is_running().await { - log::info!("检测到代理服务器正在运行,开始清理..."); - - // 检查是否存在 Live 备份(有则视为接管) - let has_backups = match state.db.has_any_live_backup().await { - Ok(v) => v, - Err(e) => { - log::error!("退出时检查 Live 备份失败: {e}"); - false - } - }; - - if has_backups { - // 接管模式:停止并恢复配置 - if let Err(e) = proxy_service.stop_with_restore().await { - log::error!("退出时恢复 Live 配置失败: {e}"); - } else { - log::info!("已恢复 Live 配置"); - } - } else { - // 非接管模式:仅停止代理 - if let Err(e) = proxy_service.stop().await { - log::error!("退出时停止代理失败: {e}"); - } + // 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。 + let has_backups = match state.db.has_any_live_backup().await { + Ok(v) => v, + Err(e) => { + log::error!("退出时检查 Live 备份失败: {e}"); + false } + }; + let live_taken_over = proxy_service.detect_takeover_in_live_configs(); + let needs_restore = has_backups || live_taken_over; + if needs_restore { + log::info!("检测到接管残留,开始恢复 Live 配置..."); + if let Err(e) = proxy_service.stop_with_restore().await { + log::error!("退出时恢复 Live 配置失败: {e}"); + } else { + log::info!("已恢复 Live 配置"); + } + return; + } + + // 非接管模式:代理在运行则仅停止代理 + if proxy_service.is_running().await { + log::info!("检测到代理服务器正在运行,开始停止..."); + if let Err(e) = proxy_service.stop().await { + log::error!("退出时停止代理失败: {e}"); + } log::info!("代理服务器清理完成"); } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a21fbeaf2..25172c1ba 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -8,6 +8,7 @@ use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; +use crate::services::provider::write_live_snapshot; use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; @@ -199,8 +200,17 @@ impl ProxyService { // 5) 写入接管配置(仅当前 app) if let Err(e) = self.takeover_live_config_strict(&app).await { log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); - let _ = self.restore_live_config_for_app(&app).await; - let _ = self.db.delete_live_backup(app_type_str).await; + match self.restore_live_config_for_app(&app).await { + Ok(()) => { + // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源 + let _ = self.db.delete_live_backup(app_type_str).await; + } + Err(restore_err) => { + log::error!( + "{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}" + ); + } + } return Err(e); } @@ -865,30 +875,270 @@ impl ProxyService { /// 恢复原始 Live 配置 async fn restore_live_configs(&self) -> Result<(), String> { - // Claude - if let Ok(Some(backup)) = self.db.get_live_backup("claude").await { - let config: Value = serde_json::from_str(&backup.original_config) - .map_err(|e| format!("解析 Claude 备份失败: {e}"))?; - self.write_claude_live(&config)?; - log::info!("Claude Live 配置已恢复"); + let mut errors = Vec::new(); + + for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { + if let Err(e) = self + .restore_live_config_for_app_with_fallback(&app_type) + .await + { + errors.push(e); + } } - // Codex - if let Ok(Some(backup)) = self.db.get_live_backup("codex").await { + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(";")) + } + } + + async fn restore_live_config_for_app_with_fallback( + &self, + app_type: &AppType, + ) -> Result<(), String> { + let app_type_str = app_type.as_str(); + + // 1) 优先从 Live 备份恢复(这是“原始 Live”的唯一可靠来源) + let backup = self + .db + .get_live_backup(app_type_str) + .await + .map_err(|e| format!("获取 {app_type_str} Live 备份失败: {e}"))?; + if let Some(backup) = backup { let config: Value = serde_json::from_str(&backup.original_config) - .map_err(|e| format!("解析 Codex 备份失败: {e}"))?; - self.write_codex_live(&config)?; - log::info!("Codex Live 配置已恢复"); + .map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?; + self.write_live_config_for_app(app_type, &config)?; + log::info!("{app_type_str} Live 配置已从备份恢复"); + return Ok(()); } - // Gemini - if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await { - let config: Value = serde_json::from_str(&backup.original_config) - .map_err(|e| format!("解析 Gemini 备份失败: {e}"))?; - self.write_gemini_live(&config)?; - log::info!("Gemini Live 配置已恢复"); + // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景) + if !self.detect_takeover_in_live_config_for_app(app_type) { + return Ok(()); } + // 2.1) 优先从 SSOT(当前供应商)重建 Live(比“清理字段”更可用) + match self.restore_live_from_ssot_for_app(app_type) { + Ok(true) => { + log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)"); + return Ok(()); + } + Ok(false) => { + log::warn!( + "{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符" + ); + } + Err(e) => { + log::error!( + "{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}" + ); + } + } + + // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态 + self.cleanup_takeover_placeholders_in_live_for_app(app_type)?; + log::info!("{app_type_str} Live 接管占位符已清理(无备份兜底)"); + Ok(()) + } + + fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> { + match app_type { + AppType::Claude => self.write_claude_live(config), + AppType::Codex => self.write_codex_live(config), + AppType::Gemini => self.write_gemini_live(config), + } + } + + fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool { + match app_type { + AppType::Claude => match self.read_claude_live() { + Ok(config) => Self::is_claude_live_taken_over(&config), + Err(_) => false, + }, + AppType::Codex => match self.read_codex_live() { + Ok(config) => Self::is_codex_live_taken_over(&config), + Err(_) => false, + }, + AppType::Gemini => match self.read_gemini_live() { + Ok(config) => Self::is_gemini_live_taken_over(&config), + Err(_) => false, + }, + } + } + + /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。 + /// + /// 返回值: + /// - Ok(true):已成功写回 + /// - Ok(false):缺少当前供应商/供应商不存在,无法写回 + fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result { + let current_id = crate::settings::get_effective_current_provider(&self.db, app_type) + .map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?; + + let Some(current_id) = current_id else { + return Ok(false); + }; + + let providers = self + .db + .get_all_providers(app_type.as_str()) + .map_err(|e| format!("读取 {app_type:?} 供应商列表失败: {e}"))?; + + let Some(provider) = providers.get(¤t_id) else { + return Ok(false); + }; + + write_live_snapshot(app_type, provider) + .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; + + Ok(true) + } + + fn cleanup_takeover_placeholders_in_live_for_app( + &self, + app_type: &AppType, + ) -> Result<(), String> { + match app_type { + AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), + AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), + AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), + } + } + + fn is_local_proxy_url(url: &str) -> bool { + let url = url.trim(); + if !url.starts_with("http://") { + return false; + } + let rest = &url["http://".len()..]; + rest.starts_with("127.0.0.1") + || rest.starts_with("localhost") + || rest.starts_with("0.0.0.0") + || rest.starts_with("[::1]") + || rest.starts_with("[::]") + || rest.starts_with("::1") + || rest.starts_with("::") + } + + fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> { + let mut config = self.read_claude_live()?; + + let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else { + return Ok(()); + }; + + for key in [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + ] { + if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { + env.remove(key); + } + } + + if env + .get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .map(Self::is_local_proxy_url) + .unwrap_or(false) + { + env.remove("ANTHROPIC_BASE_URL"); + } + + self.write_claude_live(&config)?; + Ok(()) + } + + fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> { + let mut config = self.read_codex_live()?; + + if let Some(auth) = config.get_mut("auth").and_then(|v| v.as_object_mut()) { + if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) + { + auth.remove("OPENAI_API_KEY"); + } + } + + if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) { + let updated = Self::remove_local_toml_base_url(cfg_str); + config["config"] = json!(updated); + } + + self.write_codex_live(&config)?; + Ok(()) + } + + fn remove_local_toml_base_url(toml_str: &str) -> String { + use toml_edit::DocumentMut; + + let mut doc = match toml_str.parse::() { + Ok(doc) => doc, + Err(_) => return toml_str.to_string(), + }; + + let model_provider = doc + .get("model_provider") + .and_then(|item| item.as_str()) + .map(str::to_string); + + if let Some(provider_key) = model_provider { + if let Some(model_providers) = doc + .get_mut("model_providers") + .and_then(|v| v.as_table_mut()) + { + if let Some(provider_table) = model_providers + .get_mut(provider_key.as_str()) + .and_then(|v| v.as_table_mut()) + { + let should_remove = provider_table + .get("base_url") + .and_then(|item| item.as_str()) + .map(Self::is_local_proxy_url) + .unwrap_or(false); + if should_remove { + provider_table.remove("base_url"); + } + } + } + } + + // 兜底:清理顶层 base_url(仅当它看起来像本地代理地址) + let should_remove_root = doc + .get("base_url") + .and_then(|item| item.as_str()) + .map(Self::is_local_proxy_url) + .unwrap_or(false); + if should_remove_root { + doc.as_table_mut().remove("base_url"); + } + + doc.to_string() + } + + fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> { + let mut config = self.read_gemini_live()?; + + let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else { + return Ok(()); + }; + + if env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { + env.remove("GEMINI_API_KEY"); + } + + if env + .get("GOOGLE_GEMINI_BASE_URL") + .and_then(|v| v.as_str()) + .map(Self::is_local_proxy_url) + .unwrap_or(false) + { + env.remove("GOOGLE_GEMINI_BASE_URL"); + } + + self.write_gemini_live(&config)?; Ok(()) } diff --git a/src/App.tsx b/src/App.tsx index 15255766f..6572f0756 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,12 +70,6 @@ function App() { const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus(); // 当前应用的代理是否开启 const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; - // 任意应用的代理是否开启 - const isTakeoverActive = - takeoverStatus?.claude || - takeoverStatus?.codex || - takeoverStatus?.gemini || - false; // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { @@ -605,7 +599,7 @@ function App() { }} onSubmit={handleEditProvider} appId={activeApp} - isProxyTakeover={isProxyRunning && isTakeoverActive} + isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} /> {usageProvider && ( From 8ecb41d25e0051f81d65dc1c514b09a8475ffb8d Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 11:04:07 +0800 Subject: [PATCH 006/126] fix(proxy): respect existing token field when syncing Claude config - Add support for ANTHROPIC_API_KEY in Claude auth extraction - Only update existing token fields during sync, avoid adding fields that weren't originally configured by the user - Add tests for both scenarios --- src-tauri/src/proxy/providers/claude.rs | 23 +++ src-tauri/src/services/proxy.rs | 196 +++++++++++++++++++++--- 2 files changed, 198 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index a9bc2e567..57ca328c7 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -87,6 +87,14 @@ impl ClaudeAdapter { log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); return Some(key.to_string()); } + if let Some(key) = env + .get("ANTHROPIC_API_KEY") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + log::debug!("[Claude] 使用 ANTHROPIC_API_KEY"); + return Some(key.to_string()); + } // OpenRouter key if let Some(key) = env .get("OPENROUTER_API_KEY") @@ -284,6 +292,21 @@ mod tests { assert_eq!(auth.strategy, AuthStrategy::Anthropic); } + #[test] + fn test_extract_auth_anthropic_api_key() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_API_KEY": "sk-ant-test-key" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-ant-test-key"); + assert_eq!(auth.strategy, AuthStrategy::Anthropic); + } + #[test] fn test_extract_auth_openrouter() { let adapter = ClaudeAdapter::new(); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 25172c1ba..7c720a9ad 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -312,34 +312,35 @@ impl ProxyService { match env_obj { Some(obj) => { - obj.insert(token_key.to_string(), json!(token)); - // ANTHROPIC_AUTH_TOKEN 与 ANTHROPIC_API_KEY 视为同义字段,保持一致 if token_key == "ANTHROPIC_AUTH_TOKEN" || token_key == "ANTHROPIC_API_KEY" { - obj.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - json!(token), - ); - obj.insert( - "ANTHROPIC_API_KEY".to_string(), - json!(token), - ); + let mut updated = false; + if obj.contains_key("ANTHROPIC_AUTH_TOKEN") { + obj.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + json!(token), + ); + updated = true; + } + if obj.contains_key("ANTHROPIC_API_KEY") { + obj.insert( + "ANTHROPIC_API_KEY".to_string(), + json!(token), + ); + updated = true; + } + if !updated { + obj.insert(token_key.to_string(), json!(token)); + } + } else { + obj.insert(token_key.to_string(), json!(token)); } } None => { // 至少写入一份可用的 Token - provider.settings_config["env"] = json!({ - token_key: token - }); - if token_key == "ANTHROPIC_AUTH_TOKEN" - || token_key == "ANTHROPIC_API_KEY" - { - provider.settings_config["env"] - ["ANTHROPIC_AUTH_TOKEN"] = json!(token); - provider.settings_config["env"]["ANTHROPIC_API_KEY"] = - json!(token); - } + provider.settings_config["env"] = + json!({ token_key: token }); } } @@ -1575,6 +1576,47 @@ impl ProxyService { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + use std::env; + use tempfile::TempDir; + + struct TempHome { + #[allow(dead_code)] + dir: TempDir, + original_home: Option, + original_userprofile: Option, + } + + impl TempHome { + fn new() -> Self { + let dir = TempDir::new().expect("failed to create temp home"); + let original_home = env::var("HOME").ok(); + let original_userprofile = env::var("USERPROFILE").ok(); + + env::set_var("HOME", dir.path()); + env::set_var("USERPROFILE", dir.path()); + + Self { + dir, + original_home, + original_userprofile, + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + match &self.original_home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + + match &self.original_userprofile { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + } + } #[test] fn update_toml_base_url_updates_active_model_provider_base_url() { @@ -1637,4 +1679,116 @@ model = "gpt-5.1-codex" assert_eq!(base_url, new_url); } + + #[tokio::test] + #[serial] + async fn sync_claude_token_does_not_add_anthropic_api_key() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + let provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "stale" + } + }), + None, + ); + db.save_provider("claude", &provider) + .expect("save provider"); + db.set_current_provider("claude", "p1") + .expect("set current provider"); + + let live_config = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "fresh" + } + }); + + service + .sync_live_config_to_provider(&AppType::Claude, &live_config) + .await + .expect("sync"); + + let updated = db + .get_provider_by_id("p1", "claude") + .expect("get provider") + .expect("provider exists"); + let env = updated + .settings_config + .get("env") + .and_then(|v| v.as_object()) + .expect("env object"); + + assert_eq!( + env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()), + Some("fresh") + ); + assert!( + !env.contains_key("ANTHROPIC_API_KEY"), + "should not add ANTHROPIC_API_KEY when absent" + ); + } + + #[tokio::test] + #[serial] + async fn sync_claude_token_respects_existing_api_key_field() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + let provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_API_KEY": "stale" + } + }), + None, + ); + db.save_provider("claude", &provider) + .expect("save provider"); + db.set_current_provider("claude", "p1") + .expect("set current provider"); + + let live_config = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "fresh" + } + }); + + service + .sync_live_config_to_provider(&AppType::Claude, &live_config) + .await + .expect("sync"); + + let updated = db + .get_provider_by_id("p1", "claude") + .expect("get provider") + .expect("provider exists"); + let env = updated + .settings_config + .get("env") + .and_then(|v| v.as_object()) + .expect("env object"); + + assert_eq!( + env.get("ANTHROPIC_API_KEY").and_then(|v| v.as_str()), + Some("fresh") + ); + assert!( + !env.contains_key("ANTHROPIC_AUTH_TOKEN"), + "should not add ANTHROPIC_AUTH_TOKEN when absent" + ); + } } From 64e0cabaa787460c2390d5185e876061a397b13a Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 11:19:26 +0800 Subject: [PATCH 007/126] fix(window): add minWidth/minHeight to Windows platform config Tauri 2.0 platform config merging is shallow, not deep. The Windows config only specified titleBarStyle, causing minWidth/minHeight to be missing on Windows. This allowed users to resize the window below 900px, causing header elements to misalign. --- src-tauri/tauri.windows.conf.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 0f64fc6fd..4461d5fb8 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -4,7 +4,9 @@ "windows": [ { "label": "main", - "titleBarStyle": "Visible" + "titleBarStyle": "Visible", + "minWidth": 900, + "minHeight": 600 } ] } From c4535c894a5051db9dcea00fe3d7e79902f0e74e Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 12:13:39 +0800 Subject: [PATCH 008/126] refactor(proxy): switch OpenRouter to passthrough mode for native Claude API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter now supports Claude Code compatible endpoint (/v1/messages), eliminating the need for Anthropic ↔ OpenAI format conversion. - Disable format transformation for OpenRouter (keep old logic as fallback) - Pass through original endpoint instead of redirecting to /v1/chat/completions - Add anthropic-version header for ClaudeAuth and Bearer strategies - Update tests to reflect new passthrough behavior --- src-tauri/src/proxy/handlers.rs | 8 ++-- src-tauri/src/proxy/providers/adapter.rs | 2 +- src-tauri/src/proxy/providers/claude.rs | 49 ++++++++++++++---------- src-tauri/src/proxy/providers/mod.rs | 12 ++++-- 4 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 029671dfc..faeefec01 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -5,7 +5,7 @@ //! 重构后的结构: //! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块 //! - 各 handler 只保留独特的业务逻辑 -//! - Claude 的格式转换逻辑保留在此文件(独有功能) +//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退) use super::{ error_mapper::{get_error_message, map_proxy_error_to_status}, @@ -54,8 +54,8 @@ pub async fn get_status(State(state): State) -> Result, headers: axum::http::HeaderMap, @@ -112,7 +112,7 @@ pub async fn handle_messages( /// Claude 格式转换处理(独有逻辑) /// -/// 处理 OpenRouter 等需要格式转换的中转服务 +/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用) async fn handle_claude_transform( response: reqwest::Response, ctx: &RequestContext, diff --git a/src-tauri/src/proxy/providers/adapter.rs b/src-tauri/src/proxy/providers/adapter.rs index c0372e03f..6393cf62a 100644 --- a/src-tauri/src/proxy/providers/adapter.rs +++ b/src-tauri/src/proxy/providers/adapter.rs @@ -87,7 +87,7 @@ pub trait ProviderAdapter: Send + Sync { /// 是否需要格式转换 /// /// 默认返回 `false`(透传模式)。 - /// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。 + /// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。 /// /// # Arguments /// * `provider` - Provider 配置 diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 57ca328c7..ff7a55921 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -5,7 +5,7 @@ //! ## 认证模式 //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) -//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换 +//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用) use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use crate::provider::Provider; @@ -28,10 +28,8 @@ impl ClaudeAdapter { /// - Claude: 默认 Anthropic 官方 pub fn provider_type(&self, provider: &Provider) -> ProviderType { // 检测 OpenRouter - if let Ok(base_url) = self.extract_base_url(provider) { - if base_url.contains("openrouter.ai") { - return ProviderType::OpenRouter; - } + if self.is_openrouter(provider) { + return ProviderType::OpenRouter; } // 检测 ClaudeAuth (仅 Bearer 认证) @@ -194,12 +192,17 @@ impl ProviderAdapter for ClaudeAdapter { } fn build_url(&self, base_url: &str, endpoint: &str) -> String { - // OpenRouter 使用 /v1/chat/completions - if base_url.contains("openrouter.ai") { - return format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); - } + // NOTE: + // 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages` + // 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 + // + // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 + // 如需回退旧逻辑,可恢复下面这段分支: + // + // if base_url.contains("openrouter.ai") { + // return format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + // } - // Anthropic 直连 format!( "{}/{}", base_url.trim_end_matches('/'), @@ -215,19 +218,25 @@ impl ProviderAdapter for ClaudeAdapter { .header("x-api-key", &auth.api_key) .header("anthropic-version", "2023-06-01"), // ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key - AuthStrategy::ClaudeAuth => { - request.header("Authorization", format!("Bearer {}", auth.api_key)) - } + AuthStrategy::ClaudeAuth => request + .header("Authorization", format!("Bearer {}", auth.api_key)) + .header("anthropic-version", "2023-06-01"), // OpenRouter: Bearer - AuthStrategy::Bearer => { - request.header("Authorization", format!("Bearer {}", auth.api_key)) - } + AuthStrategy::Bearer => request + .header("Authorization", format!("Bearer {}", auth.api_key)) + .header("anthropic-version", "2023-06-01"), _ => request, } } - fn needs_transform(&self, provider: &Provider) -> bool { - self.is_openrouter(provider) + fn needs_transform(&self, _provider: &Provider) -> bool { + // NOTE: + // OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用 + // Anthropic ↔ OpenAI 的格式转换。 + // + // 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行: + // self.is_openrouter(_provider) + false } fn transform_request( @@ -401,7 +410,7 @@ mod tests { fn test_build_url_openrouter() { let adapter = ClaudeAdapter::new(); let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); - assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions"); + assert_eq!(url, "https://openrouter.ai/api/v1/messages"); } #[test] @@ -420,6 +429,6 @@ mod tests { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" } })); - assert!(adapter.needs_transform(&openrouter_provider)); + assert!(!adapter.needs_transform(&openrouter_provider)); } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index f604f60a0..ee59c0078 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -48,17 +48,21 @@ pub enum ProviderType { Gemini, /// Google Gemini CLI (OAuth Bearer) GeminiCli, - /// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换) + /// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用) OpenRouter, } impl ProviderType { /// 是否需要格式转换 /// - /// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式 + /// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式; + /// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。 #[allow(dead_code)] pub fn needs_transform(&self) -> bool { - matches!(self, ProviderType::OpenRouter) + match self { + ProviderType::OpenRouter => false, + _ => false, + } } /// 获取默认端点 @@ -215,7 +219,7 @@ mod tests { assert!(!ProviderType::Codex.needs_transform()); assert!(!ProviderType::Gemini.needs_transform()); assert!(!ProviderType::GeminiCli.needs_transform()); - assert!(ProviderType::OpenRouter.needs_transform()); + assert!(!ProviderType::OpenRouter.needs_transform()); } #[test] From 4a1a997935b11cedc133994d8727ac71e81e5069 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 12:43:59 +0800 Subject: [PATCH 009/126] feat(icons): add provider icons for OpenRouter, LongCat, ModelScope, AiHubMix - Add SVG icons for OpenRouter, LongCat, ModelScope, and AiHubMix - Register icons in index.ts and metadata.ts with search keywords - Link icons to corresponding provider presets in claudeProviderPresets.ts --- src/config/claudeProviderPresets.ts | 8 ++++++- src/icons/extracted/aihubmix-color.svg | 1 + src/icons/extracted/index.ts | 4 ++++ src/icons/extracted/longcat-color.svg | 1 + src/icons/extracted/metadata.ts | 28 ++++++++++++++++++++++++ src/icons/extracted/modelscope-color.svg | 1 + src/icons/extracted/openrouter.svg | 1 + 7 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/icons/extracted/aihubmix-color.svg create mode 100644 src/icons/extracted/longcat-color.svg create mode 100644 src/icons/extracted/modelscope-color.svg create mode 100644 src/icons/extracted/openrouter.svg diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index a4b41d108..ec3e2f8ad 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -185,6 +185,8 @@ export const providerPresets: ProviderPreset[] = [ }, }, category: "aggregator", + icon: "modelscope", + iconColor: "#624AFF", }, { name: "KAT-Coder", @@ -228,6 +230,8 @@ export const providerPresets: ProviderPreset[] = [ }, }, category: "cn_official", + icon: "longcat", + iconColor: "#29E154", }, { name: "MiniMax", @@ -330,6 +334,8 @@ export const providerPresets: ProviderPreset[] = [ // 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖 endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"], category: "aggregator", + icon: "aihubmix", + iconColor: "#006FFB", }, { name: "DMXAPI", @@ -381,6 +387,6 @@ export const providerPresets: ProviderPreset[] = [ }, category: "aggregator", icon: "openrouter", - iconColor: "#6366F1", + iconColor: "#6566F1", }, ]; diff --git a/src/icons/extracted/aihubmix-color.svg b/src/icons/extracted/aihubmix-color.svg new file mode 100644 index 000000000..82ba4ca88 --- /dev/null +++ b/src/icons/extracted/aihubmix-color.svg @@ -0,0 +1 @@ +AiHubMix \ No newline at end of file diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index cac4d5aa4..507a41a7b 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -45,6 +45,10 @@ export const icons: Record = { yi: `Yi`, zeroone: `01.AI`, zhipu: `Zhipu`, + openrouter: `OpenRouter`, + longcat: `LongCat`, + modelscope: `ModelScope`, + aihubmix: `AiHubMix`, }; export const iconList = Object.keys(icons); diff --git a/src/icons/extracted/longcat-color.svg b/src/icons/extracted/longcat-color.svg new file mode 100644 index 000000000..fde6c8a36 --- /dev/null +++ b/src/icons/extracted/longcat-color.svg @@ -0,0 +1 @@ +LongCat \ No newline at end of file diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 5aa70373a..c0a9de38c 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -303,6 +303,34 @@ export const iconMetadata: Record = { keywords: ["chatglm", "glm"], defaultColor: "#0F62FE", }, + openrouter: { + name: "openrouter", + displayName: "OpenRouter", + category: "ai-provider", + keywords: ["openrouter", "router", "aggregator"], + defaultColor: "#6566F1", + }, + longcat: { + name: "longcat", + displayName: "LongCat", + category: "ai-provider", + keywords: ["longcat", "long", "cat"], + defaultColor: "#29E154", + }, + modelscope: { + name: "modelscope", + displayName: "ModelScope", + category: "ai-provider", + keywords: ["modelscope", "alibaba", "scope"], + defaultColor: "#624AFF", + }, + aihubmix: { + name: "aihubmix", + displayName: "AiHubMix", + category: "ai-provider", + keywords: ["aihubmix", "hub", "mix", "aggregator"], + defaultColor: "#006FFB", + }, }; export function getIconMetadata(name: string): IconMetadata | undefined { diff --git a/src/icons/extracted/modelscope-color.svg b/src/icons/extracted/modelscope-color.svg new file mode 100644 index 000000000..afbaa171a --- /dev/null +++ b/src/icons/extracted/modelscope-color.svg @@ -0,0 +1 @@ +ModelScope \ No newline at end of file diff --git a/src/icons/extracted/openrouter.svg b/src/icons/extracted/openrouter.svg new file mode 100644 index 000000000..e6cca2a86 --- /dev/null +++ b/src/icons/extracted/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file From b2a9e91d7086180931d16e1da88e60507a917e98 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 13:23:15 +0800 Subject: [PATCH 010/126] feat(providers): add DMXAPI as official partner Mark DMXAPI as partner in both Claude and Codex presets with promotion message for their Claude Code exclusive model 66% OFF offer. --- src/config/claudeProviderPresets.ts | 2 ++ src/config/codexProviderPresets.ts | 2 ++ src/i18n/locales/en.json | 3 ++- src/i18n/locales/ja.json | 3 ++- src/i18n/locales/zh.json | 3 ++- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index ec3e2f8ad..851e9e794 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -350,6 +350,8 @@ export const providerPresets: ProviderPreset[] = [ // 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖 endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"], category: "aggregator", + isPartner: true, // 合作伙伴 + partnerPromotionKey: "dmxapi", // 促销信息 i18n key }, { name: "PackyCode", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index e6ceb2da3..053d2be37 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -131,6 +131,8 @@ requires_openai_auth = true`, "gpt-5.1-codex", ), endpointCandidates: ["https://www.dmxapi.cn/v1"], + isPartner: true, // 合作伙伴 + partnerPromotionKey: "dmxapi", // 促销信息 i18n key }, { name: "PackyCode", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 03d8f3cf7..63fea6c41 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -273,7 +273,8 @@ "zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount", "packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off", "minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9", - "minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)" + "minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)", + "dmxapi": "Claude Code exclusive model 66% OFF now!" }, "parameterConfig": "Parameter Config - {{name}} *", "mainModel": "Main Model (optional)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 36076dbf1..1b3f178e1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -273,7 +273,8 @@ "zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引", "packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ", "minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から", - "minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)" + "minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)", + "dmxapi": "Claude Code 専用モデル 66% OFF 実施中!" }, "parameterConfig": "パラメーター設定 - {{name}} *", "mainModel": "メインモデル(任意)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5e6e458f3..5f2e8a0de 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -273,7 +273,8 @@ "zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠", "packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠", "minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起", - "minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)" + "minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)", + "dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!" }, "parameterConfig": "参数配置 - {{name}} *", "mainModel": "主模型 (可选)", From 5fe5ed98be9289703db17e3342360986cba4421c Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 16:24:25 +0800 Subject: [PATCH 011/126] style(header): unify height and styling of header toolbar sections - Use consistent h-8 fixed height for all inner elements - Standardize border-radius to rounded-xl across all sections - Remove background from ProxyToggle for cleaner appearance - Simplify ProxyToggle structure with nested container --- src/components/AppSwitcher.tsx | 8 ++--- src/components/proxy/ProxyToggle.tsx | 53 ++++++++++++++-------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 76c4da5d3..7f2f86d19 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -24,11 +24,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { }; return ( -
+
); } From 44ca688253de79990f9d11a45253017979dd6222 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 18:10:45 +0800 Subject: [PATCH 012/126] chore: bump version to 3.9.0-2 for second test release - Update version in package.json, Cargo.toml, tauri.conf.json - Fix clippy too_many_arguments warning in forwarder.rs --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/proxy/forwarder.rs | 1 + src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 3b39356a5..13e355a9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.9.0-1", + "version": "3.9.0-2", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "scripts": { "dev": "pnpm tauri dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8bc67b5e8..a19bf1f2b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -695,7 +695,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.9.0-1" +version = "3.9.0-2" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f4a15aa5b..596f59f40 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.9.0-1" +version = "3.9.0-2" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index a91dc593d..b9b8fe8e5 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -34,6 +34,7 @@ pub struct RequestForwarder { } impl RequestForwarder { + #[allow(clippy::too_many_arguments)] pub fn new( router: Arc, timeout_secs: u64, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d1cdc9a67..adfe7b661 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Switch", - "version": "3.9.0-1", + "version": "3.9.0-2", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", From 3da5525c7945133594ecc52b48b1585f6fa13f48 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 18:57:36 +0800 Subject: [PATCH 013/126] fix(ui): improve text visibility in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded gray color classes with semantic color classes to fix poor text contrast in dark mode: - MCP panel: server names, descriptions, tags, app labels - Prompt panel: prompt names, descriptions, empty states - Usage footer: timestamps, refresh buttons - Update badge: close button icon - API key input: disabled state text - Env warning banner: source info text Changes: - `text-gray-400 dark:text-gray-500` → `text-muted-foreground` (fixes reversed dark mode logic) - `text-gray-500 dark:text-gray-400` → `text-muted-foreground` - `bg-gray-100 dark:bg-gray-800` → `bg-muted` --- src/components/UpdateBadge.tsx | 2 +- src/components/UsageFooter.tsx | 6 ++--- src/components/env/EnvWarningBanner.tsx | 2 +- src/components/mcp/UnifiedMcpPanel.tsx | 22 +++++++++---------- src/components/prompts/PromptListItem.tsx | 4 ++-- src/components/prompts/PromptPanel.tsx | 10 ++++----- .../providers/forms/ApiKeyInput.tsx | 2 +- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/components/UpdateBadge.tsx b/src/components/UpdateBadge.tsx index 30d4d7adc..67244714e 100644 --- a/src/components/UpdateBadge.tsx +++ b/src/components/UpdateBadge.tsx @@ -56,7 +56,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) { " aria-label={t("common.close")} > - +
); diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index f20600948..ca5b3d112 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -114,7 +114,7 @@ const UsageFooter: React.FC = ({ {/* 第一行:更新时间和刷新按钮 */}
{/* 上次查询时间 */} - + {lastQueriedAt ? formatRelativeTime(lastQueriedAt, now, t) @@ -128,7 +128,7 @@ const UsageFooter: React.FC = ({ refetch(); }} disabled={loading} - className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500" + className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground" title={t("usage.refreshUsage")} > @@ -191,7 +191,7 @@ const UsageFooter: React.FC = ({
{/* 自动查询时间提示 */} {lastQueriedAt && ( - + {formatRelativeTime(lastQueriedAt, now, t)} diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx index 55c5621fa..20d4e8d76 100644 --- a/src/components/env/EnvWarningBanner.tsx +++ b/src/components/env/EnvWarningBanner.tsx @@ -198,7 +198,7 @@ export function EnvWarningBanner({

{t("env.field.value")}: {conflict.varValue}

-

+

{t("env.field.source")}:{" "} {getSourceDescription(conflict)}

diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 08f6c340b..9f765bbae 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -129,18 +129,18 @@ const UnifiedMcpPanel = React.forwardRef< {/* Content - Scrollable */}
{isLoading ? ( -
+
{t("mcp.loading")}
) : serverEntries.length === 0 ? (
-
- +
+
-

+

{t("mcp.unifiedPanel.noServers")}

-

+

{t("mcp.emptyDescription")}

@@ -237,7 +237,7 @@ const UnifiedMcpListItem: React.FC = ({ {/* 左侧:服务器信息 */}
-

+

{name}

{docsUrl && ( @@ -253,12 +253,12 @@ const UnifiedMcpListItem: React.FC = ({ )}
{description && ( -

+

{description}

)} {!description && tags && tags.length > 0 && ( -

+

{tags.join(", ")}

)} @@ -269,7 +269,7 @@ const UnifiedMcpListItem: React.FC = ({
@@ -285,7 +285,7 @@ const UnifiedMcpListItem: React.FC = ({
@@ -301,7 +301,7 @@ const UnifiedMcpListItem: React.FC = ({
diff --git a/src/components/prompts/PromptListItem.tsx b/src/components/prompts/PromptListItem.tsx index 1ba298bc4..36e1186a0 100644 --- a/src/components/prompts/PromptListItem.tsx +++ b/src/components/prompts/PromptListItem.tsx @@ -36,11 +36,11 @@ const PromptListItem: React.FC = ({
-

+

{prompt.name}

{prompt.description && ( -

+

{prompt.description}

)} diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 12b9b2b95..88822b3ac 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -108,21 +108,21 @@ const PromptPanel = React.forwardRef(
{loading ? ( -
+
{t("prompts.loading")}
) : promptEntries.length === 0 ? (
-
+
-

+

{t("prompts.empty")}

-

+

{t("prompts.emptyDescription")}

diff --git a/src/components/providers/forms/ApiKeyInput.tsx b/src/components/providers/forms/ApiKeyInput.tsx index 87939e287..0c4a1ef77 100644 --- a/src/components/providers/forms/ApiKeyInput.tsx +++ b/src/components/providers/forms/ApiKeyInput.tsx @@ -30,7 +30,7 @@ const ApiKeyInput: React.FC = ({ const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${ disabled - ? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed" + ? "bg-muted border-border-default text-muted-foreground cursor-not-allowed" : "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20" }`; From ec649e7718e6098ec38e5a4a93fde0b17b5d7d3d Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 19:18:29 +0800 Subject: [PATCH 014/126] style(switch): improve dark mode appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track (unchecked): lighten in light mode (gray-300 → gray-200), darken in dark mode (gray-700 → gray-900) to blend with background - Thumb: soften in dark mode (white → gray-400) to reduce glare --- src/components/ui/switch.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index 2482958fa..f66c1328f 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -9,14 +9,14 @@ const Switch = React.forwardRef< From 2fb3b5405a9ac49b4f5eb6d12472654ea3cfbe47 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 21:38:37 +0800 Subject: [PATCH 015/126] i18n: complete internationalization for v3.8+ features - Add health status translations (operational, degraded, failed, circuitOpen) - Add proxy panel translations (serviceAddress, stats, stopped state) - Add usage filter translations (appType, statusCode, searchPlaceholder) - Add providerIcon click hints (clickToChange, clickToSelect) - Add config load error translations for main.tsx - Complete Japanese proxy section (failoverQueue, autoFailover) - Fix date/time locale in usage charts and tables - Use t() function in all hardcoded UI strings --- .../providers/HealthStatusIndicator.tsx | 14 ++- .../providers/ProviderHealthBadge.tsx | 22 ++++- .../providers/forms/BasicFormFields.tsx | 12 ++- .../forms/ProviderPresetSelector.tsx | 4 +- src/components/proxy/ProxyPanel.tsx | 60 ++++++++---- .../settings/ImportExportSection.tsx | 2 +- src/components/usage/PricingEditModal.tsx | 8 +- src/components/usage/RequestDetailPanel.tsx | 12 ++- src/components/usage/RequestLogTable.tsx | 32 +++++-- src/components/usage/UsageSummaryCards.tsx | 34 +++---- src/components/usage/UsageTrendChart.tsx | 12 ++- src/hooks/useProxyConfig.ts | 10 +- src/hooks/useProxyStatus.ts | 19 +++- src/i18n/locales/en.json | 51 +++++++++- src/i18n/locales/ja.json | 96 ++++++++++++++++++- src/i18n/locales/zh.json | 51 +++++++++- src/main.tsx | 16 +++- 17 files changed, 373 insertions(+), 82 deletions(-) diff --git a/src/components/providers/HealthStatusIndicator.tsx b/src/components/providers/HealthStatusIndicator.tsx index 8b87601fc..0fd319f2c 100644 --- a/src/components/providers/HealthStatusIndicator.tsx +++ b/src/components/providers/HealthStatusIndicator.tsx @@ -1,6 +1,7 @@ import React from "react"; import { cn } from "@/lib/utils"; import type { HealthStatus } from "@/lib/api/model-test"; +import { useTranslation } from "react-i18next"; interface HealthStatusIndicatorProps { status: HealthStatus; @@ -11,17 +12,20 @@ interface HealthStatusIndicatorProps { const statusConfig = { operational: { color: "bg-emerald-500", - label: "正常", + labelKey: "health.operational", + labelFallback: "正常", textColor: "text-emerald-600 dark:text-emerald-400", }, degraded: { color: "bg-yellow-500", - label: "降级", + labelKey: "health.degraded", + labelFallback: "降级", textColor: "text-yellow-600 dark:text-yellow-400", }, failed: { color: "bg-red-500", - label: "失败", + labelKey: "health.failed", + labelFallback: "失败", textColor: "text-red-600 dark:text-red-400", }, }; @@ -31,13 +35,15 @@ export const HealthStatusIndicator: React.FC = ({ responseTimeMs, className, }) => { + const { t } = useTranslation(); const config = statusConfig[status]; + const label = t(config.labelKey, { defaultValue: config.labelFallback }); return (
- {config.label} + {label} {responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
diff --git a/src/components/providers/ProviderHealthBadge.tsx b/src/components/providers/ProviderHealthBadge.tsx index 1a9f901d7..aa6b55ad6 100644 --- a/src/components/providers/ProviderHealthBadge.tsx +++ b/src/components/providers/ProviderHealthBadge.tsx @@ -1,5 +1,6 @@ import { cn } from "@/lib/utils"; import { ProviderHealthStatus } from "@/types/proxy"; +import { useTranslation } from "react-i18next"; interface ProviderHealthBadgeProps { consecutiveFailures: number; @@ -14,11 +15,14 @@ export function ProviderHealthBadge({ consecutiveFailures, className, }: ProviderHealthBadgeProps) { + const { t } = useTranslation(); + // 根据失败次数计算状态 const getStatus = () => { if (consecutiveFailures === 0) { return { - label: "正常", + labelKey: "health.operational", + labelFallback: "正常", status: ProviderHealthStatus.Healthy, color: "bg-green-500", // 使用更深/柔和的背景色,去除可能的白色内容感 @@ -27,7 +31,8 @@ export function ProviderHealthBadge({ }; } else if (consecutiveFailures < 5) { return { - label: "降级", + labelKey: "health.degraded", + labelFallback: "降级", status: ProviderHealthStatus.Degraded, color: "bg-yellow-500", bgColor: "bg-yellow-500/10", @@ -35,7 +40,8 @@ export function ProviderHealthBadge({ }; } else { return { - label: "熔断", + labelKey: "health.circuitOpen", + labelFallback: "熔断", status: ProviderHealthStatus.Failed, color: "bg-red-500", bgColor: "bg-red-500/10", @@ -45,6 +51,9 @@ export function ProviderHealthBadge({ }; const statusConfig = getStatus(); + const label = t(statusConfig.labelKey, { + defaultValue: statusConfig.labelFallback, + }); return (
- {statusConfig.label} + {label}
); } diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 7ef94f77c..82477c2a3 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -52,7 +52,15 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
@@ -70,16 +74,23 @@ export function ProxyPanel() { navigator.clipboard.writeText( `http://${status.address}:${status.port}`, ); - toast.success("地址已复制", { closeButton: true }); + toast.success( + t("proxy.panel.addressCopied", { + defaultValue: "地址已复制", + }), + { closeButton: true }, + ); }} > - 复制 + {t("common.copy")}
-

使用中

+

+ {t("provider.inUse")} +

{status.active_targets && status.active_targets.length > 0 ? (
{status.active_targets.map((target) => ( @@ -101,14 +112,18 @@ export function ProxyPanel() {
) : status.current_provider ? (

- 当前 Provider:{" "} + {t("proxy.panel.currentProvider", { + defaultValue: "当前 Provider:", + })}{" "} {status.current_provider}

) : (

- 当前 Provider:等待首次请求… + {t("proxy.panel.waitingFirstRequest", { + defaultValue: "当前 Provider:等待首次请求…", + })}

)}
@@ -121,7 +136,7 @@ export function ProxyPanel() {

- 故障转移队列 + {t("proxy.failoverQueue.title")}

@@ -179,23 +194,31 @@ export function ProxyPanel() {
} - label="活跃连接" + label={t("proxy.panel.stats.activeConnections", { + defaultValue: "活跃连接", + })} value={status.active_connections} /> } - label="总请求数" + label={t("proxy.panel.stats.totalRequests", { + defaultValue: "总请求数", + })} value={status.total_requests} /> } - label="成功率" + label={t("proxy.panel.stats.successRate", { + defaultValue: "成功率", + })} value={`${status.success_rate.toFixed(1)}%`} variant={status.success_rate > 90 ? "success" : "warning"} /> } - label="运行时间" + label={t("proxy.panel.stats.uptime", { + defaultValue: "运行时间", + })} value={formatUptime(status.uptime_seconds)} />
@@ -206,10 +229,12 @@ export function ProxyPanel() {

- 代理服务已停止 + {t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止" })}

- 使用右上角开关即可启动服务 + {t("proxy.panel.stoppedDescription", { + defaultValue: "使用右上角开关即可启动服务", + })}

)} @@ -319,6 +346,7 @@ function ProviderQueueItem({ appType, isCurrent, }: ProviderQueueItemProps) { + const { t } = useTranslation(); const { data: health } = useProviderHealth(provider.id, appType); return ( @@ -344,7 +372,7 @@ function ProviderQueueItem({ {isCurrent && ( - 使用中 + {t("provider.inUse")} )}
diff --git a/src/components/settings/ImportExportSection.tsx b/src/components/settings/ImportExportSection.tsx index ad70a6ac4..4dfc5d712 100644 --- a/src/components/settings/ImportExportSection.tsx +++ b/src/components/settings/ImportExportSection.tsx @@ -93,7 +93,7 @@ export function ImportExportSection({ type="button" onClick={onClear} className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10" - aria-label="Clear selection" + aria-label={t("common.clear")} > diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx index 79958ed98..4e9a759ae 100644 --- a/src/components/usage/PricingEditModal.tsx +++ b/src/components/usage/PricingEditModal.tsx @@ -106,7 +106,9 @@ export function PricingEditModal({ onChange={(e) => setFormData({ ...formData, modelId: e.target.value }) } - placeholder="例如: claude-3-5-sonnet-20241022" + placeholder={t("usage.modelIdPlaceholder", { + defaultValue: "例如: claude-3-5-sonnet-20241022", + })} required />
@@ -122,7 +124,9 @@ export function PricingEditModal({ onChange={(e) => setFormData({ ...formData, displayName: e.target.value }) } - placeholder="例如: Claude 3.5 Sonnet" + placeholder={t("usage.displayNamePlaceholder", { + defaultValue: "例如: Claude 3.5 Sonnet", + })} required />
diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx index aa91ee510..ea62f7780 100644 --- a/src/components/usage/RequestDetailPanel.tsx +++ b/src/components/usage/RequestDetailPanel.tsx @@ -16,8 +16,14 @@ export function RequestDetailPanel({ requestId, onClose, }: RequestDetailPanelProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { data: request, isLoading } = useRequestDetail(requestId); + const dateLocale = + i18n.language === "zh" + ? "zh-CN" + : i18n.language === "ja" + ? "ja-JP" + : "en-US"; if (isLoading) { return ( @@ -69,7 +75,9 @@ export function RequestDetailPanel({ {t("usage.time", "时间")}
- {new Date(request.createdAt * 1000).toLocaleString("zh-CN")} + {new Date(request.createdAt * 1000).toLocaleString( + dateLocale, + )}
diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 7d16b6cfe..69565c657 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -23,7 +23,7 @@ import type { LogFilters } from "@/types/usage"; import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; export function RequestLogTable() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); // 默认时间范围:过去24小时 @@ -62,6 +62,13 @@ export function RequestLogTable() { }); }; + const dateLocale = + i18n.language === "zh" + ? "zh-CN" + : i18n.language === "ja" + ? "ja-JP" + : "en-US"; + return (
{/* 筛选栏 */} @@ -77,10 +84,12 @@ export function RequestLogTable() { } > - + - {t("common.all", "全部端点")} + + {t("usage.allApps", { defaultValue: "全部应用" })} + Claude Codex Gemini @@ -97,7 +106,7 @@ export function RequestLogTable() { } > - + {t("common.all", "全部状态")} @@ -113,7 +122,10 @@ export function RequestLogTable() {
@@ -125,7 +137,7 @@ export function RequestLogTable() { />
@@ -140,7 +152,9 @@ export function RequestLogTable() {
- 时间范围: + + {t("usage.timeRange", { defaultValue: "时间范围" })}: + ( - {new Date(log.createdAt * 1000).toLocaleString("zh-CN")} + {new Date(log.createdAt * 1000).toLocaleString( + dateLocale, + )} {log.providerName || diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index 01b6dd7bd..a6d9397ff 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -56,15 +56,15 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { color: "text-purple-500", bg: "bg-purple-500/10", subValue: ( -
+
- Input + {t("usage.input", { defaultValue: "Input" })} {(inputTokens / 1000).toFixed(1)}k
- Output + {t("usage.output", { defaultValue: "Output" })} {(outputTokens / 1000).toFixed(1)}k @@ -78,21 +78,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { icon: Database, color: "text-orange-500", bg: "bg-orange-500/10", - subValue: ( -
-
- Write - - {(cacheWriteTokens / 1000).toFixed(1)}k - + subValue: ( +
+
+ {t("usage.cacheWrite", { defaultValue: "Write" })} + + {(cacheWriteTokens / 1000).toFixed(1)}k + +
+
+ {t("usage.cacheRead", { defaultValue: "Read" })} + + {(cacheReadTokens / 1000).toFixed(1)}k + +
-
- Read - - {(cacheReadTokens / 1000).toFixed(1)}k - -
-
), }, ]; diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 7d82a6282..3fe75d9ff 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -17,7 +17,7 @@ interface UsageTrendChartProps { } export function UsageTrendChart({ days }: UsageTrendChartProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { data: trends, isLoading } = useUsageTrends(days); if (isLoading) { @@ -29,14 +29,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { } const isToday = days === 1; + const dateLocale = + i18n.language === "zh" + ? "zh-CN" + : i18n.language === "ja" + ? "ja-JP" + : "en-US"; const chartData = trends?.map((stat) => { const pointDate = new Date(stat.date); return { rawDate: stat.date, label: isToday - ? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" }) - : pointDate.toLocaleDateString("zh-CN", { + ? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" }) + : pointDate.toLocaleDateString(dateLocale, { month: "2-digit", day: "2-digit", }), diff --git a/src/hooks/useProxyConfig.ts b/src/hooks/useProxyConfig.ts index effb64b1e..92ba1d3c5 100644 --- a/src/hooks/useProxyConfig.ts +++ b/src/hooks/useProxyConfig.ts @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; import type { ProxyConfig } from "@/types/proxy"; /** @@ -12,6 +13,7 @@ import type { ProxyConfig } from "@/types/proxy"; */ export function useProxyConfig() { const queryClient = useQueryClient(); + const { t } = useTranslation(); // 查询配置 const { data: config, isLoading } = useQuery({ @@ -24,12 +26,16 @@ export function useProxyConfig() { mutationFn: (newConfig: ProxyConfig) => invoke("update_proxy_config", { config: newConfig }), onSuccess: () => { - toast.success("代理配置已保存", { closeButton: true }); + toast.success(t("proxy.settings.toast.saved"), { closeButton: true }); queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); }, onError: (error: Error) => { - toast.error(`保存失败: ${error.message}`); + toast.error( + t("proxy.settings.toast.saveFailed", { + error: error.message, + }), + ); }, }); diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index cc4c78ab8..e679513a1 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -50,7 +50,8 @@ export function useProxyStatus() { queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); }, onError: (error: Error) => { - const detail = extractErrorMessage(error) || "未知错误"; + const detail = + extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.server.startFailed", { defaultValue: `启动代理服务失败: ${detail}`, @@ -75,7 +76,8 @@ export function useProxyStatus() { queryClient.invalidateQueries({ queryKey: ["providerHealth"] }); }, onError: (error: Error) => { - const detail = extractErrorMessage(error) || "未知错误"; + const detail = + extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.stopWithRestoreFailed", { defaultValue: `停止失败: ${detail}`, @@ -111,7 +113,8 @@ export function useProxyStatus() { queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); }, onError: (error: Error) => { - const detail = extractErrorMessage(error) || "未知错误"; + const detail = + extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.takeover.failed", { defaultValue: `操作失败: ${detail}`, @@ -133,8 +136,14 @@ export function useProxyStatus() { queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); }, onError: (error: Error) => { - const detail = extractErrorMessage(error) || "未知错误"; - toast.error(`切换失败: ${detail}`); + const detail = + extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + toast.error( + t("proxy.switchFailed", { + error: detail, + defaultValue: `切换失败: ${detail}`, + }), + ); }, }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 63fea6c41..322d42282 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -378,7 +378,19 @@ "daysAgo": "{{count}} day ago", "multiplePlans": "{{count}} plans", "expand": "Expand", - "collapse": "Collapse" + "collapse": "Collapse", + "modelIdPlaceholder": "e.g., claude-3-5-sonnet-20241022", + "displayNamePlaceholder": "e.g., Claude 3.5 Sonnet", + "appType": "App Type", + "allApps": "All Apps", + "statusCode": "Status Code", + "searchProviderPlaceholder": "Search provider...", + "searchModelPlaceholder": "Search model...", + "timeRange": "Time Range", + "input": "Input", + "output": "Output", + "cacheWrite": "Write", + "cacheRead": "Read" }, "usageScript": { "title": "Configure Usage Query", @@ -448,7 +460,9 @@ "tip3": "• Entire config must be wrapped in () to form object literal expression" }, "errors": { - "usage_query_failed": "Usage query failed" + "usage_query_failed": "Usage query failed", + "configLoadFailedTitle": "Configuration Load Failed", + "configLoadFailedMessage": "Unable to read configuration file:\n{{path}}\n\nError details:\n{{detail}}\n\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\n\nThe app will exit so you can fix this." }, "presetSelector": { "title": "Select Configuration Type", @@ -847,7 +861,9 @@ "label": "Icon", "colorLabel": "Icon Color", "selectIcon": "Select Icon", - "preview": "Preview" + "preview": "Preview", + "clickToChange": "Click to change icon", + "clickToSelect": "Click to select icon" }, "migration": { "success": "Configuration migrated successfully" @@ -855,7 +871,36 @@ "agents": { "title": "Agents" }, + "health": { + "operational": "Operational", + "degraded": "Degraded", + "failed": "Failed", + "circuitOpen": "Circuit Open", + "consecutiveFailures": "{{count}} consecutive failures" + }, "proxy": { + "panel": { + "serviceAddress": "Service Address", + "addressCopied": "Address copied", + "currentProvider": "Current Provider:", + "waitingFirstRequest": "Current Provider: Waiting for first request...", + "stoppedTitle": "Proxy Service Stopped", + "stoppedDescription": "Use the toggle in the top right to start the service", + "openSettings": "Configure Proxy Service", + "stats": { + "activeConnections": "Active Connections", + "totalRequests": "Total Requests", + "successRate": "Success Rate", + "uptime": "Uptime" + } + }, + "settings": { + "toast": { + "saved": "Proxy configuration saved", + "saveFailed": "Save failed: {{error}}" + } + }, + "switchFailed": "Switch failed: {{error}}", "failoverQueue": { "title": "Failover Queue", "description": "Manage failover order for each app's providers", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1b3f178e1..6a3f9ba9d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -378,7 +378,19 @@ "daysAgo": "{{count}} 日前", "multiplePlans": "{{count}} プラン", "expand": "展開", - "collapse": "折りたたむ" + "collapse": "折りたたむ", + "modelIdPlaceholder": "例: claude-3-5-sonnet-20241022", + "displayNamePlaceholder": "例: Claude 3.5 Sonnet", + "appType": "アプリ種別", + "allApps": "すべてのアプリ", + "statusCode": "ステータスコード", + "searchProviderPlaceholder": "プロバイダーを検索...", + "searchModelPlaceholder": "モデルを検索...", + "timeRange": "期間", + "input": "Input", + "output": "Output", + "cacheWrite": "Write", + "cacheRead": "Read" }, "usageScript": { "title": "利用状況を設定", @@ -448,7 +460,9 @@ "tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください" }, "errors": { - "usage_query_failed": "利用状況の取得に失敗しました" + "usage_query_failed": "利用状況の取得に失敗しました", + "configLoadFailedTitle": "設定の読み込みに失敗しました", + "configLoadFailedMessage": "設定ファイルを読み込めません:\n{{path}}\n\nエラー詳細:\n{{detail}}\n\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\n\nアプリを終了して修正してください。" }, "presetSelector": { "title": "設定タイプを選択", @@ -847,12 +861,88 @@ "label": "アイコン", "colorLabel": "アイコンカラー", "selectIcon": "アイコンを選択", - "preview": "プレビュー" + "preview": "プレビュー", + "clickToChange": "クリックでアイコンを変更", + "clickToSelect": "クリックでアイコンを選択" }, "migration": { "success": "設定の移行が完了しました" }, "agents": { "title": "エージェント" + }, + "health": { + "operational": "正常", + "degraded": "低下", + "failed": "失敗", + "circuitOpen": "サーキットオープン", + "consecutiveFailures": "{{count}} 回連続失敗" + }, + "proxy": { + "panel": { + "serviceAddress": "サービスアドレス", + "addressCopied": "アドレスをコピーしました", + "currentProvider": "現在のプロバイダー:", + "waitingFirstRequest": "現在のプロバイダー: 最初のリクエスト待ち...", + "stoppedTitle": "プロキシサービス停止中", + "stoppedDescription": "右上のトグルでサービスを開始できます", + "openSettings": "プロキシサービスを設定", + "stats": { + "activeConnections": "アクティブ接続", + "totalRequests": "総リクエスト数", + "successRate": "成功率", + "uptime": "稼働時間" + } + }, + "settings": { + "toast": { + "saved": "プロキシ設定を保存しました", + "saveFailed": "保存に失敗しました: {{error}}" + } + }, + "switchFailed": "切り替えに失敗しました: {{error}}", + "failoverQueue": { + "title": "フェイルオーバーキュー", + "description": "各アプリのプロバイダーのフェイルオーバー順序を管理します", + "info": "現在アクティブなプロバイダーが常に優先されます。リクエストが失敗すると、システムはキュー順に他のプロバイダーを試行します。", + "selectProvider": "キューに追加するプロバイダーを選択", + "noAvailableProviders": "追加できるプロバイダーがありません", + "empty": "フェイルオーバーキューが空です。自動フェイルオーバーを有効にするにはプロバイダーを追加してください。", + "dragHint": "ドラッグでフェイルオーバー順序を調整します。番号が小さいほど優先度が高くなります。", + "toggleEnabled": "有効/無効", + "addSuccess": "フェイルオーバーキューに追加しました", + "addFailed": "追加に失敗しました", + "removeSuccess": "フェイルオーバーキューから削除しました", + "removeFailed": "削除に失敗しました", + "reorderSuccess": "キュー順序を更新しました", + "reorderFailed": "順序の更新に失敗しました", + "toggleFailed": "状態の更新に失敗しました" + }, + "autoFailover": { + "info": "フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。", + "configSaved": "自動フェイルオーバー設定を保存しました", + "configSaveFailed": "保存に失敗しました", + "retrySettings": "リトライとタイムアウト設定", + "failureThreshold": "失敗しきい値", + "failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)", + "timeout": "回復待ち時間(秒)", + "timeoutHint": "サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)", + "circuitBreakerSettings": "サーキットブレーカー詳細設定", + "successThreshold": "回復成功しきい値", + "successThresholdHint": "半開状態でこの回数成功するとサーキットブレーカーが閉じます", + "errorRate": "エラー率しきい値 (%)", + "errorRateHint": "この値を超えるとサーキットブレーカーが開きます", + "minRequests": "最小リクエスト数", + "minRequestsHint": "エラー率を計算する前の最小リクエスト数", + "explanationTitle": "仕組み", + "failureThresholdLabel": "失敗しきい値", + "failureThresholdExplain": "この回数連続で失敗すると、サーキットブレーカーが開き、プロバイダーは一時的に利用不可になります", + "timeoutLabel": "回復待ち時間", + "timeoutExplain": "サーキットが開いた後、半開状態を試みるまでの待ち時間", + "successThresholdLabel": "回復成功しきい値", + "successThresholdExplain": "半開状態でこの回数成功するとサーキットブレーカーが閉じ、プロバイダーが再び利用可能になります", + "errorRateLabel": "エラー率しきい値", + "errorRateExplain": "失敗しきい値に達していなくても、エラー率がこの値を超えるとサーキットブレーカーが開きます" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5f2e8a0de..9126ce13c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -378,7 +378,19 @@ "daysAgo": "{{count}} 天前", "multiplePlans": "{{count}} 个套餐", "expand": "展开", - "collapse": "收起" + "collapse": "收起", + "modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022", + "displayNamePlaceholder": "例如: Claude 3.5 Sonnet", + "appType": "应用类型", + "allApps": "全部应用", + "statusCode": "状态码", + "searchProviderPlaceholder": "搜索供应商...", + "searchModelPlaceholder": "搜索模型...", + "timeRange": "时间范围", + "input": "Input", + "output": "Output", + "cacheWrite": "Write", + "cacheRead": "Read" }, "usageScript": { "title": "配置用量查询", @@ -448,7 +460,9 @@ "tip3": "• 整个配置必须用 () 包裹,形成对象字面量表达式" }, "errors": { - "usage_query_failed": "用量查询失败" + "usage_query_failed": "用量查询失败", + "configLoadFailedTitle": "配置加载失败", + "configLoadFailedMessage": "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。" }, "presetSelector": { "title": "选择配置类型", @@ -847,7 +861,9 @@ "label": "图标", "colorLabel": "图标颜色", "selectIcon": "选择图标", - "preview": "预览" + "preview": "预览", + "clickToChange": "点击更换图标", + "clickToSelect": "点击选择图标" }, "migration": { "success": "配置迁移成功" @@ -855,7 +871,36 @@ "agents": { "title": "智能体" }, + "health": { + "operational": "正常", + "degraded": "降级", + "failed": "失败", + "circuitOpen": "熔断", + "consecutiveFailures": "连续失败 {{count}} 次" + }, "proxy": { + "panel": { + "serviceAddress": "服务地址", + "addressCopied": "地址已复制", + "currentProvider": "当前 Provider:", + "waitingFirstRequest": "当前 Provider:等待首次请求…", + "stoppedTitle": "代理服务已停止", + "stoppedDescription": "使用右上角开关即可启动服务", + "openSettings": "配置代理服务", + "stats": { + "activeConnections": "活跃连接", + "totalRequests": "总请求数", + "successRate": "成功率", + "uptime": "运行时间" + } + }, + "settings": { + "toast": { + "saved": "代理配置已保存", + "saveFailed": "保存失败: {{error}}" + } + }, + "switchFailed": "切换失败: {{error}}", "failoverQueue": { "title": "故障转移队列", "description": "管理各应用的供应商故障转移顺序", diff --git a/src/main.tsx b/src/main.tsx index 6223e9143..d3fb63e2e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,7 @@ import App from "./App"; import { UpdateProvider } from "./contexts/UpdateContext"; import "./index.css"; // 导入国际化配置 -import "./i18n"; +import i18n from "./i18n"; import { QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@/components/theme-provider"; import { queryClient } from "@/lib/query"; @@ -43,8 +43,18 @@ async function handleConfigLoadError( const detail = payload?.error ?? "Unknown error"; await message( - `无法读取配置文件:\n${path}\n\n错误详情:\n${detail}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。`, - { title: "配置加载失败", kind: "error" }, + i18n.t("errors.configLoadFailedMessage", { + path, + detail, + defaultValue: + "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。", + }), + { + title: i18n.t("errors.configLoadFailedTitle", { + defaultValue: "配置加载失败", + }), + kind: "error", + }, ); await exit(1); From ca7cb398c29afe6b20441c34e858f32880c89cb4 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 20 Dec 2025 22:29:39 +0800 Subject: [PATCH 016/126] i18n: complete usage panel and settings internationalization - Add missing i18n keys for usage statistics panel (trends, cost, perMillion, etc.) - Add i18n keys for settings advanced section (configDir, proxy, modelTest, etc.) - Add streamCheck i18n keys for health check configuration - Remove hardcoded Chinese fallback values from t() calls - Add common keys (all, search, reset, actions, deleting) --- src/components/settings/SettingsPage.tsx | 35 ++++---- src/components/usage/ModelTestConfigPanel.tsx | 24 ++--- src/components/usage/PricingConfigPanel.tsx | 46 +++++----- src/components/usage/RequestLogTable.tsx | 51 +++++------ src/components/usage/UsageDashboard.tsx | 16 ++-- src/components/usage/UsageSummaryCards.tsx | 16 ++-- src/i18n/locales/en.json | 90 ++++++++++++++++++- src/i18n/locales/ja.json | 90 ++++++++++++++++++- src/i18n/locales/zh.json | 90 ++++++++++++++++++- 9 files changed, 355 insertions(+), 103 deletions(-) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 2819738fd..6ccba2531 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -215,7 +215,7 @@ export function SettingsPage({ {t("settings.tabAdvanced")} - {t("usage.title", "使用统计")} + {t("usage.title")} {t("common.about")} @@ -254,10 +254,10 @@ export function SettingsPage({

- 配置文件目录 + {t("settings.advanced.configDir.title")}

- 管理 Claude、Codex 和 Gemini 的配置存储路径 + {t("settings.advanced.configDir.description")}

@@ -289,10 +289,10 @@ export function SettingsPage({

- 本地代理 + {t("settings.advanced.proxy.title")}

- 控制代理服务开关、查看状态与端口信息 + {t("settings.advanced.proxy.description")}

@@ -307,7 +307,7 @@ export function SettingsPage({ - {isRunning ? "运行中" : "已停止"} + {isRunning ? t("settings.advanced.proxy.running") : t("settings.advanced.proxy.stopped")}

- 模型测试配置 + {t("settings.advanced.modelTest.title")}

- 配置模型测试使用的默认模型和提示词 + {t("settings.advanced.modelTest.description")}

@@ -353,10 +353,10 @@ export function SettingsPage({

- 自动故障转移 + {t("settings.advanced.failover.title")}

- 配置故障转移队列和熔断策略 + {t("settings.advanced.failover.description")}

@@ -376,13 +376,10 @@ export function SettingsPage({

- {t("proxy.failoverQueue.title", "故障转移队列")} + {t("proxy.failoverQueue.title")}

- {t( - "proxy.failoverQueue.description", - "管理各应用的供应商故障转移顺序", - )} + {t("proxy.failoverQueue.description")}

@@ -432,10 +429,10 @@ export function SettingsPage({

- 成本定价 + {t("settings.advanced.pricing.title")}

- 管理各模型 Token 计费规则 + {t("settings.advanced.pricing.description")}

@@ -454,10 +451,10 @@ export function SettingsPage({

- 数据管理 + {t("settings.advanced.data.title")}

- 导入导出配置与备份恢复 + {t("settings.advanced.data.description")}

diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index d6c972705..998b954ad 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -47,12 +47,12 @@ export function ModelTestConfigPanel() { try { setIsSaving(true); await saveStreamCheckConfig(config); - toast.success(t("streamCheck.configSaved", "健康检查配置已保存"), { + toast.success(t("streamCheck.configSaved"), { closeButton: true, }); } catch (e) { toast.error( - t("streamCheck.configSaveFailed", "保存失败") + ": " + String(e), + t("streamCheck.configSaveFailed") + ": " + String(e), ); } finally { setIsSaving(false); @@ -78,12 +78,12 @@ export function ModelTestConfigPanel() { {/* 测试模型配置 */}

- {t("streamCheck.testModels", "测试模型")} + {t("streamCheck.testModels")}

- {t("streamCheck.checkParams", "检查参数")} + {t("streamCheck.checkParams")}

- {t("common.saving", "保存中...")} + {t("common.saving")} ) : ( <> - {t("common.save", "保存")} + {t("common.save")} )} diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index 85182221e..3a3cee317 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -63,7 +63,7 @@ export function PricingConfigPanel() {
- {t("usage.modelPricing", "模型定价")} + {t("usage.modelPricing")}
@@ -85,7 +85,7 @@ export function PricingConfigPanel() { )} - {t("usage.modelPricing", "模型定价")} + {t("usage.modelPricing")}
@@ -93,7 +93,7 @@ export function PricingConfigPanel() { - {t("usage.loadPricingError", "加载定价数据失败")}:{" "} + {t("usage.loadPricingError")}:{" "} {String(error)} @@ -107,7 +107,7 @@ export function PricingConfigPanel() {

- {t("usage.modelPricingDesc", "配置各模型的 Token 成本")} (每百万) + {t("usage.modelPricingDesc")} {t("usage.perMillion")}

@@ -125,10 +125,7 @@ export function PricingConfigPanel() { {!pricing || pricing.length === 0 ? ( - {t( - "usage.noPricingData", - '暂无定价数据。点击"新增"添加模型定价配置。', - )} + {t("usage.noPricingData")} ) : ( @@ -136,22 +133,22 @@ export function PricingConfigPanel() { - {t("usage.model", "模型")} - {t("usage.displayName", "显示名称")} + {t("usage.model")} + {t("usage.displayName")} - {t("usage.inputCost", "输入成本")} + {t("usage.inputCost")} - {t("usage.outputCost", "输出成本")} + {t("usage.outputCost")} - {t("usage.cacheReadCost", "缓存读取")} + {t("usage.cacheReadCost")} - {t("usage.cacheWriteCost", "缓存写入")} + {t("usage.cacheWriteCost")} - {t("common.actions", "操作")} + {t("common.actions")} @@ -183,7 +180,7 @@ export function PricingConfigPanel() { setIsAddingNew(false); setEditingModel(model); }} - title={t("common.edit", "编辑")} + title={t("common.edit")} > @@ -191,7 +188,7 @@ export function PricingConfigPanel() { variant="ghost" size="icon" onClick={() => setDeleteConfirm(model.modelId)} - title={t("common.delete", "删除")} + title={t("common.delete")} className="text-destructive hover:text-destructive" > @@ -224,18 +221,15 @@ export function PricingConfigPanel() { - {t("usage.deleteConfirmTitle", "确认删除")} + {t("usage.deleteConfirmTitle")} - {t( - "usage.deleteConfirmDesc", - "确定要删除此模型定价配置吗?此操作无法撤销。", - )} + {t("usage.deleteConfirmDesc")} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 69565c657..39d8d7169 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -84,11 +84,11 @@ export function RequestLogTable() { } > - + - {t("usage.allApps", { defaultValue: "全部应用" })} + {t("usage.allApps")} Claude Codex @@ -106,10 +106,10 @@ export function RequestLogTable() { } > - + - {t("common.all", "全部状态")} + {t("common.all")} 200 OK 400 Bad Request 401 Unauthorized @@ -122,10 +122,7 @@ export function RequestLogTable() {
@@ -137,7 +134,7 @@ export function RequestLogTable() { />
@@ -153,7 +150,7 @@ export function RequestLogTable() {
- {t("usage.timeRange", { defaultValue: "时间范围" })}: + {t("usage.timeRange")}: - {t("common.search", "查询")} + {t("common.search")}
@@ -382,7 +379,7 @@ export function RequestLogTable() { {total > 0 && (
- {t("usage.totalRecords", "共 {{total}} 条记录", { total })} + {t("usage.totalRecords", { total })}
-

- {prompt.name} -

+

{prompt.name}

{prompt.description && (

{prompt.description} diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 88822b3ac..af73626aa 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -114,10 +114,7 @@ const PromptPanel = React.forwardRef( ) : promptEntries.length === 0 ? (

- +

{t("prompts.empty")} diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 82477c2a3..514343474 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -153,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) { {t("provider.websiteUrl")} - + diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index 56523af00..80d8220f6 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -149,8 +149,7 @@ export function ProviderPresetSelector({ className={`${getPresetButtonClass(isSelected, entry.preset)} relative`} style={getPresetButtonStyle(isSelected, entry.preset)} title={ - presetCategoryLabels[category] ?? - t("providerPreset.other") + presetCategoryLabels[category] ?? t("providerPreset.other") } > {renderPresetIcon(entry.preset)} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 6d0141de6..08df765b2 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -51,7 +51,9 @@ export function ProxyPanel() {

- {t("proxy.panel.serviceAddress", { defaultValue: "服务地址" })} + {t("proxy.panel.serviceAddress", { + defaultValue: "服务地址", + })}

- {t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止" })} + {t("proxy.panel.stoppedTitle", { + defaultValue: "代理服务已停止", + })}

{t("proxy.panel.stoppedDescription", { diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx index 0a5b8afdb..3ae452f89 100644 --- a/src/components/proxy/ProxySettingsDialog.tsx +++ b/src/components/proxy/ProxySettingsDialog.tsx @@ -29,7 +29,11 @@ import type { ProxyConfig } from "@/types/proxy"; // 表单数据类型(仅包含可编辑字段) type ProxyConfigForm = Pick< ProxyConfig, - "listen_address" | "listen_port" | "max_retries" | "request_timeout" | "enable_logging" + | "listen_address" + | "listen_port" + | "max_retries" + | "request_timeout" + | "enable_logging" >; const createProxyConfigSchema = (t: TFunction) => { diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 631ce7c71..7b007c63c 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -49,10 +49,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { return (

diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ddffc0dd5..9695e9741 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -47,6 +47,10 @@ import type { SettingsFormState } from "@/hooks/useSettings"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { useProxyStatus } from "@/hooks/useProxyStatus"; +import { + useAutoFailoverEnabled, + useSetAutoFailoverEnabled, +} from "@/lib/query/failover"; interface SettingsDialogProps { open: boolean; @@ -182,11 +186,18 @@ export function SettingsPage({ stopWithRestore, isPending: isProxyPending, } = useProxyStatus(); - const [failoverEnabled, setFailoverEnabled] = useState(true); + + // 使用持久化的自动故障转移开关状态 + const { data: failoverEnabled = false } = useAutoFailoverEnabled(); + const setAutoFailoverEnabled = useSetAutoFailoverEnabled(); const handleToggleProxy = async (checked: boolean) => { try { if (!checked) { + // 关闭代理时,同时关闭故障转移 + if (failoverEnabled) { + setAutoFailoverEnabled.mutate(false); + } await stopWithRestore(); } else { await startProxyServer(); @@ -196,6 +207,19 @@ export function SettingsPage({ } }; + // 处理故障转移开关:开启时自动启动代理 + const handleToggleFailover = async (checked: boolean) => { + try { + if (checked && !isRunning) { + // 开启故障转移时,先启动代理 + await startProxyServer(); + } + setAutoFailoverEnabled.mutate(checked); + } catch (error) { + console.error("Toggle failover failed:", error); + } + }; + return (
{isBusy ? ( @@ -215,9 +239,7 @@ export function SettingsPage({ {t("settings.tabAdvanced")} - - {t("usage.title")} - + {t("usage.title")} {t("common.about")} @@ -318,7 +340,9 @@ export function SettingsPage({ - {isRunning ? t("settings.advanced.proxy.running") : t("settings.advanced.proxy.stopped")} + {isRunning + ? t("settings.advanced.proxy.running") + : t("settings.advanced.proxy.stopped")}
@@ -402,19 +429,19 @@ export function SettingsPage({ @@ -423,8 +450,8 @@ export function SettingsPage({ {/* 熔断器配置 */}
diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 998b954ad..213cf6152 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -51,9 +51,7 @@ export function ModelTestConfigPanel() { closeButton: true, }); } catch (e) { - toast.error( - t("streamCheck.configSaveFailed") + ": " + String(e), - ); + toast.error(t("streamCheck.configSaveFailed") + ": " + String(e)); } finally { setIsSaving(false); } @@ -82,9 +80,7 @@ export function ModelTestConfigPanel() {

- +
- +
- +
- +
- + - {t("usage.loadPricingError")}:{" "} - {String(error)} + {t("usage.loadPricingError")}: {String(error)} @@ -124,9 +123,7 @@ export function PricingConfigPanel() {
{!pricing || pricing.length === 0 ? ( - - {t("usage.noPricingData")} - + {t("usage.noPricingData")} ) : (
@@ -220,9 +217,7 @@ export function PricingConfigPanel() { > - - {t("usage.deleteConfirmTitle")} - + {t("usage.deleteConfirmTitle")} {t("usage.deleteConfirmDesc")} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 39d8d7169..6f6436833 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -87,9 +87,7 @@ export function RequestLogTable() { - - {t("usage.allApps")} - + {t("usage.allApps")} Claude Codex Gemini @@ -149,9 +147,7 @@ export function RequestLogTable() {
- - {t("usage.timeRange")}: - + {t("usage.timeRange")}: - {log.providerName || - t("usage.unknownProvider")} + {log.providerName || t("usage.unknownProvider")}

{t("usage.title")}

-

- {t("usage.subtitle")} -

+

{t("usage.subtitle")}

+
{t("usage.input")} @@ -78,21 +78,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { icon: Database, color: "text-orange-500", bg: "bg-orange-500/10", - subValue: ( -
-
- {t("usage.cacheWrite")} - - {(cacheWriteTokens / 1000).toFixed(1)}k - -
-
- {t("usage.cacheRead")} - - {(cacheReadTokens / 1000).toFixed(1)}k - -
+ subValue: ( +
+
+ {t("usage.cacheWrite")} + + {(cacheWriteTokens / 1000).toFixed(1)}k +
+
+ {t("usage.cacheRead")} + + {(cacheReadTokens / 1000).toFixed(1)}k + +
+
), }, ]; diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index e679513a1..e43a159eb 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -51,7 +51,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.server.startFailed", { defaultValue: `启动代理服务失败: ${detail}`, @@ -77,7 +78,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.stopWithRestoreFailed", { defaultValue: `停止失败: ${detail}`, @@ -114,7 +116,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.takeover.failed", { defaultValue: `操作失败: ${detail}`, @@ -137,7 +140,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.switchFailed", { error: detail, diff --git a/src/lib/api/failover.ts b/src/lib/api/failover.ts index 58fc26e21..85c00c852 100644 --- a/src/lib/api/failover.ts +++ b/src/lib/api/failover.ts @@ -104,4 +104,14 @@ export const failoverApi = { enabled, }); }, + + // 获取自动故障转移总开关状态 + async getAutoFailoverEnabled(): Promise { + return invoke("get_auto_failover_enabled"); + }, + + // 设置自动故障转移总开关状态 + async setAutoFailoverEnabled(enabled: boolean): Promise { + return invoke("set_auto_failover_enabled", { enabled }); + }, }; diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 9f0c7d9fb..bfba5b52a 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -173,6 +173,7 @@ export function useReorderFailoverQueue() { /** * 设置故障转移队列项的启用状态 + * 使用乐观更新(Optimistic Update)以提供即时反馈 */ export function useSetFailoverItemEnabled() { const queryClient = useQueryClient(); @@ -187,10 +188,103 @@ export function useSetFailoverItemEnabled() { providerId: string; enabled: boolean; }) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled), - onSuccess: (_, variables) => { + + // 乐观更新:立即更新缓存中的数据 + onMutate: async (variables) => { + // 取消正在进行的查询,防止覆盖乐观更新 + await queryClient.cancelQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + + // 保存之前的数据以便回滚 + const previousQueue = queryClient.getQueryData< + import("@/types/proxy").FailoverQueueItem[] + >(["failoverQueue", variables.appType]); + + // 乐观地更新缓存 + if (previousQueue) { + queryClient.setQueryData( + ["failoverQueue", variables.appType], + previousQueue.map((item) => + item.providerId === variables.providerId + ? { ...item, enabled: variables.enabled } + : item, + ), + ); + } + + // 返回上下文供 onError 使用 + return { previousQueue }; + }, + + // 错误时回滚 + onError: (_error, variables, context) => { + if (context?.previousQueue) { + queryClient.setQueryData( + ["failoverQueue", variables.appType], + context.previousQueue, + ); + } + }, + + // 无论成功失败,都重新获取最新数据以确保一致性 + onSettled: (_, __, variables) => { queryClient.invalidateQueries({ queryKey: ["failoverQueue", variables.appType], }); }, }); } + +// ========== 自动故障转移总开关 Hooks ========== + +/** + * 获取自动故障转移总开关状态 + */ +export function useAutoFailoverEnabled() { + return useQuery({ + queryKey: ["autoFailoverEnabled"], + queryFn: () => failoverApi.getAutoFailoverEnabled(), + // 默认值为 false(与后端保持一致) + placeholderData: false, + }); +} + +/** + * 设置自动故障转移总开关状态 + */ +export function useSetAutoFailoverEnabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (enabled: boolean) => + failoverApi.setAutoFailoverEnabled(enabled), + + // 乐观更新 + onMutate: async (enabled) => { + await queryClient.cancelQueries({ queryKey: ["autoFailoverEnabled"] }); + const previousValue = queryClient.getQueryData([ + "autoFailoverEnabled", + ]); + + queryClient.setQueryData(["autoFailoverEnabled"], enabled); + + return { previousValue }; + }, + + // 错误时回滚 + onError: (_error, _enabled, context) => { + if (context?.previousValue !== undefined) { + queryClient.setQueryData( + ["autoFailoverEnabled"], + context.previousValue, + ); + } + }, + + // 无论成功失败,都重新获取 + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["autoFailoverEnabled"] }); + }, + }); +} From 97495d1550d27c2ab0412b4fe02e1253aefb477b Mon Sep 17 00:00:00 2001 From: TinsFox Date: Mon, 22 Dec 2025 15:43:28 +0800 Subject: [PATCH 023/126] Remove macOS titlebar tint and align custom header (#438) --- src-tauri/src/lib.rs | 38 ---------------- src/App.tsx | 106 +++++++++++++++++++++++-------------------- 2 files changed, 57 insertions(+), 87 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6da99ea5b..b41cb279c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -223,44 +223,6 @@ pub fn run() { log::warn!("初始化 Updater 插件失败,已跳过:{e}"); } } - #[cfg(target_os = "macos")] - { - // 设置 macOS 标题栏背景色为主界面蓝色 - if let Some(window) = app.get_webview_window("main") { - use objc2::rc::Retained; - use objc2::runtime::AnyObject; - use objc2_app_kit::NSColor; - - match window.ns_window() { - Ok(ns_window_ptr) => { - if let Some(ns_window) = - unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) } - { - // 使用与主界面 banner 相同的蓝色 #3498db - // #3498db = RGB(52, 152, 219) - let bg_color = unsafe { - NSColor::colorWithRed_green_blue_alpha( - 52.0 / 255.0, // R: 52 - 152.0 / 255.0, // G: 152 - 219.0 / 255.0, // B: 219 - 1.0, // Alpha: 1.0 - ) - }; - - unsafe { - use objc2::msg_send; - let _: () = - msg_send![&*ns_window, setBackgroundColor: &*bg_color]; - } - } else { - log::warn!("Failed to retain NSWindow reference"); - } - } - Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"), - } - } - } - // 初始化日志 if cfg!(debug_assertions) { app.handle().plugin( diff --git a/src/App.tsx b/src/App.tsx index d9b730c0f..c658f0db7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,10 @@ import { Button } from "@/components/ui/button"; type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; +const DRAG_BAR_HEIGHT = 28; // px +const HEADER_HEIGHT = 64; // px +const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; + function App() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -101,7 +105,7 @@ function App() { if (event.appType === activeApp) { await refetch(); } - }, + } ); } catch (error) { console.error("[App] Failed to subscribe provider switch event", error); @@ -131,7 +135,7 @@ function App() { } catch (error) { console.error( "[App] Failed to check environment conflicts on startup:", - error, + error ); } }; @@ -147,7 +151,7 @@ function App() { if (migrated) { toast.success( t("migration.success", { defaultValue: "配置迁移成功" }), - { closeButton: true }, + { closeButton: true } ); } } catch (error) { @@ -168,10 +172,10 @@ function App() { // 合并新检测到的冲突 setEnvConflicts((prev) => { const existingKeys = new Set( - prev.map((c) => `${c.varName}:${c.sourcePath}`), + prev.map((c) => `${c.varName}:${c.sourcePath}`) ); const newConflicts = conflicts.filter( - (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`), + (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`) ); return [...prev, ...newConflicts]; }); @@ -183,7 +187,7 @@ function App() { } catch (error) { console.error( "[App] Failed to check environment conflicts on app switch:", - error, + error ); } }; @@ -244,7 +248,7 @@ function App() { (p) => p.sortIndex !== undefined && p.sortIndex >= newSortIndex! && - p.id !== provider.id, + p.id !== provider.id ) .map((p) => ({ id: p.id, @@ -260,7 +264,7 @@ function App() { toast.error( t("provider.sortUpdateFailed", { defaultValue: "排序更新失败", - }), + }) ); return; // 如果排序更新失败,不继续添加 } @@ -334,7 +338,7 @@ function App() { return (
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */} -
+
- {/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */} + {/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
{/* 环境变量警告横幅 */} {showEnvBanner && envConflicts.length > 0 && ( @@ -400,7 +404,7 @@ function App() { } catch (error) { console.error( "[App] Failed to re-check conflicts after deletion:", - error, + error ); } }} @@ -408,13 +412,18 @@ function App() { )}
-
@@ -430,7 +439,7 @@ function App() { onClick={() => setCurrentView("providers")} className="mr-2 rounded-lg" > - +

{currentView === "settings" && t("settings.title")} @@ -452,7 +461,7 @@ function App() { "text-xl font-semibold transition-colors", isProxyRunning && isCurrentAppTakeoverActive ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300" - : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300", + : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300" )} > CC Switch @@ -464,7 +473,7 @@ function App() { title={t("common.settings")} className="hover:bg-black/5 dark:hover:bg-white/5" > - +

setCurrentView("settings")} /> @@ -483,7 +492,7 @@ function App() { className={addActionButtonClass} title={t("prompts.add")} > - + )} {currentView === "mcp" && ( @@ -493,7 +502,7 @@ function App() { className={addActionButtonClass} title={t("mcp.unifiedPanel.addServer")} > - + )} {currentView === "skills" && ( @@ -504,7 +513,7 @@ function App() { onClick={() => skillsPageRef.current?.refresh()} className="hover:bg-black/5 dark:hover:bg-white/5" > - + {t("skills.refresh")} @@ -524,7 +533,7 @@ function App() { -
+
{/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* {isClaudeApp && ( - - )} */} + + )} */}
@@ -577,7 +586,7 @@ function App() { size="icon" className={`ml-2 ${addActionButtonClass}`} > - + )} @@ -585,13 +594,12 @@ function App() {
-
- {renderContent()} +
+
+ {renderContent()} +
Date: Mon, 22 Dec 2025 15:46:11 +0800 Subject: [PATCH 024/126] feat: add provider search filter (#435) * feat: add provider search filter * feat: add provider search overlay --- src/components/providers/ProviderList.tsx | 145 +++++++++++++++++++++- src/i18n/locales/en.json | 6 + src/i18n/locales/ja.json | 6 + src/i18n/locales/zh.json | 6 + tests/components/ProviderList.test.tsx | 43 +++++++ 5 files changed, 200 insertions(+), 6 deletions(-) diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index f99579130..db5fb2212 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -5,13 +5,24 @@ import { useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { CSSProperties } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Search, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { useDragSort } from "@/hooks/useDragSort"; import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; interface ProviderListProps { providers: Record; @@ -44,9 +55,10 @@ export function ProviderList({ isProxyRunning = false, // 默认值为 false isProxyTakeover = false, // 默认值为 false }: ProviderListProps) { + const { t } = useTranslation(); const { sortedProviders, sensors, handleDragEnd } = useDragSort( providers, - appId, + appId ); // 流式健康检查 @@ -56,13 +68,56 @@ export function ProviderList({ checkProvider(provider.id, provider.name); }; + const [searchTerm, setSearchTerm] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if ((event.metaKey || event.ctrlKey) && key === "f") { + event.preventDefault(); + setIsSearchOpen(true); + return; + } + + if (key === "escape") { + setIsSearchOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + useEffect(() => { + if (isSearchOpen) { + const frame = requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + return () => cancelAnimationFrame(frame); + } + }, [isSearchOpen]); + + const filteredProviders = useMemo(() => { + const keyword = searchTerm.trim().toLowerCase(); + if (!keyword) return sortedProviders; + return sortedProviders.filter((provider) => { + const fields = [provider.name, provider.notes, provider.websiteUrl]; + return fields.some((field) => + field?.toString().toLowerCase().includes(keyword) + ); + }); + }, [searchTerm, sortedProviders]); + if (isLoading) { return (
{[0, 1, 2].map((index) => (
))}
@@ -73,18 +128,18 @@ export function ProviderList({ return ; } - return ( + const renderProviderList = () => ( provider.id)} + items={filteredProviders.map((provider) => provider.id)} strategy={verticalListSortingStrategy} >
- {sortedProviders.map((provider) => ( + {filteredProviders.map((provider) => ( ); + + return ( +
+ + {isSearchOpen && ( + +
+
+ + setSearchTerm(event.target.value)} + placeholder={t("provider.searchPlaceholder", { + defaultValue: "Search name, notes, or URL...", + })} + aria-label={t("provider.searchAriaLabel", { + defaultValue: "Search providers", + })} + className="pr-16 pl-9" + /> + {searchTerm && ( + + )} + +
+
+ + {t("provider.searchScopeHint", { + defaultValue: "Matches provider name, notes, and URL.", + })} + + + {t("provider.searchCloseHint", { + defaultValue: "Press Esc to close", + })} + +
+
+
+ )} +
+ + {filteredProviders.length === 0 ? ( +
+ {t("provider.noSearchResults", { + defaultValue: "No providers match your search.", + })} +
+ ) : ( + renderProviderList() + )} +
+ ); } interface SortableProviderCardProps { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34ca1b526..051d4affc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "Remove from Claude plugin", "dragToReorder": "Drag to reorder", "dragHandle": "Drag to reorder", + "searchPlaceholder": "Search name, notes, or URL...", + "searchAriaLabel": "Search providers", + "searchScopeHint": "Matches provider name, notes, and URL.", + "searchCloseHint": "Press Esc to close", + "searchCloseAriaLabel": "Close provider search", + "noSearchResults": "No providers match your search.", "duplicate": "Duplicate", "sortUpdateFailed": "Failed to update sort order", "configureUsage": "Configure usage query", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9ca5e909a..6554839b7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "Claude プラグインから解除", "dragToReorder": "ドラッグで並べ替え", "dragHandle": "ドラッグで並べ替え", + "searchPlaceholder": "名前・メモ・URLで検索...", + "searchAriaLabel": "プロバイダーを検索", + "searchScopeHint": "名前・メモ・URL を対象に検索します。", + "searchCloseHint": "Esc で閉じる", + "searchCloseAriaLabel": "検索を閉じる", + "noSearchResults": "一致するプロバイダーがありません。", "duplicate": "複製", "sortUpdateFailed": "並び順の更新に失敗しました", "configureUsage": "利用状況を設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5976a0cf5..2b7d7cf5e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "从 Claude 插件移除", "dragToReorder": "拖拽以重新排序", "dragHandle": "拖拽排序", + "searchPlaceholder": "按名称/备注/网址搜索供应商...", + "searchAriaLabel": "搜索供应商", + "searchScopeHint": "根据名称、备注和官网链接匹配结果。", + "searchCloseHint": "按 Esc 关闭", + "searchCloseAriaLabel": "关闭供应商搜索", + "noSearchResults": "没有符合搜索条件的供应商。", "duplicate": "复制", "sortUpdateFailed": "排序更新失败", "configureUsage": "配置用量查询", diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index 4c0a16884..f50db3437 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -235,4 +235,47 @@ describe("ProviderList Component", () => { "claude", ); }); + + it("filters providers with the search input", () => { + const providerAlpha = createProvider({ id: "alpha", name: "Alpha Labs" }); + const providerBeta = createProvider({ id: "beta", name: "Beta Works" }); + + useDragSortMock.mockReturnValue({ + sortedProviders: [providerAlpha, providerBeta], + sensors: [], + handleDragEnd: vi.fn(), + }); + + render( + , + ); + + fireEvent.keyDown(window, { key: "f", metaKey: true }); + const searchInput = screen.getByPlaceholderText( + "Search name, notes, or URL...", + ); + // Initially both providers are rendered + expect(screen.getByTestId("provider-card-alpha")).toBeInTheDocument(); + expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "beta" } }); + expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument(); + expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "gamma" } }); + expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument(); + expect(screen.queryByTestId("provider-card-beta")).not.toBeInTheDocument(); + expect( + screen.getByText("No providers match your search."), + ).toBeInTheDocument(); + }); }); From 26c3f05daf60c3b49a4acbb70b88b97f1e5ecca0 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 21 Dec 2025 16:05:22 +0800 Subject: [PATCH 025/126] feat(ui): add fade transition for view and panel switching Add smooth fade animations when navigating between views (Settings, MCP, Skills, Prompts) and opening full-screen panels (Add/Edit Provider). --- src/App.tsx | 156 ++++++++++++---------- src/components/common/FullScreenPanel.tsx | 8 +- 2 files changed, 91 insertions(+), 73 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c658f0db7..3388f3c5a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -299,78 +299,92 @@ function App() { }; const renderContent = () => { - switch (currentView) { - case "settings": - return ( - setCurrentView("providers")} - onImportSuccess={handleImportSuccess} - /> - ); - case "prompts": - return ( - setCurrentView("providers")} - appId={activeApp} - /> - ); - case "skills": - return ( - setCurrentView("providers")} - initialApp={activeApp} - /> - ); - case "mcp": - return ( - setCurrentView("providers")} - /> - ); - case "agents": - return setCurrentView("providers")} />; - default: - return ( -
- {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */} -
- - - setIsAddOpen(true)} - /> - - + const content = (() => { + switch (currentView) { + case "settings": + return ( + setCurrentView("providers")} + onImportSuccess={handleImportSuccess} + /> + ); + case "prompts": + return ( + setCurrentView("providers")} + appId={activeApp} + /> + ); + case "skills": + return ( + setCurrentView("providers")} + initialApp={activeApp} + /> + ); + case "mcp": + return ( + setCurrentView("providers")} + /> + ); + case "agents": + return setCurrentView("providers")} />; + default: + return ( +
+ {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */} +
+ + + setIsAddOpen(true)} + /> + + +
-
- ); - } + ); + } + })(); + + return ( + + + {content} + + + ); }; return ( diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index 56e3cab3a..edf3a1555 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -1,5 +1,6 @@ import React from "react"; import { createPortal } from "react-dom"; +import { motion } from "framer-motion"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -35,7 +36,10 @@ export const FullScreenPanel: React.FC = ({ if (!isOpen) return null; return createPortal( -
@@ -71,7 +75,7 @@ export const FullScreenPanel: React.FC = ({
)} -
, + , document.body, ); }; From bf570b6d2ab94c2489adafe3cae6bdfceea22ada Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 21 Dec 2025 16:23:47 +0800 Subject: [PATCH 026/126] fix(ui): prevent header layout shift when switching views Add min-height to right-side button container and ml-auto to add buttons in MCP/Prompts views to maintain consistent header height and button position across all views. --- src/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3388f3c5a..e215123af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -496,14 +496,14 @@ function App() {
{currentView === "prompts" && (
diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index d5488fadd..26865c2f7 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -12,6 +12,7 @@ import { ProviderActions } from "@/components/providers/ProviderActions"; import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; +import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; @@ -36,6 +37,12 @@ interface ProviderCardProps { isProxyRunning: boolean; isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) dragHandleProps?: DragHandleProps; + // 故障转移相关 + isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移 + failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...) + isInFailoverQueue?: boolean; // 是否在故障转移队列中 + onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列 + activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框) } const extractApiUrl = (provider: Provider, fallbackText: string) => { @@ -88,6 +95,12 @@ export function ProviderCard({ isProxyRunning, isProxyTakeover = false, dragHandleProps, + // 故障转移相关 + isAutoFailoverEnabled = false, + failoverPriority, + isInFailoverQueue = false, + onToggleFailover, + activeProviderId, }: ProviderCardProps) { const { t } = useTranslation(); @@ -148,21 +161,32 @@ export function ProviderCard({ onOpenWebsite(displayUrl); }; + // 判断是否是"当前使用中"的供应商 + // - 故障转移模式:代理实际使用的供应商(activeProviderId) + // - 代理接管模式(非故障转移):isCurrent + // - 普通模式:isCurrent + const isActiveProvider = isAutoFailoverEnabled + ? activeProviderId === provider.id + : isCurrent; + + // 判断是否使用绿色(代理接管模式)还是蓝色(普通模式) + const shouldUseGreen = isProxyTakeover && isActiveProvider; + const shouldUseBlue = !isProxyTakeover && isActiveProvider; + return (
@@ -209,13 +233,20 @@ export function ProviderCard({ {provider.name} - {/* 健康状态徽章和优先级 */} - {isProxyRunning && health && ( + {/* 健康状态徽章 */} + {isProxyRunning && isInFailoverQueue && health && ( )} + {/* 故障转移优先级徽章 */} + {isAutoFailoverEnabled && + isInFailoverQueue && + failoverPriority && ( + + )} + {provider.category === "third_party" && provider.meta?.isPartner && ( onTest(provider) : undefined} onConfigureUsage={() => onConfigureUsage(provider)} onDelete={() => onDelete(provider)} + // 故障转移相关 + isAutoFailoverEnabled={isAutoFailoverEnabled} + isInFailoverQueue={isInFailoverQueue} + onToggleFailover={onToggleFailover} />
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index db5fb2212..f4c916358 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -21,6 +21,13 @@ import { useDragSort } from "@/hooks/useDragSort"; import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; +import { + useAutoFailoverEnabled, + useFailoverQueue, + useAddToFailoverQueue, + useRemoveFromFailoverQueue, +} from "@/lib/query/failover"; +import { useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -38,6 +45,7 @@ interface ProviderListProps { isLoading?: boolean; isProxyRunning?: boolean; // 代理服务运行状态 isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管) + activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框) } export function ProviderList({ @@ -52,18 +60,62 @@ export function ProviderList({ onOpenWebsite, onCreate, isLoading = false, - isProxyRunning = false, // 默认值为 false - isProxyTakeover = false, // 默认值为 false + isProxyRunning = false, + isProxyTakeover = false, + activeProviderId, }: ProviderListProps) { const { t } = useTranslation(); const { sortedProviders, sensors, handleDragEnd } = useDragSort( providers, - appId + appId, ); // 流式健康检查 const { checkProvider, isChecking } = useStreamCheck(appId); + // 故障转移相关 + const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId); + const { data: failoverQueue } = useFailoverQueue(appId); + const addToQueue = useAddToFailoverQueue(); + const removeFromQueue = useRemoveFromFailoverQueue(); + + // 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式 + const isFailoverModeActive = + isProxyTakeover === true && isAutoFailoverEnabled === true; + + // 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序) + const getFailoverPriority = useCallback( + (providerId: string): number | undefined => { + if (!isFailoverModeActive || !failoverQueue) return undefined; + const index = failoverQueue.findIndex( + (item) => item.providerId === providerId, + ); + return index >= 0 ? index + 1 : undefined; + }, + [isFailoverModeActive, failoverQueue], + ); + + // 判断供应商是否在故障转移队列中 + const isInFailoverQueue = useCallback( + (providerId: string): boolean => { + if (!isFailoverModeActive || !failoverQueue) return false; + return failoverQueue.some((item) => item.providerId === providerId); + }, + [isFailoverModeActive, failoverQueue], + ); + + // 切换供应商的故障转移队列状态 + const handleToggleFailover = useCallback( + (providerId: string, enabled: boolean) => { + if (enabled) { + addToQueue.mutate({ appType: appId, providerId }); + } else { + removeFromQueue.mutate({ appType: appId, providerId }); + } + }, + [appId, addToQueue, removeFromQueue], + ); + const handleTest = (provider: Provider) => { checkProvider(provider.id, provider.name); }; @@ -106,7 +158,7 @@ export function ProviderList({ return sortedProviders.filter((provider) => { const fields = [provider.name, provider.notes, provider.websiteUrl]; return fields.some((field) => - field?.toString().toLowerCase().includes(keyword) + field?.toString().toLowerCase().includes(keyword), ); }); }, [searchTerm, sortedProviders]); @@ -155,6 +207,14 @@ export function ProviderList({ isTesting={isChecking(provider.id)} isProxyRunning={isProxyRunning} isProxyTakeover={isProxyTakeover} + // 故障转移相关:联动状态 + isAutoFailoverEnabled={isFailoverModeActive} + failoverPriority={getFailoverPriority(provider.id)} + isInFailoverQueue={isInFailoverQueue(provider.id)} + onToggleFailover={(enabled) => + handleToggleFailover(provider.id, enabled) + } + activeProviderId={activeProviderId} /> ))}
@@ -255,6 +315,12 @@ interface SortableProviderCardProps { isTesting: boolean; isProxyRunning: boolean; isProxyTakeover: boolean; + // 故障转移相关 + isAutoFailoverEnabled: boolean; + failoverPriority?: number; + isInFailoverQueue: boolean; + onToggleFailover: (enabled: boolean) => void; + activeProviderId?: string; } function SortableProviderCard({ @@ -271,6 +337,11 @@ function SortableProviderCard({ isTesting, isProxyRunning, isProxyTakeover, + isAutoFailoverEnabled, + failoverPriority, + isInFailoverQueue, + onToggleFailover, + activeProviderId, }: SortableProviderCardProps) { const { setNodeRef, @@ -309,6 +380,12 @@ function SortableProviderCard({ listeners, isDragging, }} + // 故障转移相关 + isAutoFailoverEnabled={isAutoFailoverEnabled} + failoverPriority={failoverPriority} + isInFailoverQueue={isInFailoverQueue} + onToggleFailover={onToggleFailover} + activeProviderId={activeProviderId} />
); diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 055fcf25f..182dbce5c 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -12,14 +12,14 @@ import { } from "@/lib/query/failover"; export interface AutoFailoverConfigPanelProps { - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; + enabled?: boolean; + onEnabledChange?: (enabled: boolean) => void; } export function AutoFailoverConfigPanel({ - enabled, + enabled = true, onEnabledChange: _onEnabledChange, -}: AutoFailoverConfigPanelProps) { +}: AutoFailoverConfigPanelProps = {}) { // Note: onEnabledChange is currently unused but kept in the interface // for potential future use by parent components void _onEnabledChange; diff --git a/src/components/proxy/FailoverQueueManager.tsx b/src/components/proxy/FailoverQueueManager.tsx index 11da074e8..5dc7120a2 100644 --- a/src/components/proxy/FailoverQueueManager.tsx +++ b/src/components/proxy/FailoverQueueManager.tsx @@ -2,37 +2,14 @@ * 故障转移队列管理组件 * * 允许用户管理代理模式下的故障转移队列,支持: - * - 拖拽排序 * - 添加/移除供应商 - * - 启用/禁用队列项 + * - 队列顺序基于首页供应商列表的 sort_index */ -import { useState, useCallback, useMemo } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { CSS } from "@dnd-kit/utilities"; -import { DndContext, closestCenter } from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { toast } from "sonner"; -import { - GripVertical, - Plus, - Trash2, - Loader2, - Info, - AlertTriangle, -} from "lucide-react"; +import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -51,8 +28,8 @@ import { useAvailableProvidersForFailover, useAddToFailoverQueue, useRemoveFromFailoverQueue, - useReorderFailoverQueue, - useSetFailoverItemEnabled, + useAutoFailoverEnabled, + useSetAutoFailoverEnabled, } from "@/lib/query/failover"; interface FailoverQueueManagerProps { @@ -67,6 +44,10 @@ export function FailoverQueueManager({ const { t } = useTranslation(); const [selectedProviderId, setSelectedProviderId] = useState(""); + // 故障转移开关状态(每个应用独立) + const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType); + const setFailoverEnabled = useSetAutoFailoverEnabled(); + // 查询数据 const { data: queue, @@ -79,59 +60,11 @@ export function FailoverQueueManager({ // Mutations const addToQueue = useAddToFailoverQueue(); const removeFromQueue = useRemoveFromFailoverQueue(); - const reorderQueue = useReorderFailoverQueue(); - const setItemEnabled = useSetFailoverItemEnabled(); - // 拖拽配置 - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - // 排序后的队列 - const sortedQueue = useMemo(() => { - if (!queue) return []; - return [...queue].sort((a, b) => a.queueOrder - b.queueOrder); - }, [queue]); - - // 处理拖拽结束 - const handleDragEnd = useCallback( - async (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id || !sortedQueue) return; - - const oldIndex = sortedQueue.findIndex( - (item) => item.providerId === active.id, - ); - const newIndex = sortedQueue.findIndex( - (item) => item.providerId === over.id, - ); - - if (oldIndex === -1 || newIndex === -1) return; - - const reordered = arrayMove(sortedQueue, oldIndex, newIndex); - const providerIds = reordered.map((item) => item.providerId); - - try { - await reorderQueue.mutateAsync({ appType, providerIds }); - toast.success( - t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"), - { closeButton: true }, - ); - } catch (error) { - toast.error( - t("proxy.failoverQueue.reorderFailed", "更新顺序失败") + - ": " + - String(error), - ); - } - }, - [sortedQueue, appType, reorderQueue, t], - ); + // 切换故障转移开关 + const handleToggleFailover = (enabled: boolean) => { + setFailoverEnabled.mutate({ appType, enabled }); + }; // 添加供应商到队列 const handleAddProvider = async () => { @@ -171,19 +104,6 @@ export function FailoverQueueManager({ } }; - // 切换启用状态 - const handleToggleEnabled = async (providerId: string, enabled: boolean) => { - try { - await setItemEnabled.mutateAsync({ appType, providerId, enabled }); - } catch (error) { - toast.error( - t("proxy.failoverQueue.toggleFailed", "状态更新失败") + - ": " + - String(error), - ); - } - }; - if (isQueueLoading) { return (
@@ -203,13 +123,41 @@ export function FailoverQueueManager({ return (
+ {/* 自动故障转移开关 */} +
+
+
+ + {t("proxy.failover.autoSwitch", { + defaultValue: "自动故障转移", + })} + + {isFailoverEnabled && ( + + {t("common.enabled", { defaultValue: "已开启" })} + + )} +
+

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

+
+ +
+ {/* 说明信息 */} {t( "proxy.failoverQueue.info", - "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。", + "队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。", )} @@ -260,7 +208,7 @@ export function FailoverQueueManager({
{/* 队列列表 */} - {sortedQueue.length === 0 ? ( + {!queue || queue.length === 0 ? (

{t( @@ -270,39 +218,26 @@ export function FailoverQueueManager({

) : ( - - item.providerId)} - strategy={verticalListSortingStrategy} - > -
- {sortedQueue.map((item, index) => ( - - ))} -
-
-
+
+ {queue.map((item, index) => ( + + ))} +
)} {/* 队列说明 */} - {sortedQueue.length > 0 && ( + {queue && queue.length > 0 && (

{t( - "proxy.failoverQueue.dragHint", - "拖拽供应商可调整故障转移顺序,序号越小优先级越高。", + "proxy.failoverQueue.orderHint", + "队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。", )}

)} @@ -310,65 +245,29 @@ export function FailoverQueueManager({ ); } -interface SortableQueueItemProps { +interface QueueItemProps { item: FailoverQueueItem; index: number; disabled: boolean; - onToggleEnabled: (providerId: string, enabled: boolean) => void; onRemove: (providerId: string) => void; isRemoving: boolean; - isToggling: boolean; } -function SortableQueueItem({ +function QueueItem({ item, index, disabled, - onToggleEnabled, onRemove, isRemoving, - isToggling, -}: SortableQueueItemProps) { +}: QueueItemProps) { const { t } = useTranslation(); - const { - setNodeRef, - attributes, - listeners, - transform, - transition, - isDragging, - } = useSortable({ id: item.providerId, disabled }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; return (
- {/* 拖拽手柄 */} - - {/* 序号 */}
{index + 1} @@ -376,24 +275,11 @@ function SortableQueueItem({ {/* 供应商名称 */}
- + {item.providerName}
- {/* 启用开关 */} - onToggleEnabled(item.providerId, checked)} - disabled={disabled || isToggling} - aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")} - /> - {/* 删除按钮 */} -

{title}

-
-
- - {/* Content */} -
-
- {children} -
-
- - {/* Footer */} - {footer && ( -
+ {isOpen && ( + -
- {footer} + {/* Header */} +
+
+
+ +

{title}

+
-
+ + {/* Content */} +
+
+ {children} +
+
+ + {/* Footer */} + {footer && ( +
+
+ {footer} +
+
+ )} + )} - , + , document.body, ); }; From 3dcbe313bed2085ab64e00f22727d69a28934274 Mon Sep 17 00:00:00 2001 From: w0x7ce Date: Tue, 23 Dec 2025 17:11:20 +0800 Subject: [PATCH 030/126] feat: add provider-specific terminal button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a terminal button next to each provider card that opens a new terminal window with that provider's specific API configuration. This allows using different providers independently without changing the global setting. Changes: - Backend: Add `open_provider_terminal` command that extracts provider config and creates a temporary claude settings file - Frontend: Add terminal button to provider cards with proper callback propagation through component hierarchy - Support macOS (Terminal.app), Linux (gnome-terminal, konsole, etc.), and Windows (cmd) Each provider gets a unique config file named `claude__.json` in the temp directory, containing the provider's API configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src-tauri/src/commands/misc.rs | 233 +++++++++++++++++++ src-tauri/src/lib.rs | 3 + src/App.tsx | 21 ++ src/components/providers/ProviderActions.tsx | 18 ++ src/components/providers/ProviderCard.tsx | 3 + src/components/providers/ProviderList.tsx | 6 + src/lib/api/providers.ts | 9 + 7 files changed, 293 insertions(+) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 15e88145f..122e9b1bc 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1,8 +1,12 @@ #![allow(non_snake_case)] +use crate::app_config::AppType; use crate::init_status::InitErrorPayload; +use crate::services::ProviderService; use tauri::AppHandle; +use tauri::State; use tauri_plugin_opener::OpenerExt; +use std::str::FromStr; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -282,3 +286,232 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { (None, Some("未安装或无法执行".to_string())) } + +/// 打开指定提供商的终端 +/// +/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端 +/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端 +#[allow(non_snake_case)] +#[tauri::command] +pub async fn open_provider_terminal( + state: State<'_, crate::store::AppState>, + app: String, + #[allow(non_snake_case)] providerId: String, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + + // 获取提供商配置 + let providers = ProviderService::list(state.inner(), app_type.clone()) + .map_err(|e| format!("获取提供商列表失败: {e}"))?; + + let provider = providers.get(&providerId) + .ok_or_else(|| format!("提供商 {providerId} 不存在"))?; + + // 从提供商配置中提取环境变量 + let config = &provider.settings_config; + let env_vars = extract_env_vars_from_config(config, &app_type); + + // 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名 + launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?; + + Ok(true) +} + +/// 从提供商配置中提取环境变量 +fn extract_env_vars_from_config( + config: &serde_json::Value, + app_type: &AppType, +) -> Vec<(String, String)> { + let mut env_vars = Vec::new(); + + if let Some(obj) = config.as_object() { + // Claude 使用 env 字段 + if let Some(env) = obj.get("env").and_then(|v| v.as_object()) { + for (key, value) in env { + if let Some(str_val) = value.as_str() { + env_vars.push((key.clone(), str_val.to_string())); + } + } + } + + // Codex 使用 auth 字段 + if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) { + match app_type { + AppType::Codex => { + env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string())); + } + _ => {} + } + } + + // Gemini 使用 API_KEY + if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) { + match app_type { + AppType::Gemini => { + env_vars.push(("GOOGLE_API_KEY".to_string(), api_key.to_string())); + } + _ => {} + } + } + + // 提取 base_url(如果存在) + if let Some(env) = obj.get("env").and_then(|v| v.as_object()) { + if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) { + if let Some(url_str) = base_url.as_str() { + match app_type { + AppType::Claude => { + env_vars.push(("ANTHROPIC_BASE_URL".to_string(), url_str.to_string())); + } + AppType::Gemini => { + env_vars.push(("GOOGLE_GEMINI_BASE_URL".to_string(), url_str.to_string())); + } + _ => {} + } + } + } + } + } + + env_vars +} + +/// 创建临时配置文件并启动 claude 终端 +/// 使用 --settings 参数传入提供商特定的 API 配置 +fn launch_terminal_with_env(env_vars: Vec<(String, String)>, provider_id: &str) -> Result<(), String> { + use std::process::Command; + + // 创建临时配置文件,使用提供商ID和进程ID确保唯一性 + let temp_dir = std::env::temp_dir(); + let config_file = temp_dir.join(format!("claude_{}_{}.json", provider_id, std::process::id())); + + // 构建 claude 配置 JSON 格式 + let mut config_obj = serde_json::Map::new(); + let mut env_obj = serde_json::Map::new(); + + for (key, value) in &env_vars { + env_obj.insert(key.clone(), serde_json::Value::String(value.clone())); + } + + config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj)); + + let config_json = serde_json::to_string_pretty(&config_obj) + .map_err(|e| format!("序列化配置失败: {e}"))?; + + // 写入临时配置文件 + std::fs::write(&config_file, config_json) + .map_err(|e| format!("写入配置文件失败: {e}"))?; + + // 转义配置文件路径用于 shell + let config_path_escaped = config_file.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace(' ', "\\ "); + + #[cfg(target_os = "macos")] + { + // macOS: 使用 Terminal.app 启动 claude + let mut terminal_cmd = Command::new("osascript"); + terminal_cmd.arg("-e"); + + let config_file_for_cleanup = config_file.clone(); + let script = format!( + r#"tell application "Terminal" + activate + do script "echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; exit" + end tell"#, + config_path_escaped, config_path_escaped + ); + + terminal_cmd.arg(&script); + + terminal_cmd + .spawn() + .map_err(|e| format!("启动 macOS 终端失败: {e}"))?; + + return Ok(()); + } + + #[cfg(target_os = "linux")] + { + // Linux: 尝试使用常见终端 + let terminals = [ + "gnome-terminal", "konsole", "xfce4-terminal", + "mate-terminal", "lxterminal", "alacritty", "kitty", + ]; + + let mut last_error = String::from("未找到可用的终端"); + + for terminal in terminals { + // 检查终端是否存在 + if Command::new("which").arg(terminal).output().is_err() { + continue; + } + + let result = Command::new(terminal) + .arg("--") + .arg("sh") + .arg("-c") + .arg(&format!( + "echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; $SHELL", + config_path_escaped, config_path_escaped + )) + .spawn(); + + match result { + Ok(_) => { + return Ok(()); + } + Err(e) => { + last_error = format!("启动 {} 失败: {}", terminal, e); + continue; + } + } + } + + // 如果所有终端都失败,清理配置文件 + let _ = std::fs::remove_file(&config_file); + return Err(last_error); + } + + #[cfg(target_os = "windows")] + { + use std::io::Write; + + // Windows: 创建临时批处理文件 + let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id())); + + let mut content = String::from("@echo off\n"); + content.push_str(&format!("echo Using provider-specific claude config:\n")); + content.push_str(&format!("echo {}\n", config_file.to_string_lossy().to_string().replace('&', "^&"))); + content.push_str(&format!("claude --settings \"{}\"\n", config_file.to_string_lossy().to_string().replace('&', "^&"))); + content.push_str("if errorlevel 1 (\n"); + content.push_str(" echo.\n"); + content.push_str(" echo Press any key to close...\n"); + content.push_str(" pause >nul\n"); + content.push_str(")\n"); + + std::fs::write(&bat_file, content) + .map_err(|e| format!("写入批处理文件失败: {e}"))?; + + // 启动新的 cmd 窗口执行批处理文件 + Command::new("cmd") + .args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy().to_string()]) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| format!("启动 Windows 终端失败: {e}"))?; + + return Ok(()); + } + + // 这个代码在所有支持的平台上都不可达,因为前面的平台特定块都已经返回了 + // 使用 cfg 和 allow 来避免编译器警告和错误 + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[allow(unreachable_code)] + { + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + Err("不支持的操作系统".to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27110f225..fbef7aa95 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod tray; mod usage_script; pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; +pub use commands::open_provider_terminal; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; @@ -689,6 +690,8 @@ pub fn run() { commands::get_stream_check_config, commands::save_stream_check_config, commands::get_tool_versions, + // Provider terminal + commands::open_provider_terminal, ]); let app = builder diff --git a/src/App.tsx b/src/App.tsx index 40db2fa2c..ff5764260 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -286,6 +286,26 @@ function App() { await addProvider(duplicatedProvider); }; + // 打开提供商终端 + const handleOpenTerminal = async (provider: Provider) => { + try { + await providersApi.openTerminal(provider.id, activeApp); + toast.success( + t("provider.terminalOpened", { + defaultValue: "终端已打开", + }), + ); + } catch (error) { + console.error("[App] Failed to open terminal", error); + const errorMessage = extractErrorMessage(error); + toast.error( + t("provider.terminalOpenFailed", { + defaultValue: "打开终端失败", + }) + (errorMessage ? `: ${errorMessage}` : ""), + ); + } + }; + // 导入配置成功后刷新 const handleImportSuccess = async () => { try { @@ -378,6 +398,7 @@ function App() { onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} + onOpenTerminal={handleOpenTerminal} onCreate={() => setIsAddOpen(true)} /> diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index e3047e187..e508167ac 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -6,6 +6,7 @@ import { Loader2, Play, Plus, + Terminal, TestTube2, Trash2, } from "lucide-react"; @@ -23,6 +24,7 @@ interface ProviderActionsProps { onTest?: () => void; onConfigureUsage: () => void; onDelete: () => void; + onOpenTerminal?: () => void; // 故障转移相关 isAutoFailoverEnabled?: boolean; isInFailoverQueue?: boolean; @@ -39,6 +41,7 @@ export function ProviderActions({ onTest, onConfigureUsage, onDelete, + onOpenTerminal, // 故障转移相关 isAutoFailoverEnabled = false, isInFailoverQueue = false, @@ -171,6 +174,21 @@ export function ProviderActions({ + {onOpenTerminal && ( + + )} +

{title}

diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index d0df92bb7..351f11b41 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField } from "./shared"; @@ -39,12 +40,14 @@ interface ClaudeFormFieldsProps { // Model Selector shouldShowModelSelector: boolean; claudeModel: string; + reasoningModel: string; defaultHaikuModel: string; defaultSonnetModel: string; defaultOpusModel: string; onModelChange: ( field: | "ANTHROPIC_MODEL" + | "ANTHROPIC_REASONING_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_OPUS_MODEL", @@ -53,6 +56,11 @@ interface ClaudeFormFieldsProps { // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; + + // OpenRouter Compat + showOpenRouterCompatToggle: boolean; + openRouterCompatEnabled: boolean; + onOpenRouterCompatChange: (enabled: boolean) => void; } export function ClaudeFormFields({ @@ -77,11 +85,15 @@ export function ClaudeFormFields({ onCustomEndpointsChange, shouldShowModelSelector, claudeModel, + reasoningModel, defaultHaikuModel, defaultSonnetModel, defaultOpusModel, onModelChange, speedTestEndpoints, + showOpenRouterCompatToggle, + openRouterCompatEnabled, + onOpenRouterCompatChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); @@ -162,6 +174,28 @@ export function ClaudeFormFields({ /> )} + {showOpenRouterCompatToggle && ( +
+
+ + {t("providerForm.openrouterCompatMode", { + defaultValue: "OpenRouter 兼容模式", + })} + +

+ {t("providerForm.openrouterCompatModeHint", { + defaultValue: + "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。", + })} +

+
+ +
+ )} + {/* 模型选择器 */} {shouldShowModelSelector && (
@@ -185,6 +219,27 @@ export function ClaudeFormFields({ />
+ {/* 推理模型 */} +
+ + {t("providerForm.anthropicReasoningModel", { + defaultValue: "推理模型 (Thinking)", + })} + + + onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value) + } + placeholder={t("providerForm.reasoningModelPlaceholder", { + defaultValue: "", + })} + autoComplete="off" + /> +
+ {/* 默认 Haiku */}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 81b530e29..fadec46a7 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -162,6 +162,8 @@ export function ProviderForm({ mode: "onSubmit", }); + const settingsConfigValue = form.watch("settingsConfig"); + // 使用 API Key hook const { apiKey, @@ -187,9 +189,10 @@ export function ProviderForm({ }, }); - // 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型) + // 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型) const { claudeModel, + reasoningModel, defaultHaikuModel, defaultSonnetModel, defaultOpusModel, @@ -199,6 +202,53 @@ export function ProviderForm({ onConfigChange: (config) => form.setValue("settingsConfig", config), }); + const isOpenRouterProvider = useMemo(() => { + if (appId !== "claude") return false; + const normalized = baseUrl.trim().toLowerCase(); + if (normalized.includes("openrouter.ai")) { + return true; + } + try { + const config = JSON.parse(settingsConfigValue || "{}"); + const envUrl = config?.env?.ANTHROPIC_BASE_URL; + return typeof envUrl === "string" && envUrl.includes("openrouter.ai"); + } catch { + return false; + } + }, [appId, baseUrl, settingsConfigValue]); + + const openRouterCompatEnabled = useMemo(() => { + if (!isOpenRouterProvider) return false; + try { + const config = JSON.parse(settingsConfigValue || "{}"); + const raw = config?.openrouter_compat_mode; + if (typeof raw === "boolean") return raw; + if (typeof raw === "number") return raw !== 0; + if (typeof raw === "string") { + const normalized = raw.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; + } + } catch { + // ignore + } + return true; + }, [isOpenRouterProvider, settingsConfigValue]); + + const handleOpenRouterCompatChange = useCallback( + (enabled: boolean) => { + try { + const currentConfig = JSON.parse( + form.getValues("settingsConfig") || "{}", + ); + currentConfig.openrouter_compat_mode = enabled; + form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + // 使用 Codex 配置 hook (仅 Codex 模式) const { codexAuth, @@ -789,11 +839,15 @@ export function ProviderForm({ } shouldShowModelSelector={category !== "official"} claudeModel={claudeModel} + reasoningModel={reasoningModel} defaultHaikuModel={defaultHaikuModel} defaultSonnetModel={defaultSonnetModel} defaultOpusModel={defaultOpusModel} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} + showOpenRouterCompatToggle={isOpenRouterProvider} + openRouterCompatEnabled={openRouterCompatEnabled} + onOpenRouterCompatChange={handleOpenRouterCompatChange} /> )} diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 6e2fe4b60..07d7122dc 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -7,13 +7,14 @@ interface UseModelStateProps { /** * 管理模型选择状态 - * 支持 ANTHROPIC_MODEL 和 ANTHROPIC_SMALL_FAST_MODEL + * 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型 */ export function useModelState({ settingsConfig, onConfigChange, }: UseModelStateProps) { const [claudeModel, setClaudeModel] = useState(""); + const [reasoningModel, setReasoningModel] = useState(""); const [defaultHaikuModel, setDefaultHaikuModel] = useState(""); const [defaultSonnetModel, setDefaultSonnetModel] = useState(""); const [defaultOpusModel, setDefaultOpusModel] = useState(""); @@ -29,6 +30,10 @@ export function useModelState({ const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; + const reasoning = + typeof env.ANTHROPIC_REASONING_MODEL === "string" + ? env.ANTHROPIC_REASONING_MODEL + : ""; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -47,6 +52,7 @@ export function useModelState({ : model || small; setClaudeModel(model || ""); + setReasoningModel(reasoning || ""); setDefaultHaikuModel(haiku || ""); setDefaultSonnetModel(sonnet || ""); setDefaultOpusModel(opus || ""); @@ -59,12 +65,14 @@ export function useModelState({ ( field: | "ANTHROPIC_MODEL" + | "ANTHROPIC_REASONING_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_OPUS_MODEL", value: string, ) => { if (field === "ANTHROPIC_MODEL") setClaudeModel(value); + if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value); if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") setDefaultHaikuModel(value); if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL") @@ -98,6 +106,8 @@ export function useModelState({ return { claudeModel, setClaudeModel, + reasoningModel, + setReasoningModel, defaultHaikuModel, setDefaultHaikuModel, defaultSonnetModel, diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 182dbce5c..9daeaed0d 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -6,51 +6,67 @@ import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Save, Loader2, Info } from "lucide-react"; import { toast } from "sonner"; -import { - useCircuitBreakerConfig, - useUpdateCircuitBreakerConfig, -} from "@/lib/query/failover"; +import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy"; export interface AutoFailoverConfigPanelProps { - enabled?: boolean; - onEnabledChange?: (enabled: boolean) => void; + appType: string; + disabled?: boolean; } export function AutoFailoverConfigPanel({ - enabled = true, - onEnabledChange: _onEnabledChange, -}: AutoFailoverConfigPanelProps = {}) { - // Note: onEnabledChange is currently unused but kept in the interface - // for potential future use by parent components - void _onEnabledChange; + appType, + disabled = false, +}: AutoFailoverConfigPanelProps) { const { t } = useTranslation(); - const { data: config, isLoading, error } = useCircuitBreakerConfig(); - const updateConfig = useUpdateCircuitBreakerConfig(); + const { data: config, isLoading, error } = useAppProxyConfig(appType); + const updateConfig = useUpdateAppProxyConfig(); const [formData, setFormData] = useState({ - failureThreshold: 5, - successThreshold: 2, - timeoutSeconds: 60, - errorRateThreshold: 0.5, - minRequests: 10, + autoFailoverEnabled: false, + maxRetries: 3, + streamingFirstByteTimeout: 30, + streamingIdleTimeout: 60, + nonStreamingTimeout: 300, + circuitFailureThreshold: 5, + circuitSuccessThreshold: 2, + circuitTimeoutSeconds: 60, + circuitErrorRateThreshold: 0.5, + circuitMinRequests: 10, }); useEffect(() => { if (config) { setFormData({ - ...config, + autoFailoverEnabled: config.autoFailoverEnabled, + maxRetries: config.maxRetries, + streamingFirstByteTimeout: config.streamingFirstByteTimeout, + streamingIdleTimeout: config.streamingIdleTimeout, + nonStreamingTimeout: config.nonStreamingTimeout, + circuitFailureThreshold: config.circuitFailureThreshold, + circuitSuccessThreshold: config.circuitSuccessThreshold, + circuitTimeoutSeconds: config.circuitTimeoutSeconds, + circuitErrorRateThreshold: config.circuitErrorRateThreshold, + circuitMinRequests: config.circuitMinRequests, }); } }, [config]); const handleSave = async () => { + if (!config) return; try { await updateConfig.mutateAsync({ - failureThreshold: formData.failureThreshold, - successThreshold: formData.successThreshold, - timeoutSeconds: formData.timeoutSeconds, - errorRateThreshold: formData.errorRateThreshold, - minRequests: formData.minRequests, + appType, + enabled: config.enabled, + autoFailoverEnabled: formData.autoFailoverEnabled, + maxRetries: formData.maxRetries, + streamingFirstByteTimeout: formData.streamingFirstByteTimeout, + streamingIdleTimeout: formData.streamingIdleTimeout, + nonStreamingTimeout: formData.nonStreamingTimeout, + circuitFailureThreshold: formData.circuitFailureThreshold, + circuitSuccessThreshold: formData.circuitSuccessThreshold, + circuitTimeoutSeconds: formData.circuitTimeoutSeconds, + circuitErrorRateThreshold: formData.circuitErrorRateThreshold, + circuitMinRequests: formData.circuitMinRequests, }); toast.success( t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"), @@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({ const handleReset = () => { if (config) { setFormData({ - ...config, + autoFailoverEnabled: config.autoFailoverEnabled, + maxRetries: config.maxRetries, + streamingFirstByteTimeout: config.streamingFirstByteTimeout, + streamingIdleTimeout: config.streamingIdleTimeout, + nonStreamingTimeout: config.nonStreamingTimeout, + circuitFailureThreshold: config.circuitFailureThreshold, + circuitSuccessThreshold: config.circuitSuccessThreshold, + circuitTimeoutSeconds: config.circuitTimeoutSeconds, + circuitErrorRateThreshold: config.circuitErrorRateThreshold, + circuitMinRequests: config.circuitMinRequests, }); } }; @@ -79,16 +104,10 @@ export function AutoFailoverConfigPanel({ ); } + const isDisabled = disabled || updateConfig.isPending; + return (
- {/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits. - Since we need it in the accordion header, and this component is inside the content, we can use a portal or - absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage - and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually. - Better yet, let's just render the content directly without the wrapping Card header/collapse logic - since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding). - */} -
{error && ( @@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
-
+ +
+ + + setFormData({ + ...formData, + circuitFailureThreshold: parseInt(e.target.value) || 5, + }) + } + disabled={isDisabled} />

{t( @@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({ )}

+
+
+ {/* 超时配置 */} +
+

+ {t("proxy.autoFailover.timeoutSettings", "超时配置")} +

+ +
-
+ +
+ + + setFormData({ + ...formData, + streamingIdleTimeout: parseInt(e.target.value) || 60, + }) + } + disabled={isDisabled} + /> +

+ {t( + "proxy.autoFailover.streamingIdleHint", + "数据块之间的最大间隔", + )} +

+
+ +
+ + + setFormData({ + ...formData, + nonStreamingTimeout: parseInt(e.target.value) || 300, + }) + } + disabled={isDisabled} + /> +

+ {t( + "proxy.autoFailover.nonStreamingHint", + "非流式请求的总超时时间", )}

- {/* 熔断器高级配置 */} + {/* 熔断器配置 */}

- {t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")} + {t("proxy.autoFailover.circuitBreakerSettings", "熔断器配置")}

-
+
-
-
+ +
+ setFormData({ ...formData, - errorRateThreshold: (parseInt(e.target.value) || 50) / 100, + circuitErrorRateThreshold: + (parseInt(e.target.value) || 50) / 100, }) } - disabled={!enabled} + disabled={isDisabled} />

{t( @@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({

-
); diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 66a1c829e..537b8ff68 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -1,26 +1,54 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Activity, Clock, TrendingUp, Server, ListOrdered, - Settings, + Save, + Loader2, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; import { useProxyStatus } from "@/hooks/useProxyStatus"; -import { ProxySettingsDialog } from "./ProxySettingsDialog"; import { toast } from "sonner"; import { useFailoverQueue } from "@/lib/query/failover"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { useProviderHealth } from "@/lib/query/failover"; +import { + useProxyTakeoverStatus, + useSetProxyTakeoverForApp, + useGlobalProxyConfig, + useUpdateGlobalProxyConfig, +} from "@/lib/query/proxy"; import type { ProxyStatus } from "@/types/proxy"; import { useTranslation } from "react-i18next"; export function ProxyPanel() { const { t } = useTranslation(); const { status, isRunning } = useProxyStatus(); - const [showSettings, setShowSettings] = useState(false); + + // 获取应用接管状态 + const { data: takeoverStatus } = useProxyTakeoverStatus(); + const setTakeoverForApp = useSetProxyTakeoverForApp(); + + // 获取全局代理配置 + const { data: globalConfig } = useGlobalProxyConfig(); + const updateGlobalConfig = useUpdateGlobalProxyConfig(); + + // 监听地址/端口的本地状态 + const [listenAddress, setListenAddress] = useState("127.0.0.1"); + const [listenPort, setListenPort] = useState(5000); + + // 同步全局配置到本地状态 + useEffect(() => { + if (globalConfig) { + setListenAddress(globalConfig.listenAddress); + setListenPort(globalConfig.listenPort); + } + }, [globalConfig]); // 获取所有三个应用类型的故障转移队列(不包含当前供应商) // 当前供应商始终优先,队列仅用于失败后的备用顺序 @@ -28,6 +56,69 @@ export function ProxyPanel() { const { data: codexQueue = [] } = useFailoverQueue("codex"); const { data: geminiQueue = [] } = useFailoverQueue("gemini"); + const handleTakeoverChange = async (appType: string, enabled: boolean) => { + try { + await setTakeoverForApp.mutateAsync({ appType, enabled }); + toast.success( + enabled + ? t("proxy.takeover.enabled", { + app: appType, + defaultValue: `${appType} 接管已启用`, + }) + : t("proxy.takeover.disabled", { + app: appType, + defaultValue: `${appType} 接管已关闭`, + }), + { closeButton: true }, + ); + } catch (error) { + toast.error( + t("proxy.takeover.failed", { + defaultValue: "切换接管状态失败", + }), + ); + } + }; + + const handleLoggingChange = async (enabled: boolean) => { + if (!globalConfig) return; + try { + await updateGlobalConfig.mutateAsync({ + ...globalConfig, + enableLogging: enabled, + }); + toast.success( + enabled + ? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" }) + : t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }), + { closeButton: true }, + ); + } catch (error) { + toast.error( + t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }), + ); + } + }; + + const handleSaveBasicConfig = async () => { + if (!globalConfig) return; + try { + await updateGlobalConfig.mutateAsync({ + ...globalConfig, + listenAddress, + listenPort, + }); + toast.success( + t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), + { closeButton: true }, + ); + } catch (error) { + toast.error( + t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }), + ); + } + }; + const formatUptime = (seconds: number): string => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -49,22 +140,11 @@ export function ProxyPanel() {
-
-

- {t("proxy.panel.serviceAddress", { - defaultValue: "服务地址", - })} -

- -
+

+ {t("proxy.panel.serviceAddress", { + defaultValue: "服务地址", + })} +

http://{status.address}:{status.port} @@ -87,6 +167,11 @@ export function ProxyPanel() { {t("common.copy")}
+

+ {t("proxy.settings.restartRequired", { + defaultValue: "修改监听地址/端口需要先停止代理服务", + })} +

@@ -130,6 +215,63 @@ export function ProxyPanel() { )}
+ {/* 应用接管开关 */} +
+

+ {t("proxyConfig.appTakeover", { + defaultValue: "应用接管", + })} +

+
+ {(["claude", "codex", "gemini"] as const).map((appType) => { + const isEnabled = + takeoverStatus?.[ + appType as keyof typeof takeoverStatus + ] ?? false; + return ( +
+ + {appType} + + + handleTakeoverChange(appType, checked) + } + disabled={setTakeoverForApp.isPending} + /> +
+ ); + })} +
+
+ + {/* 日志记录开关 */} +
+
+
+ +

+ {t("proxy.settings.fields.enableLogging.description", { + defaultValue: "记录所有代理请求,便于排查问题", + })} +

+
+ +
+
+ {/* 供应商队列 - 按应用类型分组展示 */} {(claudeQueue.length > 0 || codexQueue.length > 0 || @@ -217,36 +359,109 @@ export function ProxyPanel() {
) : ( -
-
- +
+ {/* 空白区域避免冲突 */} +
+ + {/* 基础设置 - 监听地址/端口 */} +
+
+

+ {t("proxy.settings.basic.title", { + defaultValue: "基础设置", + })} +

+

+ {t("proxy.settings.basic.description", { + defaultValue: "配置代理服务监听的地址与端口。", + })} +

+
+ +
+
+ + setListenAddress(e.target.value)} + placeholder="127.0.0.1" + /> +

+ {t("proxy.settings.fields.listenAddress.description", { + defaultValue: + "代理服务器监听的 IP 地址(推荐 127.0.0.1)", + })} +

+
+ +
+ + + setListenPort(parseInt(e.target.value) || 5000) + } + placeholder="5000" + /> +

+ {t("proxy.settings.fields.listenPort.description", { + defaultValue: "代理服务器监听的端口号(1024 ~ 65535)", + })} +

+
+
+ +
+ +
+
+ + {/* 代理服务已停止提示 */} +
+
+ +
+

+ {t("proxy.panel.stoppedTitle", { + defaultValue: "代理服务已停止", + })} +

+

+ {t("proxy.panel.stoppedDescription", { + defaultValue: "使用右上角开关即可启动服务", + })} +

-

- {t("proxy.panel.stoppedTitle", { - defaultValue: "代理服务已停止", - })} -

-

- {t("proxy.panel.stoppedDescription", { - defaultValue: "使用右上角开关即可启动服务", - })} -

-
)} - - ); } diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx deleted file mode 100644 index 3ae452f89..000000000 --- a/src/components/proxy/ProxySettingsDialog.tsx +++ /dev/null @@ -1,403 +0,0 @@ -/** - * 代理服务设置对话框 - */ - -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormDescription, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useProxyConfig } from "@/hooks/useProxyConfig"; -import { useEffect, useMemo } from "react"; -import { AlertCircle } from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { FullScreenPanel } from "@/components/common/FullScreenPanel"; -import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import type { ProxyConfig } from "@/types/proxy"; - -// 表单数据类型(仅包含可编辑字段) -type ProxyConfigForm = Pick< - ProxyConfig, - | "listen_address" - | "listen_port" - | "max_retries" - | "request_timeout" - | "enable_logging" ->; - -const createProxyConfigSchema = (t: TFunction) => { - const requestTimeoutSchema = z - .number() - .min( - 0, - t("proxy.settings.validation.timeoutNonNegative", { - defaultValue: "超时时间不能为负数", - }), - ) - .max( - 600, - t("proxy.settings.validation.timeoutMax", { - defaultValue: "超时时间最多600秒", - }), - ) - .refine((value) => value === 0 || value >= 10, { - message: t("proxy.settings.validation.timeoutRange", { - defaultValue: "请输入 0 或 10-600 之间的数值", - }), - }); - - return z.object({ - listen_address: z.string().regex( - /^(\d{1,3}\.){3}\d{1,3}$/, - t("proxy.settings.validation.addressInvalid", { - defaultValue: "请输入有效的IP地址", - }), - ), - listen_port: z - .number() - .min( - 1024, - t("proxy.settings.validation.portMin", { - defaultValue: "端口必须大于1024", - }), - ) - .max( - 65535, - t("proxy.settings.validation.portMax", { - defaultValue: "端口必须小于65535", - }), - ), - max_retries: z - .number() - .min( - 0, - t("proxy.settings.validation.retryMin", { - defaultValue: "重试次数不能为负", - }), - ) - .max( - 10, - t("proxy.settings.validation.retryMax", { - defaultValue: "重试次数不能超过10", - }), - ), - request_timeout: requestTimeoutSchema, - enable_logging: z.boolean(), - }); -}; - -interface ProxySettingsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function ProxySettingsDialog({ - open, - onOpenChange, -}: ProxySettingsDialogProps) { - const { config, isLoading, updateConfig, isUpdating } = useProxyConfig(); - const { t } = useTranslation(); - const schema = useMemo(() => createProxyConfigSchema(t), [t]); - - const closePanel = () => onOpenChange(false); - - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { - listen_address: "127.0.0.1", - listen_port: 5000, - max_retries: 3, - request_timeout: 300, - enable_logging: true, - }, - }); - - // 当配置加载完成后更新表单 - useEffect(() => { - if (config) { - form.reset({ - listen_address: config.listen_address, - listen_port: config.listen_port, - max_retries: config.max_retries, - request_timeout: config.request_timeout, - enable_logging: config.enable_logging, - }); - } - }, [config, form]); - - const onSubmit = async (data: ProxyConfigForm) => { - try { - await updateConfig(data); - closePanel(); - } catch (error) { - console.error("Save config failed:", error); - } - }; - - const formId = "proxy-settings-form"; - - return ( - - - - - } - > -
-

- {t("proxy.settings.description", { - defaultValue: - "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。", - })} -

- - - - {t("proxy.settings.alert.autoApply", { - defaultValue: - "保存后将自动同步到正在运行的代理服务,无需手动重启。", - })} - - - -
- -
-
-

- {t("proxy.settings.basic.title", { - defaultValue: "基础设置", - })} -

-

- {t("proxy.settings.basic.description", { - defaultValue: "配置代理服务监听的地址与端口。", - })} -

-
- -
- ( - - - {t("proxy.settings.fields.listenAddress.label", { - defaultValue: "监听地址", - })} - - - - - - {t("proxy.settings.fields.listenAddress.description", { - defaultValue: - "代理服务器监听的 IP 地址(推荐 127.0.0.1)", - })} - - - - )} - /> - - ( - - - {t("proxy.settings.fields.listenPort.label", { - defaultValue: "监听端口", - })} - - - - field.onChange(parseInt(e.target.value, 10) || 0) - } - placeholder={t( - "proxy.settings.fields.listenPort.placeholder", - { defaultValue: "5000" }, - )} - disabled={isLoading} - /> - - - {t("proxy.settings.fields.listenPort.description", { - defaultValue: - "代理服务器监听的端口号(1024 ~ 65535)", - })} - - - - )} - /> -
-
- -
-
-

- {t("proxy.settings.advanced.title", { - defaultValue: "高级参数", - })} -

-

- {t("proxy.settings.advanced.description", { - defaultValue: "控制请求的稳定性和日志记录。", - })} -

-
- -
- ( - - - {t("proxy.settings.fields.maxRetries.label", { - defaultValue: "最大重试次数", - })} - - - - field.onChange(parseInt(e.target.value, 10) || 0) - } - placeholder={t( - "proxy.settings.fields.maxRetries.placeholder", - { defaultValue: "3" }, - )} - disabled={isLoading} - /> - - - {t("proxy.settings.fields.maxRetries.description", { - defaultValue: "请求失败时的重试次数(0 ~ 10)", - })} - - - - )} - /> - - ( - - - {t("proxy.settings.fields.requestTimeout.label", { - defaultValue: "请求超时(秒)", - })} - - - - field.onChange(parseInt(e.target.value, 10) || 0) - } - placeholder={t( - "proxy.settings.fields.requestTimeout.placeholder", - { defaultValue: "0(不限)或 300" }, - )} - disabled={isLoading} - /> - - - {t("proxy.settings.fields.requestTimeout.description", { - defaultValue: - "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)", - })} - - - - )} - /> -
- - ( - -
- - {t("proxy.settings.fields.enableLogging.label", { - defaultValue: "启用日志记录", - })} - - - {t("proxy.settings.fields.enableLogging.description", { - defaultValue: "记录所有代理请求,便于排查问题", - })} - -
- - - -
- )} - /> -
- - -
-
- ); -} diff --git a/src/components/proxy/index.ts b/src/components/proxy/index.ts index b645228d8..0abc65408 100644 --- a/src/components/proxy/index.ts +++ b/src/components/proxy/index.ts @@ -3,4 +3,3 @@ */ export { ProxyPanel } from "./ProxyPanel"; -export { ProxySettingsDialog } from "./ProxySettingsDialog"; diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 30481bd3d..44c538af5 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -384,47 +384,89 @@ export function SettingsPage({
)} - {/* 故障转移队列管理 - 每个应用独立 */} -
-
-

- {t("proxy.failoverQueue.title")} -

-

- {t("proxy.failoverQueue.description")} -

-
- - - Claude - Codex - Gemini - - + {/* 故障转移设置 - 按应用分组 */} + + + Claude + Codex + Gemini + + +
+
+

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

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

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

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

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

+
- - -
- - {/* 熔断器配置 - 全局共享 */} -
- -
+
+
+ +
+ +
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 051d4affc..88cf7336b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -981,6 +981,76 @@ } }, "settings": { + "title": "Proxy Service Settings", + "description": "Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.", + "alert": { + "autoApply": "Changes will be automatically synced to the running proxy service without manual restart." + }, + "basic": { + "title": "Basic Settings", + "description": "Configure proxy service listening address and port." + }, + "advanced": { + "title": "Advanced Parameters", + "description": "Control request stability and logging." + }, + "timeout": { + "title": "Timeout Settings", + "description": "Configure timeout for streaming and non-streaming requests." + }, + "fields": { + "listenAddress": { + "label": "Listen Address", + "placeholder": "127.0.0.1", + "description": "IP address the proxy server listens on (recommended: 127.0.0.1)" + }, + "listenPort": { + "label": "Listen Port", + "placeholder": "5000", + "description": "Port number the proxy server listens on (1024 ~ 65535)" + }, + "maxRetries": { + "label": "Max Retries", + "placeholder": "3", + "description": "Number of retries on request failure (0 ~ 10)" + }, + "requestTimeout": { + "label": "Request Timeout (sec)", + "placeholder": "0 (unlimited) or 300", + "description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)" + }, + "enableLogging": { + "label": "Enable Logging", + "description": "Log all proxy requests for troubleshooting" + }, + "streamingFirstByteTimeout": { + "label": "Streaming First Byte Timeout (sec)", + "description": "Maximum time to wait for the first data chunk" + }, + "streamingIdleTimeout": { + "label": "Streaming Idle Timeout (sec)", + "description": "Maximum interval between data chunks" + }, + "nonStreamingTimeout": { + "label": "Non-Streaming Timeout (sec)", + "description": "Total timeout for non-streaming requests" + } + }, + "validation": { + "addressInvalid": "Please enter a valid IP address", + "portMin": "Port must be greater than 1024", + "portMax": "Port must be less than 65535", + "retryMin": "Retry count cannot be negative", + "retryMax": "Retry count cannot exceed 10", + "timeoutNonNegative": "Timeout cannot be negative", + "timeoutMax": "Timeout cannot exceed 600 seconds", + "timeoutRange": "Please enter 0 or a value between 10-600", + "streamingTimeoutMin": "Timeout must be at least 5 seconds", + "streamingTimeoutMax": "Timeout cannot exceed 300 seconds" + }, + "actions": { + "save": "Save Configuration" + }, "toast": { "saved": "Proxy configuration saved", "saveFailed": "Save failed: {{error}}" @@ -1013,7 +1083,7 @@ "failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)", "timeout": "Recovery Wait Time (seconds)", "timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)", - "circuitBreakerSettings": "Circuit Breaker Advanced Settings", + "circuitBreakerSettings": "Circuit Breaker Settings", "successThreshold": "Recovery Success Threshold", "successThresholdHint": "Close circuit breaker after this many successes in half-open state", "errorRate": "Error Rate Threshold (%)", @@ -1042,5 +1112,21 @@ "timeout": "Timeout (seconds)", "maxRetries": "Max Retries", "degradedThreshold": "Degraded Threshold (ms)" + }, + "proxyConfig": { + "proxyEnabled": "Proxy Enabled", + "appTakeover": "Proxy Enabled", + "perAppConfig": "Per-App Config", + "circuitBreaker": "Circuit Breaker", + "circuitBreakerSettings": "Circuit Breaker Settings", + "failureThreshold": "Failure Threshold", + "successThreshold": "Success Threshold", + "recoveryTimeout": "Recovery Timeout", + "errorRateThreshold": "Error Rate Threshold", + "minRequests": "Min Requests", + "timeoutConfig": "Timeout Config", + "streamingFirstByte": "Streaming First Byte Timeout", + "streamingIdle": "Streaming Idle Timeout", + "nonStreaming": "Non-Streaming Timeout" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6554839b7..93567bf0b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -981,6 +981,76 @@ } }, "settings": { + "title": "プロキシサービス設定", + "description": "ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。", + "alert": { + "autoApply": "変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。" + }, + "basic": { + "title": "基本設定", + "description": "プロキシサービスのリッスンアドレスとポートを設定します。" + }, + "advanced": { + "title": "詳細パラメータ", + "description": "リクエストの安定性とログ記録を制御します。" + }, + "timeout": { + "title": "タイムアウト設定", + "description": "ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。" + }, + "fields": { + "listenAddress": { + "label": "リッスンアドレス", + "placeholder": "127.0.0.1", + "description": "プロキシサーバーがリッスンするIPアドレス(推奨: 127.0.0.1)" + }, + "listenPort": { + "label": "リッスンポート", + "placeholder": "5000", + "description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535)" + }, + "maxRetries": { + "label": "最大リトライ回数", + "placeholder": "3", + "description": "リクエスト失敗時のリトライ回数(0 ~ 10)" + }, + "requestTimeout": { + "label": "リクエストタイムアウト(秒)", + "placeholder": "0(無制限)または 300", + "description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)" + }, + "enableLogging": { + "label": "ログ記録を有効化", + "description": "トラブルシューティングのためにすべてのプロキシリクエストを記録" + }, + "streamingFirstByteTimeout": { + "label": "ストリーミング初回バイトタイムアウト(秒)", + "description": "最初のデータチャンクを待つ最大時間" + }, + "streamingIdleTimeout": { + "label": "ストリーミングアイドルタイムアウト(秒)", + "description": "データチャンク間の最大間隔" + }, + "nonStreamingTimeout": { + "label": "非ストリーミングタイムアウト(秒)", + "description": "非ストリーミングリクエストの総タイムアウト" + } + }, + "validation": { + "addressInvalid": "有効なIPアドレスを入力してください", + "portMin": "ポートは1024より大きい必要があります", + "portMax": "ポートは65535より小さい必要があります", + "retryMin": "リトライ回数は負の値にできません", + "retryMax": "リトライ回数は10を超えることはできません", + "timeoutNonNegative": "タイムアウトは負の値にできません", + "timeoutMax": "タイムアウトは600秒を超えることはできません", + "timeoutRange": "0または10-600の間の値を入力してください", + "streamingTimeoutMin": "タイムアウトは少なくとも5秒必要です", + "streamingTimeoutMax": "タイムアウトは300秒を超えることはできません" + }, + "actions": { + "save": "設定を保存" + }, "toast": { "saved": "プロキシ設定を保存しました", "saveFailed": "保存に失敗しました: {{error}}" @@ -1013,7 +1083,7 @@ "failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)", "timeout": "回復待ち時間(秒)", "timeoutHint": "サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)", - "circuitBreakerSettings": "サーキットブレーカー詳細設定", + "circuitBreakerSettings": "サーキットブレーカー設定", "successThreshold": "回復成功しきい値", "successThresholdHint": "半開状態でこの回数成功するとサーキットブレーカーが閉じます", "errorRate": "エラー率しきい値 (%)", @@ -1042,5 +1112,21 @@ "timeout": "タイムアウト(秒)", "maxRetries": "最大リトライ回数", "degradedThreshold": "劣化しきい値(ミリ秒)" + }, + "proxyConfig": { + "proxyEnabled": "プロキシ有効", + "appTakeover": "プロキシ有効", + "perAppConfig": "アプリ別設定", + "circuitBreaker": "サーキットブレーカー", + "circuitBreakerSettings": "サーキットブレーカー設定", + "failureThreshold": "失敗閾値", + "successThreshold": "回復閾値", + "recoveryTimeout": "回復待機時間", + "errorRateThreshold": "エラー率閾値", + "minRequests": "最小リクエスト数", + "timeoutConfig": "タイムアウト設定", + "streamingFirstByte": "ストリーミング初回バイトタイムアウト", + "streamingIdle": "ストリーミングアイドルタイムアウト", + "nonStreaming": "非ストリーミングタイムアウト" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2b7d7cf5e..55aeb99bb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -981,6 +981,76 @@ } }, "settings": { + "title": "代理服务设置", + "description": "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。", + "alert": { + "autoApply": "保存后将自动同步到正在运行的代理服务,无需手动重启。" + }, + "basic": { + "title": "基础设置", + "description": "配置代理服务监听的地址与端口。" + }, + "advanced": { + "title": "高级参数", + "description": "控制请求的稳定性和日志记录。" + }, + "timeout": { + "title": "超时设置", + "description": "配置流式和非流式请求的超时时间。" + }, + "fields": { + "listenAddress": { + "label": "监听地址", + "placeholder": "127.0.0.1", + "description": "代理服务器监听的 IP 地址(推荐 127.0.0.1)" + }, + "listenPort": { + "label": "监听端口", + "placeholder": "5000", + "description": "代理服务器监听的端口号(1024 ~ 65535)" + }, + "maxRetries": { + "label": "最大重试次数", + "placeholder": "3", + "description": "请求失败时的重试次数(0 ~ 10)" + }, + "requestTimeout": { + "label": "请求超时(秒)", + "placeholder": "0(不限)或 300", + "description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)" + }, + "enableLogging": { + "label": "启用日志记录", + "description": "记录所有代理请求,便于排查问题" + }, + "streamingFirstByteTimeout": { + "label": "流式首字超时(秒)", + "description": "等待首个数据块的最大时间" + }, + "streamingIdleTimeout": { + "label": "流式静默超时(秒)", + "description": "数据块之间的最大间隔" + }, + "nonStreamingTimeout": { + "label": "非流式超时(秒)", + "description": "非流式请求的总超时时间" + } + }, + "validation": { + "addressInvalid": "请输入有效的IP地址", + "portMin": "端口必须大于1024", + "portMax": "端口必须小于65535", + "retryMin": "重试次数不能为负", + "retryMax": "重试次数不能超过10", + "timeoutNonNegative": "超时时间不能为负数", + "timeoutMax": "超时时间最多600秒", + "timeoutRange": "请输入 0 或 10-600 之间的数值", + "streamingTimeoutMin": "超时时间至少5秒", + "streamingTimeoutMax": "超时时间最多300秒" + }, + "actions": { + "save": "保存配置" + }, "toast": { "saved": "代理配置已保存", "saveFailed": "保存失败: {{error}}" @@ -1013,7 +1083,7 @@ "failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)", "timeout": "恢复等待时间(秒)", "timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)", - "circuitBreakerSettings": "熔断器高级设置", + "circuitBreakerSettings": "熔断器设置", "successThreshold": "恢复成功阈值", "successThresholdHint": "半开状态下成功多少次后关闭熔断器", "errorRate": "错误率阈值 (%)", @@ -1042,5 +1112,21 @@ "timeout": "超时时间(秒)", "maxRetries": "最大重试次数", "degradedThreshold": "降级阈值(毫秒)" + }, + "proxyConfig": { + "proxyEnabled": "代理总开关", + "appTakeover": "代理启用", + "perAppConfig": "应用配置", + "circuitBreaker": "熔断器配置", + "circuitBreakerSettings": "熔断器设置", + "failureThreshold": "失败阈值", + "successThreshold": "恢复阈值", + "recoveryTimeout": "恢复等待时间", + "errorRateThreshold": "错误率阈值", + "minRequests": "最小请求数", + "timeoutConfig": "超时配置", + "streamingFirstByte": "流式首字节超时", + "streamingIdle": "流式静默超时", + "nonStreaming": "非流式超时" } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 9053449eb..371faa945 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -5,6 +5,7 @@ export { mcpApi } from "./mcp"; export { promptsApi } from "./prompts"; export { usageApi } from "./usage"; export { vscodeApi } from "./vscode"; +export { proxyApi } from "./proxy"; export * as configApi from "./config"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts new file mode 100644 index 000000000..6009fe745 --- /dev/null +++ b/src/lib/api/proxy.ts @@ -0,0 +1,95 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + ProxyConfig, + ProxyStatus, + ProxyServerInfo, + ProxyTakeoverStatus, + GlobalProxyConfig, + AppProxyConfig, +} from "@/types/proxy"; + +export const proxyApi = { + // ========== 代理服务器控制 API ========== + + // 启动代理服务器 + async startProxyServer(): Promise { + return invoke("start_proxy_server"); + }, + + // 停止代理服务器并恢复配置 + async stopProxyWithRestore(): Promise { + return invoke("stop_proxy_with_restore"); + }, + + // 获取代理服务器状态 + async getProxyStatus(): Promise { + return invoke("get_proxy_status"); + }, + + // 检查代理服务器是否正在运行 + async isProxyRunning(): Promise { + return invoke("is_proxy_running"); + }, + + // 检查是否处于接管模式 + async isLiveTakeoverActive(): Promise { + return invoke("is_live_takeover_active"); + }, + + // 代理模式下切换供应商 + async switchProxyProvider( + appType: string, + providerId: string, + ): Promise { + return invoke("switch_proxy_provider", { appType, providerId }); + }, + + // ========== 接管状态 API ========== + + // 获取各应用接管状态 + async getProxyTakeoverStatus(): Promise { + return invoke("get_proxy_takeover_status"); + }, + + // 为指定应用开启/关闭接管 + async setProxyTakeoverForApp( + appType: string, + enabled: boolean, + ): Promise { + return invoke("set_proxy_takeover_for_app", { appType, enabled }); + }, + + // ========== Legacy 代理配置 API (兼容) ========== + + // 获取代理配置(旧版 v2 兼容接口) + async getProxyConfig(): Promise { + return invoke("get_proxy_config"); + }, + + // 更新代理配置(旧版 v2 兼容接口) + async updateProxyConfig(config: ProxyConfig): Promise { + return invoke("update_proxy_config", { config }); + }, + + // ========== v3+ 全局/应用级配置 API ========== + + // 获取全局代理配置 + async getGlobalProxyConfig(): Promise { + return invoke("get_global_proxy_config"); + }, + + // 更新全局代理配置 + async updateGlobalProxyConfig(config: GlobalProxyConfig): Promise { + return invoke("update_global_proxy_config", { config }); + }, + + // 获取指定应用的代理配置 + async getProxyConfigForApp(appType: string): Promise { + return invoke("get_proxy_config_for_app", { appType }); + }, + + // 更新指定应用的代理配置 + async updateProxyConfigForApp(config: AppProxyConfig): Promise { + return invoke("update_proxy_config_for_app", { config }); + }, +}; diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts index a3961bf60..223ec0327 100644 --- a/src/lib/query/index.ts +++ b/src/lib/query/index.ts @@ -1,3 +1,4 @@ export * from "./queryClient"; export * from "./queries"; export * from "./mutations"; +export * from "./proxy"; diff --git a/src/lib/query/proxy.ts b/src/lib/query/proxy.ts new file mode 100644 index 000000000..38e162488 --- /dev/null +++ b/src/lib/query/proxy.ts @@ -0,0 +1,239 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { proxyApi } from "@/lib/api/proxy"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { GlobalProxyConfig, AppProxyConfig } from "@/types/proxy"; + +// ========== 代理服务器状态 Hooks ========== + +/** + * 获取代理服务器状态 + */ +export function useProxyStatus() { + return useQuery({ + queryKey: ["proxyStatus"], + queryFn: () => proxyApi.getProxyStatus(), + refetchInterval: 5000, // 每 5 秒刷新一次 + }); +} + +/** + * 检查代理服务器是否运行 + */ +export function useIsProxyRunning() { + return useQuery({ + queryKey: ["proxyRunning"], + queryFn: () => proxyApi.isProxyRunning(), + refetchInterval: 2000, + }); +} + +/** + * 检查是否处于接管模式 + */ +export function useIsLiveTakeoverActive() { + return useQuery({ + queryKey: ["liveTakeoverActive"], + queryFn: () => proxyApi.isLiveTakeoverActive(), + refetchInterval: 2000, + }); +} + +/** + * 获取各应用接管状态 + */ +export function useProxyTakeoverStatus() { + return useQuery({ + queryKey: ["proxyTakeoverStatus"], + queryFn: () => proxyApi.getProxyTakeoverStatus(), + refetchInterval: 2000, + }); +} + +// ========== 代理服务器控制 Hooks ========== + +/** + * 启动代理服务器 + */ +export function useStartProxyServer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => proxyApi.startProxyServer(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + queryClient.invalidateQueries({ queryKey: ["proxyRunning"] }); + queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] }); + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + }, + }); +} + +/** + * 停止代理服务器 + */ +export function useStopProxyServer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => proxyApi.stopProxyWithRestore(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + queryClient.invalidateQueries({ queryKey: ["proxyRunning"] }); + queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] }); + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + }, + }); +} + +/** + * 设置应用接管状态 + */ +export function useSetProxyTakeoverForApp() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) => + proxyApi.setProxyTakeoverForApp(appType, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] }); + }, + }); +} + +/** + * 代理模式下切换供应商 + */ +export function useSwitchProxyProvider() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + }: { + appType: string; + providerId: string; + }) => proxyApi.switchProxyProvider(appType, providerId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + queryClient.invalidateQueries({ + queryKey: ["providers", variables.appType], + }); + }, + onError: (error: Error) => { + toast.error(t("proxy.switchFailed", { error: error.message })); + }, + }); +} + +// ========== Legacy 代理配置 Hooks (兼容) ========== + +/** + * 获取代理配置(旧版) + */ +export function useProxyConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + const { data: config, isLoading } = useQuery({ + queryKey: ["proxyConfig"], + queryFn: () => proxyApi.getProxyConfig(), + }); + + const updateMutation = useMutation({ + mutationFn: proxyApi.updateProxyConfig, + onSuccess: () => { + toast.success(t("proxy.settings.toast.saved"), { closeButton: true }); + queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + toast.error( + t("proxy.settings.toast.saveFailed", { error: error.message }), + ); + }, + }); + + return { + config, + isLoading, + updateConfig: updateMutation.mutateAsync, + isUpdating: updateMutation.isPending, + }; +} + +// ========== v3+ 全局/应用级配置 Hooks ========== + +/** + * 获取全局代理配置 + */ +export function useGlobalProxyConfig() { + return useQuery({ + queryKey: ["globalProxyConfig"], + queryFn: () => proxyApi.getGlobalProxyConfig(), + }); +} + +/** + * 更新全局代理配置 + */ +export function useUpdateGlobalProxyConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (config: GlobalProxyConfig) => + proxyApi.updateGlobalProxyConfig(config), + onSuccess: () => { + toast.success(t("proxy.settings.toast.saved"), { closeButton: true }); + queryClient.invalidateQueries({ queryKey: ["globalProxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + toast.error( + t("proxy.settings.toast.saveFailed", { error: error.message }), + ); + }, + }); +} + +/** + * 获取指定应用的代理配置 + */ +export function useAppProxyConfig(appType: string) { + return useQuery({ + queryKey: ["appProxyConfig", appType], + queryFn: () => proxyApi.getProxyConfigForApp(appType), + enabled: !!appType, + }); +} + +/** + * 更新指定应用的代理配置 + */ +export function useUpdateAppProxyConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (config: AppProxyConfig) => + proxyApi.updateProxyConfigForApp(config), + onSuccess: (_, variables) => { + toast.success(t("proxy.settings.toast.saved"), { closeButton: true }); + queryClient.invalidateQueries({ + queryKey: ["appProxyConfig", variables.appType], + }); + queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["circuitBreakerConfig"] }); + }, + onError: (error: Error) => { + toast.error( + t("proxy.settings.toast.saveFailed", { error: error.message }), + ); + }, + }); +} diff --git a/src/types/proxy.ts b/src/types/proxy.ts index cc809dfeb..4d11370ad 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -5,6 +5,10 @@ export interface ProxyConfig { request_timeout: number; enable_logging: boolean; live_takeover_active?: boolean; + // 超时配置 + streaming_first_byte_timeout: number; + streaming_idle_timeout: number; + non_streaming_timeout: number; } export interface ProxyStatus { @@ -105,3 +109,27 @@ export interface FailoverQueueItem { providerName: string; sortIndex?: number; } + +// 全局代理配置(统一字段,三行镜像) +export interface GlobalProxyConfig { + proxyEnabled: boolean; + listenAddress: string; + listenPort: number; + enableLogging: boolean; +} + +// 应用级代理配置(每个 app 独立) +export interface AppProxyConfig { + appType: string; + enabled: boolean; + autoFailoverEnabled: boolean; + maxRetries: number; + streamingFirstByteTimeout: number; + streamingIdleTimeout: number; + nonStreamingTimeout: number; + circuitFailureThreshold: number; + circuitSuccessThreshold: number; + circuitTimeoutSeconds: number; + circuitErrorRateThreshold: number; + circuitMinRequests: number; +} From 8f58c08d0dc9c15d4c1119880cdab833f784756d Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 25 Dec 2025 16:04:34 +0800 Subject: [PATCH 032/126] fix(database): add backward compatibility check for proxy_config seed insert Add has_column check before inserting seed data into proxy_config table. This prevents SQL errors when upgrading from older databases where proxy_config was a singleton table without the app_type column. The migration function will handle the table structure upgrade and insert the three rows after converting to the new schema. --- src-tauri/src/database/schema.rs | 60 ++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 70ab96d68..0a1429110 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -113,33 +113,39 @@ impl Database { )", []).map_err(|e| AppError::Database(e.to_string()))?; // 初始化三行数据(每应用不同默认值) - conn.execute( - "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, - streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, - circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests) - VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - conn.execute( - "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, - streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, - circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests) - VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - conn.execute( - "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, - streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, - circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests) - VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; + // + // 兼容旧数据库: + // - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert; + // - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。 + if Self::has_column(conn, "proxy_config", "app_type")? { + conn.execute( + "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests) + VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute( + "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests) + VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute( + "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests) + VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } // 9. Provider Health 表 conn.execute("CREATE TABLE IF NOT EXISTS provider_health ( From c87bb43aaafe1acc2325eee1342b210b89b963db Mon Sep 17 00:00:00 2001 From: Weiyi Xu Date: Thu, 25 Dec 2025 17:30:29 +0800 Subject: [PATCH 033/126] feat: add xiaomi mimo icon and claude provider configuration (#470) --- src/config/claudeProviderPresets.ts | 18 ++++++++++++++++++ src/icons/extracted/index.ts | 1 + src/icons/extracted/metadata.ts | 7 +++++++ src/icons/extracted/xiaomimimo.svg | 1 + 4 files changed, 27 insertions(+) create mode 100644 src/icons/extracted/xiaomimimo.svg diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 851e9e794..8de5067d2 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -391,4 +391,22 @@ export const providerPresets: ProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "Xiaomi MiMo", + websiteUrl: "https://platform.xiaomimimo.com", + apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.xiaomimimo.com/anthropic", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "mimo-v2-flash", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2-flash", + ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2-flash", + ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2-flash", + }, + }, + category: "cn_official", + icon: "xiaomimimo", + iconColor: "#000000", + }, ]; diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 507a41a7b..c9f56c3f8 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -42,6 +42,7 @@ export const icons: Record = { vercel: `Vercel`, wenxin: `Wenxin`, xai: `Grok`, + 'xiaomimimo': `Logo`, yi: `Yi`, zeroone: `01.AI`, zhipu: `Zhipu`, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index c0a9de38c..0224fcbfe 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -331,6 +331,13 @@ export const iconMetadata: Record = { keywords: ["aihubmix", "hub", "mix", "aggregator"], defaultColor: "#006FFB", }, + xiaomimimo: { + name: "xiaomimimo", + displayName: "Xiaomi MiMo", + category: "ai-provider", + keywords: ["xiaomimimo", "xiaomi", "mimo"], + defaultColor: "#000000", + }, }; export function getIconMetadata(name: string): IconMetadata | undefined { diff --git a/src/icons/extracted/xiaomimimo.svg b/src/icons/extracted/xiaomimimo.svg new file mode 100644 index 000000000..8467fa9f0 --- /dev/null +++ b/src/icons/extracted/xiaomimimo.svg @@ -0,0 +1 @@ +Logo \ No newline at end of file From bb2756d0fb65a3e399285d3d97501f4ae7e4548b Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 25 Dec 2025 16:57:41 +0800 Subject: [PATCH 034/126] fix(ui): improve dark mode text contrast for form labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded Tailwind color classes with design system CSS variables to improve text visibility in dark mode: - text-gray-900 dark:text-gray-100 → text-foreground - text-gray-500/600 dark:text-gray-400 → text-muted-foreground - bg-white dark:bg-gray-800 → bg-background --- src/components/env/EnvWarningBanner.tsx | 4 +-- src/components/mcp/McpWizardModal.tsx | 30 +++++++++---------- .../providers/forms/ApiKeyInput.tsx | 6 ++-- .../providers/forms/CodexConfigSections.tsx | 10 +++---- .../providers/forms/CodexFormFields.tsx | 6 ++-- .../providers/forms/GeminiConfigSections.tsx | 10 +++---- .../providers/forms/shared/EndpointField.tsx | 2 +- src/components/skills/SkillsPage.tsx | 8 ++--- 8 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx index 20d4e8d76..da31ce929 100644 --- a/src/components/env/EnvWarningBanner.tsx +++ b/src/components/env/EnvWarningBanner.tsx @@ -191,11 +191,11 @@ export function EnvWarningBanner({
-

+

{t("env.field.value")}: {conflict.varValue}

diff --git a/src/components/mcp/McpWizardModal.tsx b/src/components/mcp/McpWizardModal.tsx index fe4336f7f..a5cf5de2c 100644 --- a/src/components/mcp/McpWizardModal.tsx +++ b/src/components/mcp/McpWizardModal.tsx @@ -239,7 +239,7 @@ const McpWizardModal: React.FC = ({

{/* Hint */}
-

+

{t("mcp.wizard.hint")}

@@ -248,7 +248,7 @@ const McpWizardModal: React.FC = ({
{/* Type */}
-