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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CC Switch

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

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

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

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

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

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

+
+

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

+

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

+

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

+

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

+

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

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

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

+

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

+
+
+
+ + + +
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 289e65654..9cd7cc1c3 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,11 +1,18 @@ import { Toaster as SonnerToaster } from "sonner"; +import { useTheme } from "@/components/theme-provider"; export function Toaster() { + const { theme } = useTheme(); + + // 将应用主题映射到 Sonner 的主题 + // 如果是 "system",Sonner 会自己处理 + const sonnerTheme = theme === "system" ? "system" : theme; + return ( { return await invoke("set_rectifier_config", { config }); }, + + async getLogConfig(): Promise { + return await invoke("get_log_config"); + }, + + async setLogConfig(config: LogConfig): Promise { + return await invoke("set_log_config", { config }); + }, }; export interface RectifierConfig { enabled: boolean; requestThinkingSignature: boolean; } + +export interface LogConfig { + enabled: boolean; + level: "error" | "warn" | "info" | "debug" | "trace"; +} diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 4ba57bcfa..10049eb80 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -1,5 +1,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { failoverApi } from "@/lib/api/failover"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { extractErrorMessage } from "@/utils/errorUtils"; // ========== 熔断器 Hooks ========== @@ -197,6 +200,7 @@ export function useAutoFailoverEnabled(appType: string) { */ export function useSetAutoFailoverEnabled() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) => @@ -217,14 +221,46 @@ export function useSetAutoFailoverEnabled() { return { previousValue, appType }; }, + onSuccess: (_data, variables) => { + const appLabel = + variables.appType === "claude" + ? "Claude" + : variables.appType === "codex" + ? "Codex" + : "Gemini"; + + toast.success( + variables.enabled + ? t("failover.enabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已启用`, + }) + : t("failover.disabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已关闭`, + }), + { closeButton: true }, + ); + }, + // 错误时回滚 - onError: (_error, _variables, context) => { + onError: (error: Error, _variables, context) => { if (context?.previousValue !== undefined) { queryClient.setQueryData( ["autoFailoverEnabled", context.appType], context.previousValue, ); } + + const detail = + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); + toast.error( + t("failover.toggleFailed", { + detail, + defaultValue: `操作失败: ${detail}`, + }), + ); }, // 无论成功失败,都重新获取 diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 73d75c854..0aa84356d 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -12,7 +12,7 @@ export const useAddProviderMutation = (appId: AppId) => { return useMutation({ mutationFn: async ( - providerInput: Omit & { providerKey?: string } + providerInput: Omit & { providerKey?: string }, ) => { let id: string; diff --git a/src/types.ts b/src/types.ts index dcf0b5347..9fd73774a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,38 @@ export interface UsageResult { error?: string; } +// 供应商单独的模型测试配置 +export interface ProviderTestConfig { + // 是否启用单独配置(false 时使用全局配置) + enabled: boolean; + // 测试用的模型名称(覆盖全局配置) + testModel?: string; + // 超时时间(秒) + timeoutSecs?: number; + // 测试提示词 + testPrompt?: string; + // 降级阈值(毫秒) + degradedThresholdMs?: number; + // 最大重试次数 + maxRetries?: number; +} + +// 供应商单独的代理配置 +export interface ProviderProxyConfig { + // 是否启用单独配置(false 时使用全局/系统代理) + enabled: boolean; + // 代理类型:http, https, socks5 + proxyType?: "http" | "https" | "socks5"; + // 代理主机 + proxyHost?: string; + // 代理端口 + proxyPort?: number; + // 代理用户名(可选) + proxyUsername?: string; + // 代理密码(可选) + proxyPassword?: string; +} + // 供应商元数据(字段名与后端一致,保持 snake_case) export interface ProviderMeta { // 自定义端点:以 URL 为键,值为端点信息 @@ -99,6 +131,10 @@ export interface ProviderMeta { isPartner?: boolean; // 合作伙伴促销 key(用于后端识别 PackyCode 等) partnerPromotionKey?: string; + // 供应商单独的模型测试配置 + testConfig?: ProviderTestConfig; + // 供应商单独的代理配置 + proxyConfig?: ProviderProxyConfig; } // 应用设置类型(用于设置对话框与 Tauri API) @@ -294,4 +330,3 @@ export interface OpenCodeMcpServerSpec { // 通用字段 enabled?: boolean; } -