diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 79a8e49db..3366003e1 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -15,12 +15,26 @@ pub async fn start_proxy_server( state.proxy_service.start().await } +/// 启动代理服务器(带 Live 配置接管) +#[tauri::command] +pub async fn start_proxy_with_takeover( + state: tauri::State<'_, AppState>, +) -> Result { + state.proxy_service.start_with_takeover().await +} + /// 停止代理服务器 #[tauri::command] pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> { state.proxy_service.stop().await } +/// 停止代理服务器(恢复 Live 配置) +#[tauri::command] +pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> { + state.proxy_service.stop_with_restore().await +} + /// 获取代理服务器状态 #[tauri::command] pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result { @@ -48,6 +62,25 @@ pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result) -> Result { + state.proxy_service.is_takeover_active().await +} + +/// 代理模式下切换供应商(热切换) +#[tauri::command] +pub async fn switch_proxy_provider( + state: tauri::State<'_, AppState>, + app_type: String, + provider_id: String, +) -> Result<(), String> { + state + .proxy_service + .switch_proxy_target(&app_type, &provider_id) + .await +} + // ==================== 故障转移相关命令 ==================== /// 获取代理目标列表 diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 0c451512d..509d9b3ef 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -17,7 +17,7 @@ impl Database { let conn = lock_conn!(self.conn); conn.query_row( "SELECT enabled, listen_address, listen_port, max_retries, - request_timeout, enable_logging + request_timeout, enable_logging, live_takeover_active FROM proxy_config WHERE id = 1", [], |row| { @@ -28,6 +28,7 @@ impl Database { 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, }) }, ) @@ -51,8 +52,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO proxy_config - (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at) - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, + (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), datetime('now'))", rusqlite::params![ @@ -62,6 +63,7 @@ impl Database { config.max_retries as i32, config.request_timeout as i32, if config.enable_logging { 1 } else { 0 }, + if config.live_takeover_active { 1 } else { 0 }, "claude", // 兼容旧字段,写入默认值 ], ) @@ -70,6 +72,30 @@ impl Database { Ok(()) } + /// 设置 Live 接管状态 + pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1", + rusqlite::params![if active { 1 } else { 0 }], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 检查是否处于 Live 接管模式 + pub async fn is_live_takeover_active(&self) -> Result { + let conn = lock_conn!(self.conn); + let active: i32 = conn + .query_row( + "SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap_or(0); + Ok(active != 0) + } + // ==================== Provider Health ==================== /// 获取Provider健康状态 @@ -318,4 +344,74 @@ impl Database { Ok(()) } + + // ==================== Live Backup ==================== + + /// 保存 Live 配置备份 + pub async fn save_live_backup( + &self, + app_type: &str, + config_json: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at) + VALUES (?1, ?2, ?3)", + rusqlite::params![app_type, config_json, now], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + log::info!("已备份 {app_type} Live 配置"); + Ok(()) + } + + /// 获取 Live 配置备份 + pub async fn get_live_backup(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let result = conn.query_row( + "SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1", + rusqlite::params![app_type], + |row| { + Ok(LiveBackup { + app_type: row.get(0)?, + original_config: row.get(1)?, + backed_up_at: row.get(2)?, + }) + }, + ); + + match result { + Ok(backup) => Ok(Some(backup)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 删除 Live 配置备份 + pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "DELETE FROM proxy_live_backup WHERE app_type = ?1", + rusqlite::params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + log::info!("已删除 {app_type} Live 配置备份"); + Ok(()) + } + + /// 删除所有 Live 配置备份 + pub async fn delete_all_live_backups(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute("DELETE FROM proxy_live_backup", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + log::info!("已删除所有 Live 配置备份"); + Ok(()) + } } diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 2569cdf47..b893b1f17 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -360,6 +360,23 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 16. Proxy Live Backup 表 (Live 配置备份) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_live_backup ( + app_type TEXT PRIMARY KEY, + original_config TEXT NOT NULL, + backed_up_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 尝试添加 live_takeover_active 列到 proxy_config 表 + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0", + [], + ); + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a17190c27..1b20bf216 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -648,11 +648,15 @@ pub fn run() { commands::get_auto_launch_status, // Proxy server management commands::start_proxy_server, + commands::start_proxy_with_takeover, commands::stop_proxy_server, + commands::stop_proxy_with_restore, commands::get_proxy_status, commands::get_proxy_config, commands::update_proxy_config, commands::is_proxy_running, + commands::is_live_takeover_active, + commands::switch_proxy_provider, // Proxy failover commands commands::get_proxy_targets, commands::set_proxy_target, diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 6da189c36..b66b0026b 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -148,17 +148,24 @@ impl ProxyServer { // 健康检查 .route("/health", get(handlers::health_check)) .route("/status", get(handlers::get_status)) - // Claude API + // Claude API (支持带前缀和不带前缀两种格式) .route("/v1/messages", post(handlers::handle_messages)) - // OpenAI Chat Completions API (Codex CLI) + .route("/claude/v1/messages", post(handlers::handle_messages)) + // OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀) .route( "/v1/chat/completions", post(handlers::handle_chat_completions), ) - // OpenAI Responses API (Codex CLI) + .route( + "/codex/v1/chat/completions", + post(handlers::handle_chat_completions), + ) + // OpenAI Responses API (Codex CLI,支持带前缀和不带前缀) .route("/v1/responses", post(handlers::handle_responses)) - // Gemini API + .route("/codex/v1/responses", post(handlers::handle_responses)) + // Gemini API (支持带前缀和不带前缀) .route("/v1beta/*path", post(handlers::handle_gemini)) + .route("/gemini/v1beta/*path", post(handlers::handle_gemini)) .layer(cors) .with_state(self.state.clone()) } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 722862977..5dff77400 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -15,6 +15,9 @@ pub struct ProxyConfig { pub request_timeout: u64, /// 是否启用日志 pub enable_logging: bool, + /// 是否正在接管 Live 配置 + #[serde(default)] + pub live_takeover_active: bool, } impl Default for ProxyConfig { @@ -26,6 +29,7 @@ impl Default for ProxyConfig { max_retries: 3, request_timeout: 300, enable_logging: true, + live_takeover_active: false, } } } @@ -117,3 +121,14 @@ pub struct ProxyUsageRecord { pub error: Option, pub timestamp: String, } + +/// Live 配置备份记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiveBackup { + /// 应用类型 (claude/codex/gemini) + pub app_type: String, + /// 原始配置 JSON + pub original_config: String, + /// 备份时间 + pub backed_up_at: String, +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 8e4c5f965..a1e8e025c 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -173,14 +173,70 @@ impl ProviderService { /// /// Switch flow: /// 1. Validate target provider exists - /// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications - /// 3. Update local settings current_provider_xxx (device-level) - /// 4. Update database is_current (as default for new devices) - /// 5. Write target provider config to live files - /// 6. Sync MCP configuration + /// 2. Check if proxy takeover mode is active AND proxy server is running + /// 3. If takeover mode active: hot-switch proxy target only (no Live config write) + /// 4. If normal mode: + /// a. **Backfill mechanism**: Backfill current live config to current provider + /// b. Update local settings current_provider_xxx (device-level) + /// c. Update database is_current (as default for new devices) + /// d. Write target provider config to live files + /// e. Sync MCP configuration pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { // Check if provider exists let providers = state.db.get_all_providers(app_type.as_str())?; + let _provider = providers + .get(id) + .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + + // Check if proxy takeover mode is active AND proxy server is actually running + // Both conditions must be true to use hot-switch mode + // Use blocking wait since this is a sync function + let is_takeover_flag = + futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false); + let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); + + // Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running + let should_hot_switch = is_takeover_flag && is_proxy_running; + + if should_hot_switch { + // Proxy takeover mode: hot-switch only, don't write Live config + log::info!( + "代理接管模式:热切换 {} 的目标供应商为 {}", + app_type.as_str(), + id + ); + + // Update database is_current (proxy server reads this) + state.db.set_current_provider(app_type.as_str(), id)?; + + // Update local settings for consistency + crate::settings::set_current_provider(&app_type, Some(id))?; + + // Note: No Live config write, no MCP sync + // The proxy server will route requests to the new provider + return Ok(()); + } + + // Normal mode: full switch with Live config write + // Also clear stale takeover flag if proxy is not running but flag was set + if is_takeover_flag && !is_proxy_running { + log::warn!( + "检测到代理接管标志残留(代理已停止),清除标志并执行正常切换" + ); + // Clear stale takeover flag + let _ = futures::executor::block_on(state.db.set_live_takeover_active(false)); + } + + Self::switch_normal(state, app_type, id, &providers) + } + + /// Normal switch flow (non-proxy mode) + fn switch_normal( + state: &AppState, + app_type: AppType, + id: &str, + providers: &indexmap::IndexMap, + ) -> Result<(), AppError> { let provider = providers .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 97bf9de3a..2d3449875 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -2,9 +2,13 @@ //! //! 提供代理服务器的启动、停止和配置管理 +use crate::app_config::AppType; +use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; +use serde_json::{json, Value}; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; @@ -59,6 +63,66 @@ impl ProxyService { Ok(info) } + /// 启动代理服务器(带 Live 配置接管) + pub async fn start_with_takeover(&self) -> Result { + // 1. 自动将各应用当前选中的供应商设置为代理目标 + self.setup_proxy_targets().await?; + + // 2. 备份各应用的 Live 配置 + self.backup_live_configs().await?; + + // 3. 接管各应用的 Live 配置(写入代理地址) + self.takeover_live_configs().await?; + + // 4. 设置接管状态 + self.db + .set_live_takeover_active(true) + .await + .map_err(|e| format!("设置接管状态失败: {e}"))?; + + // 5. 启动代理服务器 + match self.start().await { + Ok(info) => Ok(info), + Err(e) => { + // 启动失败,恢复原始配置 + log::error!("代理启动失败,尝试恢复原始配置: {e}"); + let _ = self.restore_live_configs().await; + let _ = self.db.set_live_takeover_active(false).await; + Err(e) + } + } + } + + /// 自动设置代理目标:将各应用当前选中的供应商设置为代理目标 + async fn setup_proxy_targets(&self) -> Result<(), String> { + let app_types = ["claude", "codex", "gemini"]; + + for app_type in app_types { + // 获取当前选中的供应商 + if let Ok(Some(provider_id)) = self.db.get_current_provider(app_type) { + // 设置为代理目标 + if let Err(e) = self.db.set_proxy_target(&provider_id, app_type, true).await { + log::warn!( + "设置 {} 的代理目标 {} 失败: {}", + app_type, + provider_id, + e + ); + } else { + log::info!( + "已将 {} 的当前供应商 {} 设置为代理目标", + app_type, + provider_id + ); + } + } else { + log::debug!("{} 没有当前供应商,跳过代理目标设置", app_type); + } + } + + Ok(()) + } + /// 停止代理服务器 pub async fn stop(&self) -> Result<(), String> { if let Some(server) = self.server.write().await.take() { @@ -73,6 +137,259 @@ impl ProxyService { } } + /// 停止代理服务器(恢复 Live 配置) + pub async fn stop_with_restore(&self) -> Result<(), String> { + // 1. 停止代理服务器 + self.stop().await?; + + // 2. 恢复原始 Live 配置 + self.restore_live_configs().await?; + + // 3. 清除接管状态 + self.db + .set_live_takeover_active(false) + .await + .map_err(|e| format!("清除接管状态失败: {e}"))?; + + // 4. 删除备份 + self.db + .delete_all_live_backups() + .await + .map_err(|e| format!("删除备份失败: {e}"))?; + + log::info!("代理已停止,Live 配置已恢复"); + Ok(()) + } + + /// 备份各应用的 Live 配置 + async fn backup_live_configs(&self) -> Result<(), String> { + // Claude + if let Ok(config) = self.read_claude_live() { + let json_str = serde_json::to_string(&config) + .map_err(|e| format!("序列化 Claude 配置失败: {e}"))?; + self.db + .save_live_backup("claude", &json_str) + .await + .map_err(|e| format!("备份 Claude 配置失败: {e}"))?; + } + + // Codex + if let Ok(config) = self.read_codex_live() { + let json_str = serde_json::to_string(&config) + .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?; + self.db + .save_live_backup("codex", &json_str) + .await + .map_err(|e| format!("备份 Codex 配置失败: {e}"))?; + } + + // Gemini + if let Ok(config) = self.read_gemini_live() { + let json_str = serde_json::to_string(&config) + .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?; + self.db + .save_live_backup("gemini", &json_str) + .await + .map_err(|e| format!("备份 Gemini 配置失败: {e}"))?; + } + + log::info!("已备份所有应用的 Live 配置"); + Ok(()) + } + + /// 接管各应用的 Live 配置(写入代理地址) + /// + /// 代理服务器的路由已经根据 API 端点自动区分应用类型: + /// - `/v1/messages` → Claude + /// - `/v1/chat/completions`, `/v1/responses` → Codex + /// - `/v1beta/*` → Gemini + /// + /// 因此不需要在 URL 中添加应用前缀。 + async fn takeover_live_configs(&self) -> Result<(), String> { + let config = self + .db + .get_proxy_config() + .await + .map_err(|e| format!("获取代理配置失败: {e}"))?; + + let proxy_url = format!("http://{}:{}", config.listen_address, config.listen_port); + + // Claude: 修改 ANTHROPIC_BASE_URL + if let Ok(mut live_config) = self.read_claude_live() { + if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { + env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url)); + } else { + live_config["env"] = json!({ + "ANTHROPIC_BASE_URL": &proxy_url + }); + } + self.write_claude_live(&live_config)?; + log::info!("Claude Live 配置已接管,代理地址: {}", proxy_url); + } + + // Codex: 修改 OPENAI_BASE_URL + if let Ok(mut live_config) = self.read_codex_live() { + if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { + auth.insert("OPENAI_BASE_URL".to_string(), json!(&proxy_url)); + } + self.write_codex_live(&live_config)?; + log::info!("Codex Live 配置已接管,代理地址: {}", proxy_url); + } + + // Gemini: 修改 GEMINI_API_BASE + if let Ok(mut live_config) = self.read_gemini_live() { + if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { + env.insert("GEMINI_API_BASE".to_string(), json!(&proxy_url)); + } else { + live_config["env"] = json!({ + "GEMINI_API_BASE": &proxy_url + }); + } + self.write_gemini_live(&live_config)?; + log::info!("Gemini Live 配置已接管,代理地址: {}", proxy_url); + } + + Ok(()) + } + + /// 恢复原始 Live 配置 + async fn restore_live_configs(&self) -> Result<(), String> { + // Claude + if let Ok(Some(backup)) = self.db.get_live_backup("claude").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Claude 备份失败: {e}"))?; + self.write_claude_live(&config)?; + log::info!("Claude Live 配置已恢复"); + } + + // Codex + if let Ok(Some(backup)) = self.db.get_live_backup("codex").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Codex 备份失败: {e}"))?; + self.write_codex_live(&config)?; + log::info!("Codex Live 配置已恢复"); + } + + // Gemini + if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Gemini 备份失败: {e}"))?; + self.write_gemini_live(&config)?; + log::info!("Gemini Live 配置已恢复"); + } + + Ok(()) + } + + /// 检查是否处于 Live 接管模式 + pub async fn is_takeover_active(&self) -> Result { + self.db + .is_live_takeover_active() + .await + .map_err(|e| format!("检查接管状态失败: {e}")) + } + + /// 代理模式下切换供应商(热切换,不写 Live) + pub async fn switch_proxy_target( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), String> { + // 更新数据库中的 is_current 标记 + let app_type_enum = AppType::from_str(app_type) + .map_err(|_| format!("无效的应用类型: {app_type}"))?; + + self.db + .set_current_provider(app_type_enum.as_str(), provider_id) + .map_err(|e| format!("更新当前供应商失败: {e}"))?; + + log::info!( + "代理模式:已切换 {} 的目标供应商为 {}", + app_type, + provider_id + ); + Ok(()) + } + + // ==================== Live 配置读写辅助方法 ==================== + + fn read_claude_live(&self) -> Result { + let path = get_claude_settings_path(); + if !path.exists() { + return Err("Claude 配置文件不存在".to_string()); + } + read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}")) + } + + fn write_claude_live(&self, config: &Value) -> Result<(), String> { + let path = get_claude_settings_path(); + write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}")) + } + + fn read_codex_live(&self) -> Result { + use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; + + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Err("Codex auth.json 不存在".to_string()); + } + + let auth: Value = + read_json_file(&auth_path).map_err(|e| format!("读取 Codex auth 失败: {e}"))?; + + let config_path = get_codex_config_path(); + let config_str = if config_path.exists() { + std::fs::read_to_string(&config_path) + .map_err(|e| format!("读取 Codex config 失败: {e}"))? + } else { + String::new() + }; + + Ok(json!({ + "auth": auth, + "config": config_str + })) + } + + fn write_codex_live(&self, config: &Value) -> Result<(), String> { + use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; + + if let Some(auth) = config.get("auth") { + let auth_path = get_codex_auth_path(); + write_json_file(&auth_path, auth).map_err(|e| format!("写入 Codex auth 失败: {e}"))?; + } + + if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) { + let config_path = get_codex_config_path(); + std::fs::write(&config_path, config_str) + .map_err(|e| format!("写入 Codex config 失败: {e}"))?; + } + + Ok(()) + } + + fn read_gemini_live(&self) -> Result { + use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env}; + + let env_path = get_gemini_env_path(); + if !env_path.exists() { + return Err("Gemini .env 文件不存在".to_string()); + } + + let env_map = read_gemini_env().map_err(|e| format!("读取 Gemini env 失败: {e}"))?; + Ok(env_to_json(&env_map)) + } + + fn write_gemini_live(&self, config: &Value) -> Result<(), String> { + use crate::gemini_config::{json_to_env, write_gemini_env_atomic}; + + let env_map = json_to_env(config).map_err(|e| format!("转换 Gemini 配置失败: {e}"))?; + write_gemini_env_atomic(&env_map).map_err(|e| format!("写入 Gemini env 失败: {e}"))?; + Ok(()) + } + + // ==================== 原有方法 ==================== + /// 获取服务器状态 pub async fn get_status(&self) -> Result { if let Some(server) = self.server.read().await.as_ref() { @@ -103,9 +420,10 @@ impl ProxyService { .await .map_err(|e| format!("获取代理配置失败: {e}"))?; - // 保存到数据库(保持 enabled 状态不变) + // 保存到数据库(保持 enabled 和 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 .update_proxy_config(new_config.clone()) diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 85cccc0aa..133c58fda 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -525,7 +525,10 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro // 检查端口是否匹配(考虑默认端口) // 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443) - match (parsed_request.port_or_known_default(), parsed_base.port_or_known_default()) { + match ( + parsed_request.port_or_known_default(), + parsed_base.port_or_known_default(), + ) { (Some(request_port), Some(base_port)) if request_port == base_port => { // 端口匹配,继续执行 } @@ -534,13 +537,11 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro "usage_script.request_port_mismatch", format!( "请求端口 {} 必须与 base_url 端口 {} 匹配", - request_port, - base_port + request_port, base_port ), format!( "Request port {} must match base_url port {}", - request_port, - base_port + request_port, base_port ), )); } @@ -774,22 +775,25 @@ mod tests { fn test_https_bypass_prevention() { // 非本地域名的 HTTP 应该被拒绝 let result = validate_base_url("http://127.0.0.1.evil.com/api"); - assert!(result.is_err(), "Should reject HTTP for non-localhost domains"); + assert!( + result.is_err(), + "Should reject HTTP for non-localhost domains" + ); } #[test] fn test_edge_cases() { // 边界情况测试 - assert!(is_private_ip("172.16.0.0")); // RFC1918起始 + assert!(is_private_ip("172.16.0.0")); // RFC1918起始 assert!(is_private_ip("172.31.255.255")); // RFC1918结束 - assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始 + assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始 assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束 - assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始 + assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始 assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束 // 紧邻RFC1918的公网地址 - 应该返回false assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个 - assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个 + assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个 } #[test] @@ -815,27 +819,57 @@ mod tests { // 测试用例:(base_url, request_url, should_match) let test_cases = vec![ // HTTPS默认端口测试 - ("https://api.example.com", "https://api.example.com/v1/test", true), - ("https://api.example.com", "https://api.example.com:443/v1/test", true), - ("https://api.example.com:443", "https://api.example.com/v1/test", true), - ("https://api.example.com:443", "https://api.example.com:443/v1/test", true), - + ( + "https://api.example.com", + "https://api.example.com/v1/test", + true, + ), + ( + "https://api.example.com", + "https://api.example.com:443/v1/test", + true, + ), + ( + "https://api.example.com:443", + "https://api.example.com/v1/test", + true, + ), + ( + "https://api.example.com:443", + "https://api.example.com:443/v1/test", + true, + ), // 端口不匹配测试 - ("https://api.example.com", "https://api.example.com:8443/v1/test", false), - ("https://api.example.com:443", "https://api.example.com:8443/v1/test", false), + ( + "https://api.example.com", + "https://api.example.com:8443/v1/test", + false, + ), + ( + "https://api.example.com:443", + "https://api.example.com:8443/v1/test", + false, + ), ]; for (base_url, request_url, should_match) in test_cases { let result = validate_request_url(request_url, base_url); if should_match { - assert!(result.is_ok(), + assert!( + result.is_ok(), "应该匹配的URL被拒绝: base_url={}, request_url={}, error={}", - base_url, request_url, result.unwrap_err()); + base_url, + request_url, + result.unwrap_err() + ); } else { - assert!(result.is_err(), + assert!( + result.is_err(), "应该不匹配的URL被允许: base_url={}, request_url={}", - base_url, request_url); + base_url, + request_url + ); } } } diff --git a/src/App.tsx b/src/App.tsx index 05c695206..c142cb0c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import { SettingsPage } from "@/components/settings/SettingsPage"; import { UpdateBadge } from "@/components/UpdateBadge"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; +import { ProxyToggle } from "@/components/proxy/ProxyToggle"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; @@ -63,7 +64,7 @@ function App() { "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; // 获取代理服务状态 - const { isRunning: isProxyRunning } = useProxyStatus(); + const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus(); // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { @@ -321,6 +322,7 @@ function App() { appId={activeApp} isLoading={isLoading} isProxyRunning={isProxyRunning} + isProxyTakeover={isProxyRunning && isTakeoverActive} onSwitch={switchProvider} onEdit={setEditingProvider} onDelete={setConfirmDelete} @@ -482,6 +484,8 @@ function App() { )} {currentView === "providers" && ( <> + +
diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index e6afe4240..b0fbef103 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -40,6 +40,7 @@ interface ProviderCardProps { onTest?: (provider: Provider) => void; isTesting?: boolean; isProxyRunning: boolean; + isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...) allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级 dragHandleProps?: DragHandleProps; @@ -93,6 +94,7 @@ export function ProviderCard({ onTest, isTesting, isProxyRunning, + isProxyTakeover = false, proxyPriority, allProviders, dragHandleProps, @@ -231,7 +233,12 @@ export function ProviderCard({ className={cn( "relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300", "bg-card text-card-foreground group hover:border-border-active", - isCurrent ? "border-primary/50 shadow-sm" : "hover:shadow-sm", + // 代理接管模式下当前供应商使用绿色边框 + isProxyTakeover && isCurrent + ? "border-emerald-500/60 shadow-sm shadow-emerald-500/10" + : isCurrent + ? "border-primary/50 shadow-sm" + : "hover:shadow-sm", dragHandleProps?.isDragging && "cursor-grabbing border-primary shadow-lg scale-105 z-10", )} diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 6fcff0b61..610f16ae0 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -27,6 +27,7 @@ interface ProviderListProps { onCreate?: () => void; isLoading?: boolean; isProxyRunning?: boolean; // 代理服务运行状态 + isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管) } export function ProviderList({ @@ -42,6 +43,7 @@ export function ProviderList({ onCreate, isLoading = false, isProxyRunning = false, // 默认值为 false + isProxyTakeover = false, // 默认值为 false }: ProviderListProps) { const { sortedProviders, sensors, handleDragEnd } = useDragSort( providers, @@ -122,6 +124,7 @@ export function ProviderList({ onTest={handleTest} isTesting={isTesting(provider.id)} isProxyRunning={isProxyRunning} + isProxyTakeover={isProxyTakeover} proxyPriority={proxyPriorityMap.get(provider.id)} allProviders={sortedProviders} /> @@ -145,6 +148,7 @@ interface SortableProviderCardProps { onTest: (provider: Provider) => void; isTesting: boolean; isProxyRunning: boolean; + isProxyTakeover: boolean; proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...) allProviders?: Provider[]; // 所有供应商列表 } @@ -162,6 +166,7 @@ function SortableProviderCard({ onTest, isTesting, isProxyRunning, + isProxyTakeover, proxyPriority, allProviders, }: SortableProviderCardProps) { @@ -196,6 +201,7 @@ function SortableProviderCard({ onTest={onTest} isTesting={isTesting} isProxyRunning={isProxyRunning} + isProxyTakeover={isProxyTakeover} proxyPriority={proxyPriority} allProviders={allProviders} dragHandleProps={{ diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 9e8710f28..5b92ee9d0 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -18,8 +18,11 @@ export interface AutoFailoverConfigPanelProps { export function AutoFailoverConfigPanel({ enabled, - onEnabledChange, + onEnabledChange: _onEnabledChange, }: AutoFailoverConfigPanelProps) { + // Note: onEnabledChange is currently unused but kept in the interface + // for potential future use by parent components + void _onEnabledChange; const { t } = useTranslation(); const { data: config, isLoading, error } = useCircuitBreakerConfig(); const updateConfig = useUpdateCircuitBreakerConfig(); @@ -262,7 +265,7 @@ export function AutoFailoverConfigPanel({