diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 063e7a8..0d22240 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3849,6 +3849,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap 2.12.0", "itoa", "memchr", "ryu", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c5db0fc..0fefef7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ tauri-plugin-shell = "2" tauri-plugin-single-instance = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } dirs = "6" toml = "0.9" toml_edit = "0.23" diff --git a/src-tauri/src/commands/amp_commands.rs b/src-tauri/src/commands/amp_commands.rs new file mode 100644 index 0000000..eb4e24b --- /dev/null +++ b/src-tauri/src/commands/amp_commands.rs @@ -0,0 +1,115 @@ +//! AMP Code 用户认证相关命令 +//! +//! 通过 AMP Code Access Token 调用 ampcode.com API 获取用户信息 + +use ::duckcoding::services::proxy_config_manager::ProxyConfigManager; + +/// AMP Code 用户信息响应 +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct AmpUserInfo { + pub id: String, + pub email: Option, + pub name: Option, + pub username: Option, +} + +/// 通过 AMP Code Access Token 获取用户信息 +/// +/// 调用 ampcode.com/api/user 验证 token 并获取用户信息 +#[tauri::command] +pub async fn get_amp_user_info(access_token: String) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + let response = client + .get("https://ampcode.com/api/user") + .header("Authorization", format!("Bearer {}", access_token)) + .header("X-Api-Key", &access_token) + .header("Content-Type", "application/json") + .send() + .await + .map_err(|e| format!("请求 AMP Code API 失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + return Err(format!("AMP Code API 返回错误 {}: {}", status, body)); + } + + let user_info: AmpUserInfo = response + .json() + .await + .map_err(|e| format!("解析用户信息失败: {}", e))?; + + tracing::info!( + user_id = %user_info.id, + username = ?user_info.username, + "成功获取 AMP Code 用户信息" + ); + + Ok(user_info) +} + +/// 验证 AMP Access Token 并保存到代理配置 +/// +/// 1. 调用 get_amp_user_info 验证 token +/// 2. 成功后保存 real_api_key 和 real_base_url 到 proxy.json +#[tauri::command] +pub async fn validate_and_save_amp_token(access_token: String) -> Result { + // 1. 验证 token + let user_info = get_amp_user_info(access_token.clone()).await?; + + // 2. 保存到 proxy.json + let proxy_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; + + let mut config = proxy_mgr + .get_config("amp-code") + .map_err(|e| e.to_string())? + .unwrap_or_else(|| { + use ::duckcoding::models::proxy_config::ToolProxyConfig; + ToolProxyConfig::new(8790) + }); + + config.real_api_key = Some(access_token); + config.real_base_url = Some("https://ampcode.com".to_string()); + + proxy_mgr + .update_config("amp-code", config) + .map_err(|e| e.to_string())?; + + tracing::info!( + user_id = %user_info.id, + "AMP Code Access Token 验证成功,已保存到代理配置" + ); + + Ok(user_info) +} + +/// 获取已保存的 AMP Code 用户信息(从 proxy.json 读取 token 并验证) +#[tauri::command] +pub async fn get_saved_amp_user_info() -> Result, String> { + let proxy_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; + + let config = proxy_mgr + .get_config("amp-code") + .map_err(|e| e.to_string())?; + + match config.and_then(|c| c.real_api_key) { + Some(token) => { + // 有保存的 token,尝试获取用户信息 + match get_amp_user_info(token).await { + Ok(info) => Ok(Some(info)), + Err(e) => { + tracing::warn!("已保存的 AMP Code Token 无效: {}", e); + Ok(None) + } + } + } + None => Ok(None), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c340bc1..b42eddf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod amp_commands; // AMP 用户认证命令 pub mod balance_commands; pub mod config_commands; pub mod dashboard_commands; // 仪表板状态管理命令 @@ -19,6 +20,7 @@ pub mod watcher_commands; pub mod window_commands; // 重新导出所有命令函数 +pub use amp_commands::*; // AMP 用户认证命令 pub use balance_commands::*; pub use config_commands::*; pub use dashboard_commands::*; // 仪表板状态管理命令 diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index 517f5fb..24d723c 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -1,7 +1,7 @@ //! Profile 管理 Tauri 命令(v2.1 - 简化版) use super::error::AppResult; -use ::duckcoding::services::profile_manager::ProfileDescriptor; +use ::duckcoding::services::profile_manager::{AmpProfileSelection, ProfileDescriptor, ProfileRef}; use serde::Deserialize; use std::sync::Arc; use tokio::sync::RwLock; @@ -193,3 +193,55 @@ pub async fn pm_capture_from_native( let manager = state.manager.write().await; Ok(manager.capture_from_native(&tool_id, &name)?) } + +// ==================== AMP Profile Selection ==================== + +/// AMP Profile 选择输入(前端传递) +#[derive(Debug, Deserialize)] +pub struct AmpSelectionInput { + pub claude: Option, + pub codex: Option, + pub gemini: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ProfileRefInput { + pub tool_id: String, + pub profile_name: String, +} + +/// 获取 AMP Profile 选择 +#[tauri::command] +pub async fn pm_get_amp_selection( + state: tauri::State<'_, ProfileManagerState>, +) -> AppResult { + let manager = state.manager.read().await; + Ok(manager.get_amp_selection()?) +} + +/// 保存 AMP Profile 选择 +#[tauri::command] +pub async fn pm_save_amp_selection( + state: tauri::State<'_, ProfileManagerState>, + input: AmpSelectionInput, +) -> AppResult<()> { + let manager = state.manager.write().await; + + let selection = AmpProfileSelection { + claude: input.claude.map(|r| ProfileRef { + tool_id: r.tool_id, + profile_name: r.profile_name, + }), + codex: input.codex.map(|r| ProfileRef { + tool_id: r.tool_id, + profile_name: r.profile_name, + }), + gemini: input.gemini.map(|r| ProfileRef { + tool_id: r.tool_id, + profile_name: r.profile_name, + }), + updated_at: chrono::Utc::now(), + }; + + Ok(manager.save_amp_selection(&selection)?) +} diff --git a/src-tauri/src/commands/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index 08915fc..790cc5f 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use tauri::State; use crate::commands::profile_commands::ProfileManagerState; +use ::duckcoding::services::amp_native_config; use ::duckcoding::services::proxy::ProxyManager; use ::duckcoding::services::proxy_config_manager::ProxyConfigManager; use ::duckcoding::utils::config::read_global_config; @@ -161,51 +162,100 @@ async fn try_start_proxy_internal( if tool_config.local_api_key.is_none() { return Err("透明代理保护密钥未设置".to_string()); } - if tool_config.real_api_key.is_none() || tool_config.real_base_url.is_none() { - return Err("真实 API Key 或 Base URL 未设置".to_string()); + + // amp-code 验证:检查是否至少配置了一个工具的 Profile + if tool_id == "amp-code" { + let (claude, codex, gemini) = profile_mgr + .resolve_amp_selection() + .map_err(|e| format!("读取 AMP Code Profile 选择失败: {}", e))?; + + if claude.is_none() && codex.is_none() && gemini.is_none() { + return Err( + "AMP Code 未配置任何 Profile,请先在 Profile 管理页面选择至少一个工具的配置" + .to_string(), + ); + } + } else { + // 其他工具需要 real_api_key/real_base_url + if tool_config.real_api_key.is_none() || tool_config.real_base_url.is_none() { + return Err("真实 API Key 或 Base URL 未设置".to_string()); + } } - // ========== Profile 切换逻辑 ========== + // ========== Profile 切换逻辑(amp-code 跳过,因为它动态路由到其他工具的 Profile) ========== - // 1. 读取当前激活的 Profile 名称 - let original_profile = profile_mgr - .get_active_profile_name(tool_id) - .map_err(|e| e.to_string())?; + if tool_id != "amp-code" { + // 1. 读取当前激活的 Profile 名称 + let original_profile = profile_mgr + .get_active_profile_name(tool_id) + .map_err(|e| e.to_string())?; - // 2. 保存到 ToolProxyConfig - tool_config.original_active_profile = original_profile.clone(); - proxy_config_mgr - .update_config(tool_id, tool_config.clone()) - .map_err(|e| e.to_string())?; + // 2. 保存到 ToolProxyConfig + tool_config.original_active_profile = original_profile.clone(); + proxy_config_mgr + .update_config(tool_id, tool_config.clone()) + .map_err(|e| e.to_string())?; - // 3. 验证内置 Profile 是否存在 - let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); + // 3. 验证内置 Profile 是否存在 + let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); - let profile_exists = match tool_id { - "claude-code" => profile_mgr.get_claude_profile(&proxy_profile_name).is_ok(), - "codex" => profile_mgr.get_codex_profile(&proxy_profile_name).is_ok(), - "gemini-cli" => profile_mgr.get_gemini_profile(&proxy_profile_name).is_ok(), - _ => false, - }; + let profile_exists = match tool_id { + "claude-code" => profile_mgr.get_claude_profile(&proxy_profile_name).is_ok(), + "codex" => profile_mgr.get_codex_profile(&proxy_profile_name).is_ok(), + "gemini-cli" => profile_mgr.get_gemini_profile(&proxy_profile_name).is_ok(), + _ => false, + }; - if !profile_exists { - return Err(format!( - "内置 Profile 不存在,请先保存代理配置: {}", - proxy_profile_name - )); - } + if !profile_exists { + return Err(format!( + "内置 Profile 不存在,请先保存代理配置: {}", + proxy_profile_name + )); + } + + // 4. 激活内置 Profile(这会自动同步到原生配置文件) + profile_mgr + .activate_profile(tool_id, &proxy_profile_name) + .map_err(|e| format!("激活内置 Profile 失败: {}", e))?; + + tracing::info!( + tool_id = %tool_id, + original_profile = ?original_profile, + proxy_profile = %proxy_profile_name, + "已切换到代理 Profile" + ); + } else { + // amp-code:直接修改 AMP Code 原生配置文件 + let proxy_url = format!("http://127.0.0.1:{}", tool_config.port); + let local_key = tool_config + .local_api_key + .as_ref() + .ok_or_else(|| "透明代理保护密钥未设置".to_string())?; + + // 1. 完整备份当前 AMP Code 配置 + let backup = amp_native_config::backup_amp_config() + .map_err(|e| format!("备份 AMP Code 配置失败: {}", e))?; - // 4. 激活内置 Profile(这会自动同步到原生配置文件) - profile_mgr - .activate_profile(tool_id, &proxy_profile_name) - .map_err(|e| format!("激活内置 Profile 失败: {}", e))?; + tool_config.original_amp_settings = backup.settings; + tool_config.original_amp_secrets = backup.secrets; - tracing::info!( - tool_id = %tool_id, - original_profile = ?original_profile, - proxy_profile = %proxy_profile_name, - "已切换到代理 Profile" - ); + // 2. 保存备份到 proxy.json + proxy_config_mgr + .update_config(tool_id, tool_config.clone()) + .map_err(|e| e.to_string())?; + + // 3. 应用代理配置 + amp_native_config::apply_proxy_config(&proxy_url, local_key) + .map_err(|e| format!("应用 AMP Code 代理配置失败: {}", e))?; + + tracing::info!( + tool_id = %tool_id, + proxy_url = %proxy_url, + has_original_settings = tool_config.original_amp_settings.is_some(), + has_original_secrets = tool_config.original_amp_secrets.is_some(), + "已应用 AMP Code 代理配置" + ); + } // ========== 启动代理 ========== @@ -297,8 +347,36 @@ pub async fn stop_tool_proxy( .await .map_err(|e| format!("停止代理失败: {e}"))?; - // ========== Profile 还原逻辑 ========== + // ========== 还原逻辑 ========== + + if tool_id == "amp-code" { + // amp-code:完整还原 AMP Code 原生配置文件 + let backup = amp_native_config::AmpConfigBackup { + settings: tool_config.original_amp_settings.take(), + secrets: tool_config.original_amp_secrets.take(), + }; + + amp_native_config::restore_amp_config(&backup) + .map_err(|e| format!("还原 AMP Code 配置失败: {}", e))?; + // 清空备份字段 + proxy_config_mgr + .update_config(&tool_id, tool_config) + .map_err(|e| e.to_string())?; + + tracing::info!( + tool_id = %tool_id, + had_settings = backup.settings.is_some(), + had_secrets = backup.secrets.is_some(), + "已完整还原 AMP Code 配置" + ); + + return Ok(format!( + "✅ {tool_id} 透明代理已停止\n已完整还原 AMP Code 配置" + )); + } + + // 其他工具:Profile 还原逻辑 let original_profile = tool_config.original_active_profile.take(); if let Some(profile_name) = original_profile { @@ -345,7 +423,7 @@ pub async fn get_all_proxy_status( let mut status_map = HashMap::new(); - for tool_id in &["claude-code", "codex", "gemini-cli"] { + for tool_id in &["claude-code", "codex", "gemini-cli", "amp-code"] { let port = proxy_store .get_config(tool_id) .map(|tc| tc.port) @@ -353,6 +431,7 @@ pub async fn get_all_proxy_status( "claude-code" => 8787, "codex" => 8788, "gemini-cli" => 8789, + "amp-code" => 8790, _ => 8790, }); @@ -506,6 +585,10 @@ pub async fn update_proxy_config( ) .map_err(|e| format!("同步内置 Profile 失败: {}", e))?; } + "amp-code" => { + // AMP 不需要创建内置 Profile,配置已保存到 proxy.json + tracing::debug!(tool_id = %tool_id, "AMP 代理配置已保存,跳过 Profile 同步"); + } _ => return Err(format!("不支持的工具: {}", tool_id)), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bdc96e4..f9dfdcc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -91,7 +91,7 @@ pub async fn auto_start_proxies(manager: &ProxyManager) { let mut started_count = 0; let mut failed_count = 0; - for tool_id in &["claude-code", "codex", "gemini-cli"] { + for tool_id in &["claude-code", "codex", "gemini-cli", "amp-code"] { let tool_config = match proxy_store.get_config(tool_id) { Some(cfg) => cfg.clone(), None => continue, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 53357cf..2116819 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -316,6 +316,10 @@ fn main() { get_proxy_config, update_proxy_config, get_all_proxy_configs, + // AMP 用户认证命令 + get_amp_user_info, + validate_and_save_amp_token, + get_saved_amp_user_info, // 会话管理命令 get_session_list, delete_session, @@ -370,6 +374,8 @@ fn main() { pm_get_active_profile_name, pm_get_active_profile, pm_capture_from_native, + pm_get_amp_selection, + pm_save_amp_selection, // 供应商管理命令(v1.5.0) list_providers, create_provider, @@ -392,6 +398,10 @@ fn main() { set_tool_instance_selection, get_selected_provider_id, set_selected_provider_id, + // AMP 用户认证命令 + get_amp_user_info, + validate_and_save_amp_token, + get_saved_amp_user_info, ]); // 使用自定义事件循环处理 macOS Reopen 事件 diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index c3cff10..96ea4f5 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -242,6 +242,23 @@ fn default_proxy_configs() -> HashMap { }, ); + configs.insert( + "amp-code".to_string(), + ToolProxyConfig { + enabled: false, + port: 8790, + local_api_key: None, + real_api_key: None, + real_base_url: None, + real_model_provider: None, + real_profile_name: None, + allow_public: false, + session_endpoint_config_enabled: false, + auto_start: false, + original_active_profile: None, + }, + ); + configs } diff --git a/src-tauri/src/models/proxy_config.rs b/src-tauri/src/models/proxy_config.rs index 802ce54..a7a7bfa 100644 --- a/src-tauri/src/models/proxy_config.rs +++ b/src-tauri/src/models/proxy_config.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; /// 单个工具的透明代理配置 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,6 +26,15 @@ pub struct ToolProxyConfig { /// 启动代理前激活的 Profile 名称(用于关闭时还原) #[serde(default, skip_serializing_if = "Option::is_none")] pub original_active_profile: Option, + /// AMP Code 原始 settings.json 完整内容(用于关闭时还原,语义备份) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_amp_settings: Option, + /// AMP Code 原始 secrets.json 完整内容(用于关闭时还原,语义备份) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_amp_secrets: Option, + /// Tavily API Key(用于本地搜索,可选,无则降级 DuckDuckGo) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tavily_api_key: Option, } impl ToolProxyConfig { @@ -41,6 +51,9 @@ impl ToolProxyConfig { session_endpoint_config_enabled: false, auto_start: false, original_active_profile: None, + original_amp_settings: None, + original_amp_secrets: None, + tavily_api_key: None, } } @@ -50,6 +63,7 @@ impl ToolProxyConfig { "claude-code" => 8787, "codex" => 8788, "gemini-cli" => 8789, + "amp-code" => 8790, _ => 8787, } } @@ -64,9 +78,15 @@ pub struct ProxyStore { pub codex: ToolProxyConfig, #[serde(rename = "gemini-cli")] pub gemini_cli: ToolProxyConfig, + #[serde(rename = "amp-code", default = "default_amp_config")] + pub amp_code: ToolProxyConfig, pub metadata: ProxyMetadata, } +fn default_amp_config() -> ToolProxyConfig { + ToolProxyConfig::new(8790) +} + impl ProxyStore { pub fn new() -> Self { Self { @@ -74,6 +94,7 @@ impl ProxyStore { claude_code: ToolProxyConfig::new(8787), codex: ToolProxyConfig::new(8788), gemini_cli: ToolProxyConfig::new(8789), + amp_code: ToolProxyConfig::new(8790), metadata: ProxyMetadata { last_updated: Utc::now(), }, @@ -86,6 +107,7 @@ impl ProxyStore { "claude-code" => Some(&self.claude_code), "codex" => Some(&self.codex), "gemini-cli" => Some(&self.gemini_cli), + "amp-code" => Some(&self.amp_code), _ => None, } } @@ -96,6 +118,7 @@ impl ProxyStore { "claude-code" => Some(&mut self.claude_code), "codex" => Some(&mut self.codex), "gemini-cli" => Some(&mut self.gemini_cli), + "amp-code" => Some(&mut self.amp_code), _ => None, } } @@ -106,6 +129,7 @@ impl ProxyStore { "claude-code" => self.claude_code = config, "codex" => self.codex = config, "gemini-cli" => self.gemini_cli = config, + "amp-code" => self.amp_code = config, _ => {} } self.metadata.last_updated = Utc::now(); diff --git a/src-tauri/src/services/amp_native_config.rs b/src-tauri/src/services/amp_native_config.rs new file mode 100644 index 0000000..3b7f102 --- /dev/null +++ b/src-tauri/src/services/amp_native_config.rs @@ -0,0 +1,163 @@ +//! AMP Code 原生配置文件管理 +//! +//! 配置文件位置: +//! - ~/.config/amp/settings.json - 存储 amp.url +//! - ~/.local/share/amp/secrets.json - 存储 apiKey@{url} +//! +//! Windows: %USERPROFILE%\.config\amp\... 和 %USERPROFILE%\.local\share\amp\... + +use crate::data::DataManager; +use anyhow::{anyhow, Result}; +use serde_json::Value; +use std::path::PathBuf; + +/// AMP Code 配置备份信息(语义备份) +#[derive(Debug, Clone)] +pub struct AmpConfigBackup { + pub settings: Option, + pub secrets: Option, +} + +/// 获取 home 目录(跨平台) +fn home_dir() -> Result { + if let Some(p) = dirs::home_dir() { + return Ok(p); + } + #[cfg(windows)] + if let Ok(p) = std::env::var("USERPROFILE") { + return Ok(PathBuf::from(p)); + } + #[cfg(not(windows))] + if let Ok(p) = std::env::var("HOME") { + return Ok(PathBuf::from(p)); + } + Err(anyhow!("无法获取 home 目录")) +} + +/// 获取 AMP Code settings.json 路径 +/// 所有平台: ~/.config/amp/settings.json +fn amp_settings_path() -> Result { + Ok(home_dir()? + .join(".config") + .join("amp") + .join("settings.json")) +} + +/// 获取 AMP Code secrets.json 路径 +/// 所有平台: ~/.local/share/amp/secrets.json +fn amp_secrets_path() -> Result { + Ok(home_dir()? + .join(".local") + .join("share") + .join("amp") + .join("secrets.json")) +} + +/// 读取当前 AMP Code 配置(语义备份) +/// 注意:文件存在但读取失败时会返回错误,避免还原时误删用户文件 +pub fn backup_amp_config() -> Result { + let dm = DataManager::global(); + let jm = dm.json_uncached(); + + let settings_path = amp_settings_path()?; + let secrets_path = amp_secrets_path()?; + + let settings = if settings_path.exists() { + Some( + jm.read(&settings_path) + .map_err(|e| anyhow!("读取 settings.json 失败: {}", e))?, + ) + } else { + None + }; + + let secrets = if secrets_path.exists() { + Some( + jm.read(&secrets_path) + .map_err(|e| anyhow!("读取 secrets.json 失败: {}", e))?, + ) + } else { + None + }; + + Ok(AmpConfigBackup { settings, secrets }) +} + +/// 应用代理配置到 Amp(设置本地代理地址和密钥) +/// 注意:如果配置文件存在但格式错误,会返回错误而非静默覆盖 +pub fn apply_proxy_config(proxy_url: &str, local_api_key: &str) -> Result<()> { + let dm = DataManager::global(); + let jm = dm.json_uncached(); + + let settings_path = amp_settings_path()?; + let secrets_path = amp_secrets_path()?; + + // 读取现有配置或创建新的(文件存在但格式错误时返回错误) + let mut settings: Value = if settings_path.exists() { + jm.read(&settings_path) + .map_err(|e| anyhow!("读取 settings.json 失败: {}", e))? + } else { + serde_json::json!({}) + }; + + // 更新 settings.json(使用 object insert 因为 "amp.url" 含 .) + let settings_obj = settings + .as_object_mut() + .ok_or_else(|| anyhow!("settings.json 格式错误:不是 JSON 对象"))?; + settings_obj.insert("amp.url".to_string(), Value::String(proxy_url.to_string())); + jm.write(&settings_path, &settings)?; + + // 读取 secrets.json(文件存在但格式错误时返回错误) + let mut secrets: Value = if secrets_path.exists() { + jm.read(&secrets_path) + .map_err(|e| anyhow!("读取 secrets.json 失败: {}", e))? + } else { + serde_json::json!({}) + }; + + // 更新 secrets.json(key 含 @ 和 url,使用 object insert) + let secrets_obj = secrets + .as_object_mut() + .ok_or_else(|| anyhow!("secrets.json 格式错误:不是 JSON 对象"))?; + let key_name = format!("apiKey@{}", proxy_url); + secrets_obj.insert(key_name, Value::String(local_api_key.to_string())); + jm.write(&secrets_path, &secrets)?; + + tracing::info!( + proxy_url = %proxy_url, + "已应用 AMP Code 代理配置" + ); + + Ok(()) +} + +/// 完整还原 AMP Code 配置到原始状态 +pub fn restore_amp_config(backup: &AmpConfigBackup) -> Result<()> { + let dm = DataManager::global(); + let jm = dm.json_uncached(); + + let settings_path = amp_settings_path()?; + let secrets_path = amp_secrets_path()?; + + // 还原 settings.json + if let Some(value) = &backup.settings { + jm.write(&settings_path, value)?; + tracing::debug!("已还原 settings.json"); + } else if settings_path.exists() { + jm.delete(&settings_path, None)?; + tracing::debug!("已删除 settings.json(原本不存在)"); + } + + // 还原 secrets.json + if let Some(value) = &backup.secrets { + jm.write(&secrets_path, value)?; + tracing::debug!("已还原 secrets.json"); + } else if secrets_path.exists() { + jm.delete(&secrets_path, None)?; + tracing::debug!("已删除 secrets.json(原本不存在)"); + } + + tracing::info!("已完整还原 AMP Code 配置"); + + Ok(()) +} diff --git a/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs b/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs index c164117..dcf4a92 100644 --- a/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs +++ b/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs @@ -282,5 +282,11 @@ fn parse_old_config(value: &Value) -> Result { .get("original_active_profile") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + original_amp_settings: obj.get("original_amp_settings").cloned(), + original_amp_secrets: obj.get("original_amp_secrets").cloned(), + tavily_api_key: obj + .get("tavily_api_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), }) } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 16bf614..ec3607e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,6 +11,7 @@ // - provider_manager: 供应商配置管理 // - new_api: NEW API 客户端服务 +pub mod amp_native_config; // AMP Code 原生配置管理 pub mod balance; pub mod config; pub mod dashboard_manager; // 仪表板状态管理 diff --git a/src-tauri/src/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index 9f1c376..d466cf9 100644 --- a/src-tauri/src/services/profile_manager/manager.rs +++ b/src-tauri/src/services/profile_manager/manager.rs @@ -697,3 +697,79 @@ impl Default for ProfileManager { Self::new().expect("创建 ProfileManager 失败") } } + +// ==================== AMP Profile Selection ==================== + +impl ProfileManager { + /// 获取 AMP Profile 选择的存储路径 + fn amp_selection_path(&self) -> PathBuf { + self.profiles_path + .parent() + .unwrap() + .join("amp_selection.json") + } + + /// 获取 AMP Profile 选择 + pub fn get_amp_selection(&self) -> Result { + let path = self.amp_selection_path(); + if !path.exists() { + return Ok(AmpProfileSelection::default()); + } + let value = self.data_manager.json().read(&path)?; + serde_json::from_value(value).context("反序列化 AmpProfileSelection 失败") + } + + /// 保存 AMP Profile 选择 + pub fn save_amp_selection(&self, selection: &AmpProfileSelection) -> Result<()> { + let path = self.amp_selection_path(); + + // 验证引用的 profile 存在 + let store = self.load_profiles_store()?; + + if let Some(ref claude) = selection.claude { + if !store.claude_code.contains_key(&claude.profile_name) { + return Err(anyhow!("Claude Profile 不存在: {}", claude.profile_name)); + } + } + + if let Some(ref codex) = selection.codex { + if !store.codex.contains_key(&codex.profile_name) { + return Err(anyhow!("Codex Profile 不存在: {}", codex.profile_name)); + } + } + + if let Some(ref gemini) = selection.gemini { + if !store.gemini_cli.contains_key(&gemini.profile_name) { + return Err(anyhow!("Gemini Profile 不存在: {}", gemini.profile_name)); + } + } + + let value = serde_json::to_value(selection)?; + self.data_manager.json().write(&path, &value)?; + Ok(()) + } + + /// 解析 AMP Profile 选择,返回实际的 Profile 数据 + pub fn resolve_amp_selection( + &self, + ) -> Result<( + Option, + Option, + Option, + )> { + let selection = self.get_amp_selection()?; + let store = self.load_profiles_store()?; + + let claude = selection + .claude + .and_then(|r| store.claude_code.get(&r.profile_name).cloned()); + let codex = selection + .codex + .and_then(|r| store.codex.get(&r.profile_name).cloned()); + let gemini = selection + .gemini + .and_then(|r| store.gemini_cli.get(&r.profile_name).cloned()); + + Ok((claude, codex, gemini)) + } +} diff --git a/src-tauri/src/services/profile_manager/mod.rs b/src-tauri/src/services/profile_manager/mod.rs index f465f91..718fc01 100644 --- a/src-tauri/src/services/profile_manager/mod.rs +++ b/src-tauri/src/services/profile_manager/mod.rs @@ -10,6 +10,7 @@ pub mod types; pub use manager::ProfileManager; pub use types::{ - ActiveMetadata, ActiveProfile, ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, - ProfileDescriptor, ProfileSource, ProfilesMetadata, ProfilesStore, TokenImportStatus, + ActiveMetadata, ActiveProfile, ActiveStore, AmpProfileSelection, ClaudeProfile, CodexProfile, + GeminiProfile, ProfileDescriptor, ProfileRef, ProfileSource, ProfilesMetadata, ProfilesStore, + TokenImportStatus, }; diff --git a/src-tauri/src/services/profile_manager/types.rs b/src-tauri/src/services/profile_manager/types.rs index 31389f7..7d326cb 100644 --- a/src-tauri/src/services/profile_manager/types.rs +++ b/src-tauri/src/services/profile_manager/types.rs @@ -6,6 +6,39 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +// ==================== AMP Profile Selection ==================== + +/// AMP Profile 引用(指向某工具的某个 profile) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileRef { + pub tool_id: String, + pub profile_name: String, +} + +/// AMP Profile 选择(引用其他工具的 profile) +/// AMP 不创建独立 profile,而是从 3 个工具中选择 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AmpProfileSelection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gemini: Option, + pub updated_at: DateTime, +} + +impl Default for AmpProfileSelection { + fn default() -> Self { + Self { + claude: None, + codex: None, + gemini: None, + updated_at: Utc::now(), + } + } +} + // ==================== Profile 来源标记 ==================== /// Profile 来源类型 diff --git a/src-tauri/src/services/proxy/headers/amp_processor.rs b/src-tauri/src/services/proxy/headers/amp_processor.rs new file mode 100644 index 0000000..13061df --- /dev/null +++ b/src-tauri/src/services/proxy/headers/amp_processor.rs @@ -0,0 +1,833 @@ +// AMP Code 请求处理器 +// +// 路由逻辑: +// 0. 本地工具拦截:webSearch2 / extractWebPageContent → 本地处理 +// 1. /api/provider/anthropic/* → Claude Profile(提取 /v1/messages) +// 2. /api/provider/openai/* → Codex Profile(提取 /v1/responses 或 /v1/chat/completions) +// 3. /api/provider/google/* → Gemini Profile(提取 /v1beta/...) +// 4. 其他 /api/* → ampcode.com(使用 AMP Access Token) +// 5. 直接 LLM 路径 → 按路径/headers/model 判断 + +use super::{ + ClaudeHeadersProcessor, CodexHeadersProcessor, GeminiHeadersProcessor, ProcessedRequest, + RequestProcessor, +}; +use crate::services::profile_manager::ProfileManager; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::StreamExt; +use hyper::HeaderMap as HyperHeaderMap; +use once_cell::sync::Lazy; +use reqwest::redirect::Policy; +use serde_json::{json, Value}; +use std::net::IpAddr; +use url::Url; + +/// 全局 HTTP Client(复用连接池,禁止重定向,允许系统代理) +static HTTP_CLIENT: Lazy = Lazy::new(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .connect_timeout(std::time::Duration::from_secs(10)) + .redirect(Policy::none()) // 禁止重定向,防止 SSRF 绕过 + .build() + .expect("Failed to create HTTP client") +}); + +/// 最大响应体大小(5MB) +const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; + +#[derive(Debug)] +pub struct AmpHeadersProcessor; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ApiType { + AmpInternal, + Claude, + Codex, + Gemini, +} + +impl AmpHeadersProcessor { + fn detect_api_type(path: &str, headers: &HyperHeaderMap, body: &[u8]) -> ApiType { + let path_lower = path.to_lowercase(); + + // 1. /api/provider/{provider}/* → LLM 端点 + if path_lower.starts_with("/api/provider/anthropic") { + return ApiType::Claude; + } + if path_lower.starts_with("/api/provider/openai") { + return ApiType::Codex; + } + if path_lower.starts_with("/api/provider/google") { + return ApiType::Gemini; + } + + // 2. 其他 /api/* → ampcode.com + if path_lower.starts_with("/api/") { + return ApiType::AmpInternal; + } + + // 3. 直接 LLM 路径 + if path_lower.contains("/messages") && !path_lower.contains("/chat/completions") { + return ApiType::Claude; + } + if path_lower.contains("/chat/completions") + || path_lower.contains("/responses") + || path_lower.ends_with("/completions") + { + return ApiType::Codex; + } + if path_lower.contains("/v1beta") + || path_lower.contains(":generatecontent") + || path_lower.contains(":streamgeneratecontent") + { + return ApiType::Gemini; + } + + // 4. 按 headers + if headers.contains_key("anthropic-version") { + return ApiType::Claude; + } + + // 5. 按 body.model + if let Some(api_type) = Self::detect_by_model(body) { + return api_type; + } + + ApiType::Claude + } + + fn detect_by_model(body: &[u8]) -> Option { + if body.is_empty() { + return None; + } + let json: serde_json::Value = serde_json::from_slice(body).ok()?; + let model = json.get("model")?.as_str()?.to_lowercase(); + + if model.contains("gemini") { + Some(ApiType::Gemini) + } else if model.contains("claude") { + Some(ApiType::Claude) + } else if model.contains("gpt") { + Some(ApiType::Codex) + } else { + None + } + } + + fn extract_model_name(path: &str, body: &[u8]) -> String { + // 1. 从路径提取:/v1beta/models/{model}:xxx + if let Some(start) = path.find("/models/") { + let after = &path[start + 8..]; + if let Some(end) = after.find(':') { + return after[..end].to_string(); + } + if let Some(end) = after.find('/') { + return after[..end].to_string(); + } + return after.to_string(); + } + + // 2. 从请求体提取 + if !body.is_empty() { + if let Ok(json) = serde_json::from_slice::(body) { + if let Some(model) = json.get("model").and_then(|m| m.as_str()) { + return model.to_string(); + } + } + } + + "gemini-2.0-flash".to_string() + } + + /// 提取 LLM API 路径:/api/provider/xxx/v1/... → /v1/... + /// Gemini 特殊处理:/v1beta1/publishers/google/models/xxx → /v1beta/models/xxx + fn extract_llm_path(path: &str) -> String { + // Gemini 路径转换:v1beta1/publishers/google/models/xxx → v1beta/models/xxx + if let Some(pos) = path.find("/v1beta1/publishers/google/models/") { + let model_part = &path[pos + "/v1beta1/publishers/google/models/".len()..]; + return format!("/v1beta/models/{}", model_part); + } + + // 标准路径提取 + if let Some(pos) = path.find("/v1beta") { + return path[pos..].to_string(); + } + if let Some(pos) = path.find("/v1") { + return path[pos..].to_string(); + } + + path.to_string() + } + + fn get_user_agent(api_type: ApiType, path: &str, body: &[u8]) -> String { + match api_type { + ApiType::Claude => "claude-cli/2.0.72 (external, cli)".to_string(), + ApiType::Codex => { + "codex_cli_rs/0.77.0 (Mac OS 15.7.2; arm64) Apple_Terminal/455.1".to_string() + } + ApiType::Gemini => { + let model = Self::extract_model_name(path, body); + format!("GeminiCLI/0.22.5/{} (darwin; arm64)", model) + } + ApiType::AmpInternal => unreachable!(), + } + } + + fn add_tool_prefix(body: &[u8]) -> Vec { + const TOOL_PREFIX: &str = "mcp_"; + + if body.is_empty() { + return body.to_vec(); + } + + let Ok(mut json) = serde_json::from_slice::(body) else { + return body.to_vec(); + }; + + if let Some(model) = json.get("model").and_then(|m| m.as_str()) { + if model.to_lowercase().contains("haiku") { + return body.to_vec(); + } + } + + if let Some(tools) = json.get_mut("tools").and_then(|t| t.as_array_mut()) { + for tool in tools.iter_mut() { + if let Some(name) = tool.get("name").and_then(|n| n.as_str()) { + if !name.starts_with(TOOL_PREFIX) { + tool["name"] = + serde_json::Value::String(format!("{}{}", TOOL_PREFIX, name)); + } + } + } + } + + serde_json::to_vec(&json).unwrap_or_else(|_| body.to_vec()) + } + + async fn forward_to_amp( + path: &str, + query: Option<&str>, + headers: &HyperHeaderMap, + body: &[u8], + ) -> Result { + let proxy_mgr = crate::services::proxy_config_manager::ProxyConfigManager::new() + .map_err(|e| anyhow!("ProxyConfigManager 初始化失败: {}", e))?; + + let config = proxy_mgr + .get_config("amp-code") + .map_err(|e| anyhow!("读取配置失败: {}", e))? + .ok_or_else(|| anyhow!("AMP Code 代理未配置"))?; + + let token = config + .real_api_key + .ok_or_else(|| anyhow!("AMP Code Access Token 未配置"))?; + + let base_url = config + .real_base_url + .unwrap_or_else(|| "https://ampcode.com".to_string()); + + let target_url = match query { + Some(q) => format!("{}{}?{}", base_url, path, q), + None => format!("{}{}", base_url, path), + }; + + tracing::info!("AMP Code → ampcode.com: {}", target_url); + + let mut new_headers = headers.clone(); + new_headers.remove(hyper::header::AUTHORIZATION); + let x_api_key = hyper::header::HeaderName::from_static("x-api-key"); + new_headers.remove(&x_api_key); + new_headers.insert( + hyper::header::AUTHORIZATION, + format!("Bearer {}", token).parse().unwrap(), + ); + new_headers.insert(x_api_key, token.parse().unwrap()); + + Ok(ProcessedRequest { + target_url, + headers: new_headers, + body: body.to_vec().into(), + }) + } + + /// 检测是否为本地工具请求(精确匹配,避免误判) + fn detect_local_tool(query: Option<&str>) -> Option<&'static str> { + let q = query?; + // 精确匹配:query string 必须等于工具名或以 & 分隔 + // 支持格式:?webSearch2 或 ?webSearch2&xxx 或 ?xxx&webSearch2 + let parts: Vec<&str> = q.split('&').collect(); + for part in parts { + let key = part.split('=').next().unwrap_or(part); + match key { + "webSearch2" => return Some("webSearch2"), + "extractWebPageContent" => return Some("extractWebPageContent"), + _ => continue, + } + } + None + } + + /// 处理本地工具请求 + async fn handle_local_tool( + tool_name: &str, + body: &[u8], + tavily_api_key: Option<&str>, + ) -> Result { + match tool_name { + "webSearch2" => Self::handle_web_search(body, tavily_api_key).await, + "extractWebPageContent" => Self::handle_extract_web_page(body).await, + _ => Err(anyhow!("未知的本地工具: {}", tool_name)), + } + } + + /// 处理网页搜索请求 + async fn handle_web_search( + body: &[u8], + tavily_api_key: Option<&str>, + ) -> Result { + // 解析请求 JSON(不吞掉错误) + let req_json: Value = + serde_json::from_slice(body).map_err(|e| anyhow!("请求 JSON 解析失败: {}", e))?; + let params = &req_json["params"]; + + let objective = params["objective"].as_str().unwrap_or(""); + let search_queries: Vec<&str> = params["searchQueries"] + .as_array() + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + let max_results = params["maxResults"].as_i64().unwrap_or(5) as usize; + + // 构建查询列表 + let queries: Vec<&str> = if search_queries.is_empty() && !objective.is_empty() { + vec![objective] + } else { + search_queries + }; + + tracing::info!( + "本地搜索: queries={:?}, max_results={}", + queries, + max_results + ); + + // 尝试 Tavily,无 Key 则降级 DuckDuckGo + let (results, provider) = if let Some(api_key) = tavily_api_key { + tracing::info!("使用 Tavily 搜索服务"); + match Self::search_tavily(&queries, max_results, api_key).await { + Ok(r) => (r, "tavily"), + Err(e) => { + tracing::warn!("Tavily 搜索失败,降级 DuckDuckGo: {}", e); + ( + Self::search_duckduckgo(&queries, max_results).await?, + "local-duckduckgo", + ) + } + } + } else { + tracing::info!("使用 DuckDuckGo 本地搜索(未配置 Tavily API Key)"); + ( + Self::search_duckduckgo(&queries, max_results).await?, + "local-duckduckgo", + ) + }; + + let response = json!({ + "ok": true, + "result": { + "results": results, + "provider": provider, + "showParallelAttribution": false + }, + "creditsConsumed": "0" + }); + + tracing::info!("本地搜索完成: {} 条结果", results.len()); + Self::build_local_response("webSearch2", response) + } + + /// Tavily 搜索(使用全局 Client) + async fn search_tavily( + queries: &[&str], + max_results: usize, + api_key: &str, + ) -> Result> { + let mut all_results = Vec::new(); + let mut seen_urls = std::collections::HashSet::new(); + + for query in queries { + if all_results.len() >= max_results { + break; + } + + let request_body = json!({ + "api_key": api_key, + "query": query, + "search_depth": "basic", + "max_results": max_results.min(10), + "include_answer": false + }); + + let resp = HTTP_CLIENT + .post("https://api.tavily.com/search") + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Tavily API 错误: {} - {}", status, text)); + } + + let data: Value = resp.json().await?; + if let Some(results) = data["results"].as_array() { + for r in results { + let url = r["url"].as_str().unwrap_or(""); + if seen_urls.contains(url) { + continue; + } + seen_urls.insert(url.to_string()); + + all_results.push(json!({ + "title": r["title"].as_str().unwrap_or(""), + "url": url, + "excerpts": [r["content"].as_str().unwrap_or("")] + })); + + if all_results.len() >= max_results { + break; + } + } + } + } + + Ok(all_results) + } + + /// DuckDuckGo HTML 搜索(降级方案,使用全局 Client) + async fn search_duckduckgo(queries: &[&str], max_results: usize) -> Result> { + let mut all_results = Vec::new(); + let mut seen_urls = std::collections::HashSet::new(); + + for query in queries { + if all_results.len() >= max_results { + break; + } + + let url = format!( + "https://html.duckduckgo.com/html/?q={}", + urlencoding::encode(query) + ); + + let resp = HTTP_CLIENT + .get(&url) + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36") + .header("Accept", "text/html") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + .send() + .await?; + + let html = resp.text().await?; + let parsed = Self::parse_duckduckgo_html(&html); + + for r in parsed { + if seen_urls.contains(&r.url) { + continue; + } + seen_urls.insert(r.url.clone()); + + all_results.push(json!({ + "title": r.title, + "url": r.url, + "excerpts": if r.snippet.is_empty() { vec![] } else { vec![r.snippet] } + })); + + if all_results.len() >= max_results { + break; + } + } + } + + Ok(all_results) + } + + /// 解析 DuckDuckGo HTML 结果 + fn parse_duckduckgo_html(html: &str) -> Vec { + let mut results = Vec::new(); + + // 简单解析:查找 class="result__a" 的链接 + for part in html.split("class=\"result__a\"").skip(1) { + // 提取 URL + let url = if let Some(start) = part.find("href=\"") { + let after = &part[start + 6..]; + if let Some(end) = after.find('"') { + Self::extract_ddg_actual_url(&after[..end]) + } else { + continue; + } + } else { + continue; + }; + + if url.is_empty() { + continue; + } + + // 提取标题 + let title = if let Some(start) = part.find('>') { + let after = &part[start + 1..]; + if let Some(end) = after.find("") { + Self::clean_html(&after[..end]) + } else { + String::new() + } + } else { + String::new() + }; + + // 提取摘要 + let snippet = if let Some(snip_start) = part.find("result__snippet") { + let snip_part = &part[snip_start..]; + if let Some(start) = snip_part.find('>') { + let after = &snip_part[start + 1..]; + if let Some(end) = after.find("") { + Self::clean_html(&after[..end]) + } else { + String::new() + } + } else { + String::new() + } + } else { + String::new() + }; + + results.push(DuckDuckGoResult { + title, + url, + snippet, + }); + } + + results + } + + /// 从 DuckDuckGo 重定向 URL 提取实际 URL + fn extract_ddg_actual_url(ddg_url: &str) -> String { + if ddg_url.contains("uddg=") { + if let Some(pos) = ddg_url.find("uddg=") { + let encoded = &ddg_url[pos + 5..]; + let end = encoded.find('&').unwrap_or(encoded.len()); + if let Ok(decoded) = urlencoding::decode(&encoded[..end]) { + return decoded.into_owned(); + } + } + } + if ddg_url.starts_with("http") { + ddg_url.to_string() + } else { + String::new() + } + } + + /// 清理 HTML 标签和实体 + fn clean_html(s: &str) -> String { + let mut result = s.to_string(); + // 移除 HTML 标签 + while let Some(start) = result.find('<') { + if let Some(end) = result[start..].find('>') { + result = format!("{}{}", &result[..start], &result[start + end + 1..]); + } else { + break; + } + } + // 解码常见 HTML 实体 + result = result + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(" ", " "); + result.trim().to_string() + } + + /// 处理网页内容提取请求(增强 SSRF 防护 + 流式读取) + async fn handle_extract_web_page(body: &[u8]) -> Result { + // 解析请求 JSON(不吞掉错误) + let req_json: Value = + serde_json::from_slice(body).map_err(|e| anyhow!("请求 JSON 解析失败: {}", e))?; + let target_url = req_json["params"]["url"] + .as_str() + .ok_or_else(|| anyhow!("缺少 URL 参数"))?; + + // SSRF 防护:使用 URL 解析进行精确校验 + Self::validate_url_security(target_url)?; + + tracing::info!("本地网页提取: {}", target_url); + + let resp = HTTP_CLIENT + .get(target_url) + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36") + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + .send() + .await?; + + if !resp.status().is_success() { + return Err(anyhow!("HTTP {}", resp.status())); + } + + // 流式读取并限制大小(防止 chunked 编码绕过 Content-Length 检查) + let html = Self::read_response_with_limit(resp, MAX_RESPONSE_SIZE).await?; + + // 返回原始 HTML(与 AMP-Manager 行为一致) + let response = json!({ + "ok": true, + "result": { + "fullContent": html, + "excerpts": [], + "provider": "local" + } + }); + + tracing::info!("本地网页提取完成: {} bytes", html.len()); + Self::build_local_response("extractWebPageContent", response) + } + + /// URL 安全校验(SSRF 防护) + fn validate_url_security(url_str: &str) -> Result<()> { + // 解析 URL + let url = Url::parse(url_str).map_err(|e| anyhow!("URL 解析失败: {}", e))?; + + // 只允许 http/https + match url.scheme() { + "http" | "https" => {} + scheme => return Err(anyhow!("不支持的协议: {}", scheme)), + } + + // 禁止 userinfo(防止 http://good.com@evil.com 绕过) + if url.username() != "" || url.password().is_some() { + return Err(anyhow!("URL 不允许包含用户名/密码")); + } + + // 检查 host + let host = url.host_str().ok_or_else(|| anyhow!("URL 缺少主机名"))?; + + // 检查是否为 IP 地址 + if let Ok(ip) = host.parse::() { + if Self::is_private_ip(&ip) { + return Err(anyhow!("禁止访问内网地址")); + } + } else { + // 域名检查:禁止常见内网域名 + let host_lower = host.to_lowercase(); + if host_lower == "localhost" + || host_lower.ends_with(".local") + || host_lower.ends_with(".internal") + || host_lower.ends_with(".localhost") + { + return Err(anyhow!("禁止访问内网域名")); + } + } + + Ok(()) + } + + /// 检查是否为私有/保留 IP 地址 + fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => { + ipv4.is_loopback() // 127.0.0.0/8 + || ipv4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + || ipv4.is_link_local() // 169.254.0.0/16 + || ipv4.is_broadcast() // 255.255.255.255 + || ipv4.is_unspecified() // 0.0.0.0 + || ipv4.is_multicast() // 224.0.0.0/4 + || ipv4.octets()[0] == 100 && (ipv4.octets()[1] & 0xc0) == 64 + // 100.64.0.0/10 (CGN) + } + IpAddr::V6(ipv6) => { + ipv6.is_loopback() // ::1 + || ipv6.is_unspecified() // :: + || ipv6.is_multicast() + // IPv6 私有地址范围 + || (ipv6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 (ULA) + || (ipv6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 (link-local) + } + } + } + + /// 流式读取响应并限制大小 + async fn read_response_with_limit(resp: reqwest::Response, max_size: usize) -> Result { + let mut stream = resp.bytes_stream(); + let mut data = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| anyhow!("读取响应失败: {}", e))?; + if data.len() + chunk.len() > max_size { + return Err(anyhow!("响应体过大,超过 {} bytes 限制", max_size)); + } + data.extend_from_slice(&chunk); + } + + String::from_utf8(data).map_err(|e| anyhow!("响应不是有效的 UTF-8: {}", e)) + } + + /// 构建本地处理响应 + fn build_local_response(tool_name: &str, response: Value) -> Result { + let body_bytes = serde_json::to_vec(&response)?; + let mut headers = HyperHeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + + Ok(ProcessedRequest { + target_url: format!("dc-local://{}", tool_name), + headers, + body: Bytes::from(body_bytes), + }) + } +} + +/// DuckDuckGo 搜索结果 +struct DuckDuckGoResult { + title: String, + url: String, + snippet: String, +} + +#[async_trait] +impl RequestProcessor for AmpHeadersProcessor { + fn tool_id(&self) -> &str { + "amp-code" + } + + async fn process_outgoing_request( + &self, + _base_url: &str, + _api_key: &str, + path: &str, + query: Option<&str>, + original_headers: &HyperHeaderMap, + body: &[u8], + ) -> Result { + // 0. 本地工具拦截:webSearch2 / extractWebPageContent + if let Some(tool_name) = Self::detect_local_tool(query) { + tracing::info!("AMP Code 本地工具: {}", tool_name); + + // 获取 Tavily API Key(如果配置了) + let tavily_api_key = crate::services::proxy_config_manager::ProxyConfigManager::new() + .ok() + .and_then(|mgr| mgr.get_config("amp-code").ok().flatten()) + .and_then(|cfg| cfg.tavily_api_key); + + return Self::handle_local_tool(tool_name, body, tavily_api_key.as_deref()).await; + } + + let api_type = Self::detect_api_type(path, original_headers, body); + tracing::debug!("AMP Code 路由: path={}, type={:?}", path, api_type); + + if api_type == ApiType::AmpInternal { + return Self::forward_to_amp(path, query, original_headers, body).await; + } + + // LLM 请求 → 用户配置的 Profile + let profile_mgr = + ProfileManager::new().map_err(|e| anyhow!("ProfileManager 初始化失败: {}", e))?; + + let (claude, codex, gemini) = profile_mgr + .resolve_amp_selection() + .map_err(|e| anyhow!("Profile 解析失败: {}", e))?; + + let llm_path = Self::extract_llm_path(path); + + match api_type { + ApiType::Claude => { + let p = claude.ok_or_else(|| anyhow!("未配置 Claude Profile"))?; + tracing::info!("AMP Code → Claude: {}{}", p.base_url, llm_path); + let prefixed_body = Self::add_tool_prefix(body); + + let mut result = ClaudeHeadersProcessor + .process_outgoing_request( + &p.base_url, + &p.api_key, + &llm_path, + query, + original_headers, + &prefixed_body, + ) + .await?; + + let amp_headers: Vec<_> = result + .headers + .keys() + .filter(|k| k.as_str().starts_with("x-amp-")) + .cloned() + .collect(); + for key in amp_headers { + result.headers.remove(&key); + } + + result.headers.remove("content-length"); + result.headers.remove("transfer-encoding"); + + result.headers.insert( + "user-agent", + Self::get_user_agent(api_type, path, body).parse().unwrap(), + ); + result.headers.insert("x-app", "cli".parse().unwrap()); + + if !result.target_url.contains("beta=true") { + if result.target_url.contains('?') { + result.target_url.push_str("&beta=true"); + } else { + result.target_url.push_str("?beta=true"); + } + } + + Ok(result) + } + ApiType::Codex => { + let p = codex.ok_or_else(|| anyhow!("未配置 Codex Profile"))?; + tracing::info!("AMP Code → Codex: {}{}", p.base_url, llm_path); + let mut result = CodexHeadersProcessor + .process_outgoing_request( + &p.base_url, + &p.api_key, + &llm_path, + query, + original_headers, + body, + ) + .await?; + result.headers.insert( + "user-agent", + Self::get_user_agent(api_type, path, body).parse().unwrap(), + ); + Ok(result) + } + ApiType::Gemini => { + let p = gemini.ok_or_else(|| anyhow!("未配置 Gemini Profile"))?; + tracing::info!("AMP Code → Gemini: {}{}", p.base_url, llm_path); + let mut result = GeminiHeadersProcessor + .process_outgoing_request( + &p.base_url, + &p.api_key, + &llm_path, + query, + original_headers, + body, + ) + .await?; + result.headers.insert( + "user-agent", + Self::get_user_agent(api_type, path, body).parse().unwrap(), + ); + Ok(result) + } + ApiType::AmpInternal => unreachable!(), + } + } +} diff --git a/src-tauri/src/services/proxy/headers/mod.rs b/src-tauri/src/services/proxy/headers/mod.rs index f984e2c..1326002 100644 --- a/src-tauri/src/services/proxy/headers/mod.rs +++ b/src-tauri/src/services/proxy/headers/mod.rs @@ -6,10 +6,12 @@ use bytes::Bytes; use hyper::HeaderMap as HyperHeaderMap; use reqwest::header::HeaderMap as ReqwestHeaderMap; +mod amp_processor; mod claude_processor; mod codex_processor; mod gemini_processor; +pub use amp_processor::AmpHeadersProcessor; pub use claude_processor::ClaudeHeadersProcessor; pub use codex_processor::CodexHeadersProcessor; pub use gemini_processor::GeminiHeadersProcessor; @@ -101,6 +103,7 @@ pub trait RequestProcessor: Send + Sync + std::fmt::Debug { /// - `Err`: 当 tool_id 不被支持时返回错误 pub fn create_request_processor(tool_id: &str) -> Result> { match tool_id { + "amp-code" => Ok(Box::new(AmpHeadersProcessor)), "claude-code" => Ok(Box::new(ClaudeHeadersProcessor)), "codex" => Ok(Box::new(CodexHeadersProcessor)), "gemini-cli" => Ok(Box::new(GeminiHeadersProcessor)), diff --git a/src-tauri/src/services/proxy/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index 8c82aeb..84b6de2 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -18,6 +18,7 @@ use std::net::SocketAddr; use std::sync::Arc; use tokio::net::TcpListener; use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; use super::headers::RequestProcessor; use super::utils::body::{box_body, BoxBody}; @@ -30,6 +31,7 @@ pub struct ProxyInstance { config: Arc>, processor: Arc, server_handle: Arc>>>, + cancel_token: CancellationToken, } impl ProxyInstance { @@ -44,6 +46,7 @@ impl ProxyInstance { config: Arc::new(RwLock::new(config)), processor: Arc::from(processor), server_handle: Arc::new(RwLock::new(None)), + cancel_token: CancellationToken::new(), } } @@ -89,45 +92,66 @@ impl ProxyInstance { let processor_clone = Arc::clone(&self.processor); let port = config.port; let tool_id = self.tool_id.clone(); + let cancel_token = self.cancel_token.clone(); // 启动服务器 let handle = tokio::spawn(async move { loop { - match listener.accept().await { - Ok((stream, _addr)) => { - let config = Arc::clone(&config_clone); - let processor = Arc::clone(&processor_clone); - let tool_id_inner = tool_id.clone(); - let tool_id_for_error = tool_id.clone(); - - tokio::spawn(async move { - let io = TokioIo::new(stream); - let service = service_fn(move |req| { - let config = Arc::clone(&config); - let processor = Arc::clone(&processor); - let tool_id = tool_id_inner.clone(); - async move { - handle_request(req, config, processor, port, &tool_id).await - } - }); - - if let Err(err) = - http1::Builder::new().serve_connection(io, service).await - { + tokio::select! { + _ = cancel_token.cancelled() => { + tracing::debug!(tool_id = %tool_id, "代理服务器收到取消信号"); + break; + } + result = listener.accept() => { + match result { + Ok((stream, _addr)) => { + let config = Arc::clone(&config_clone); + let processor = Arc::clone(&processor_clone); + let tool_id_inner = tool_id.clone(); + let tool_id_for_error = tool_id.clone(); + let conn_cancel = cancel_token.clone(); + + tokio::spawn(async move { + let io = TokioIo::new(stream); + let service = service_fn(move |req| { + let config = Arc::clone(&config); + let processor = Arc::clone(&processor); + let tool_id = tool_id_inner.clone(); + async move { + handle_request(req, config, processor, port, &tool_id).await + } + }); + + let conn = http1::Builder::new().serve_connection(io, service); + tokio::pin!(conn); + + // 使用 select 在连接完成或取消时退出 + tokio::select! { + _ = conn_cancel.cancelled() => { + tracing::debug!(tool_id = %tool_id_for_error, "连接被取消"); + } + result = &mut conn => { + if let Err(err) = result { + if !err.is_incomplete_message() { + tracing::error!( + tool_id = %tool_id_for_error, + error = ?err, + "处理连接失败" + ); + } + } + } + } + }); + } + Err(e) => { tracing::error!( - tool_id = %tool_id_for_error, - error = ?err, - "处理连接失败" + tool_id = %tool_id, + error = ?e, + "接受连接失败" ); } - }); - } - Err(e) => { - tracing::error!( - tool_id = %tool_id, - error = ?e, - "接受连接失败" - ); + } } } } @@ -144,14 +168,25 @@ impl ProxyInstance { /// 停止代理服务 pub async fn stop(&self) -> Result<()> { + // 1. 发送取消信号给所有连接 + self.cancel_token.cancel(); + + // 2. 等待服务器任务结束 let handle = { let mut h = self.server_handle.write().await; h.take() }; if let Some(handle) = handle { - handle.abort(); - tracing::info!(tool_id = %self.tool_id, "透明代理已停止"); + // 等待任务结束(带超时) + match tokio::time::timeout(std::time::Duration::from_secs(2), handle).await { + Ok(_) => { + tracing::info!(tool_id = %self.tool_id, "透明代理已停止"); + } + Err(_) => { + tracing::warn!(tool_id = %self.tool_id, "透明代理停止超时,强制终止"); + } + } } Ok(()) @@ -244,11 +279,12 @@ async fn handle_request_inner( let method = req.method().clone(); let headers = req.headers().clone(); + // amp-code 在 processor 内部获取配置,这里传占位符 let base = proxy_config .real_base_url - .as_ref() - .unwrap() - .trim_end_matches('/'); + .as_deref() + .map(|s| s.trim_end_matches('/')) + .unwrap_or(""); // 读取请求体(消费 req) let body_bytes = if method != Method::GET && method != Method::HEAD { @@ -258,10 +294,11 @@ async fn handle_request_inner( }; // 使用 RequestProcessor 统一处理请求(URL + headers + body) + // amp-code 忽略传入的 base/api_key,在内部通过 amp_selection 获取 let processed = processor .process_outgoing_request( base, - proxy_config.real_api_key.as_ref().unwrap(), + proxy_config.real_api_key.as_deref().unwrap_or(""), &path, query.as_deref(), &headers, @@ -270,6 +307,26 @@ async fn handle_request_inner( .await .context("处理出站请求失败")?; + // 本地工具处理:dc-local:// 协议标记的请求直接返回 body + if processed.target_url.starts_with("dc-local://") { + tracing::info!( + tool_id = %tool_id, + local_tool = %processed.target_url, + "本地工具响应" + ); + let mut response = Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json"); + + for (name, value) in processed.headers.iter() { + response = response.header(name.as_str(), value.as_bytes()); + } + + return Ok(response + .body(box_body(http_body_util::Full::new(processed.body))) + .unwrap()); + } + // 回环检测 if loop_detector::is_proxy_loop(&processed.target_url, own_port) { return Ok(error_responses::proxy_loop_detected(tool_id)); @@ -321,11 +378,29 @@ async fn handle_request_inner( if is_sse { tracing::debug!(tool_id = %tool_id, "SSE 流式响应"); use futures_util::StreamExt; + use regex::Regex; let stream = upstream_res.bytes_stream(); - let mapped_stream = stream.map(|result| { + + // amp-code 需要移除工具名前缀 + let is_amp_code = tool_id == "amp-code"; + let prefix_regex = Regex::new(r#""name"\s*:\s*"mcp_([^"]+)""#).ok(); + + let mapped_stream = stream.map(move |result| { result - .map(Frame::data) + .map(|bytes| { + if is_amp_code { + if let Some(ref re) = prefix_regex { + let text = String::from_utf8_lossy(&bytes); + let cleaned = re.replace_all(&text, r#""name": "$1""#); + Frame::data(Bytes::from(cleaned.into_owned())) + } else { + Frame::data(bytes) + } + } else { + Frame::data(bytes) + } + }) .map_err(|e| Box::new(e) as Box) }); @@ -334,8 +409,18 @@ async fn handle_request_inner( } else { // 普通响应 let body_bytes = upstream_res.bytes().await.context("读取响应体失败")?; + + let final_body = if tool_id == "amp-code" { + let text = String::from_utf8_lossy(&body_bytes); + let re = regex::Regex::new(r#""name"\s*:\s*"mcp_([^"]+)""#).unwrap(); + let cleaned = re.replace_all(&text, r#""name": "$1""#); + Bytes::from(cleaned.into_owned()) + } else { + body_bytes + }; + Ok(response - .body(box_body(http_body_util::Full::new(body_bytes))) + .body(box_body(http_body_util::Full::new(final_body))) .unwrap()) } } diff --git a/src/assets/amp-logo.svg b/src/assets/amp-logo.svg new file mode 100644 index 0000000..3820c27 --- /dev/null +++ b/src/assets/amp-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/lib/tauri-commands/amp.ts b/src/lib/tauri-commands/amp.ts new file mode 100644 index 0000000..3cf3a77 --- /dev/null +++ b/src/lib/tauri-commands/amp.ts @@ -0,0 +1,31 @@ +// AMP Code 用户认证命令模块 +// 负责 AMP Code Access Token 验证和用户信息获取 + +import { invoke } from '@tauri-apps/api/core'; +import type { AmpUserInfo } from './types'; + +/** + * 通过 AMP Code Access Token 获取用户信息 + * @param accessToken - AMP Code Access Token + * @returns 用户信息 + */ +export async function getAmpUserInfo(accessToken: string): Promise { + return await invoke('get_amp_user_info', { accessToken }); +} + +/** + * 验证 AMP Code Access Token 并保存到代理配置 + * @param accessToken - AMP Code Access Token + * @returns 验证成功后的用户信息 + */ +export async function validateAndSaveAmpToken(accessToken: string): Promise { + return await invoke('validate_and_save_amp_token', { accessToken }); +} + +/** + * 获取已保存的 AMP Code 用户信息 + * @returns 用户信息(如果已保存且有效) + */ +export async function getSavedAmpUserInfo(): Promise { + return await invoke('get_saved_amp_user_info'); +} diff --git a/src/lib/tauri-commands/index.ts b/src/lib/tauri-commands/index.ts index 30b4566..e912d41 100644 --- a/src/lib/tauri-commands/index.ts +++ b/src/lib/tauri-commands/index.ts @@ -39,3 +39,6 @@ export * from './platform'; // API 调用 export * from './api'; + +// AMP 用户认证 +export * from './amp'; diff --git a/src/lib/tauri-commands/profile.ts b/src/lib/tauri-commands/profile.ts index fb299eb..228fc1e 100644 --- a/src/lib/tauri-commands/profile.ts +++ b/src/lib/tauri-commands/profile.ts @@ -89,3 +89,25 @@ export async function pmCaptureFromNative(toolId: ToolId, name: string): Promise export async function updateProxyFromProfile(toolId: ToolId, profileName: string): Promise { return invoke('update_proxy_from_profile', { toolId, profileName }); } + +// ==================== AMP Profile Selection ==================== + +import type { AmpProfileSelection, ProfileRef } from '@/types/profile'; + +/** + * 获取 AMP Profile 选择 + */ +export async function pmGetAmpSelection(): Promise { + return invoke('pm_get_amp_selection'); +} + +/** + * 保存 AMP Profile 选择 + */ +export async function pmSaveAmpSelection(input: { + claude: ProfileRef | null; + codex: ProfileRef | null; + gemini: ProfileRef | null; +}): Promise { + return invoke('pm_save_amp_selection', { input }); +} diff --git a/src/lib/tauri-commands/types.ts b/src/lib/tauri-commands/types.ts index d4cf3bc..f422bcd 100644 --- a/src/lib/tauri-commands/types.ts +++ b/src/lib/tauri-commands/types.ts @@ -241,6 +241,7 @@ export interface ToolProxyConfig { allow_public: boolean; session_endpoint_config_enabled: boolean; // 工具级:是否允许会话自定义端点 auto_start: boolean; // 应用启动时自动运行代理(默认关闭) + tavily_api_key?: string | null; // Tavily API Key(用于本地搜索,可选) } export interface TransparentProxyStatus { @@ -317,3 +318,11 @@ export interface BalanceConfigBackend { // 前端 BalanceConfig 格式(camelCase)- 从 BalancePage 导入 export type { BalanceConfig } from '@/pages/BalancePage/types'; + +// AMP 用户信息 +export interface AmpUserInfo { + id: string; + email: string | null; + name: string | null; + username: string | null; +} diff --git a/src/pages/ProfileManagementPage/components/AmpProfileSelector.tsx b/src/pages/ProfileManagementPage/components/AmpProfileSelector.tsx new file mode 100644 index 0000000..42fd046 --- /dev/null +++ b/src/pages/ProfileManagementPage/components/AmpProfileSelector.tsx @@ -0,0 +1,224 @@ +/** + * AMP Profile 选择器组件 + * 从 Claude Code、Codex、Gemini CLI 三个工具中选择 profile + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Save, Loader2, AlertCircle, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useToast } from '@/hooks/use-toast'; +import { pmGetAmpSelection, pmSaveAmpSelection } from '@/lib/tauri-commands'; +import type { + ProfileDescriptor, + ProfileRef, + ProfileToolId, + AmpProfileSelection, +} from '@/types/profile'; +import { logoMap } from '@/utils/constants'; + +interface AmpProfileSelectorProps { + allProfiles: ProfileDescriptor[]; + onSwitchTab: (toolId: ProfileToolId) => void; +} + +const TOOL_CONFIG: { + key: keyof Omit; + toolId: ProfileToolId; + label: string; +}[] = [ + { key: 'claude', toolId: 'claude-code', label: 'Claude API' }, + { key: 'codex', toolId: 'codex', label: 'OpenAI API' }, + { key: 'gemini', toolId: 'gemini-cli', label: 'Gemini API' }, +]; + +export function AmpProfileSelector({ allProfiles, onSwitchTab }: AmpProfileSelectorProps) { + const { toast } = useToast(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [selection, setSelection] = useState<{ + claude: ProfileRef | null; + codex: ProfileRef | null; + gemini: ProfileRef | null; + }>({ + claude: null, + codex: null, + gemini: null, + }); + + // 按工具分组 profiles + const profilesByTool: Record = { + 'claude-code': allProfiles.filter((p) => p.tool_id === 'claude-code'), + codex: allProfiles.filter((p) => p.tool_id === 'codex'), + 'gemini-cli': allProfiles.filter((p) => p.tool_id === 'gemini-cli'), + }; + + // 加载已保存的选择 + const loadSelection = useCallback(async () => { + try { + setLoading(true); + const saved = await pmGetAmpSelection(); + setSelection({ + claude: saved.claude || null, + codex: saved.codex || null, + gemini: saved.gemini || null, + }); + } catch (error) { + console.error('Failed to load AMP selection:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadSelection(); + }, [loadSelection]); + + // 保存选择 + const handleSave = async () => { + try { + setSaving(true); + await pmSaveAmpSelection(selection); + toast({ + title: '保存成功', + description: 'AMP Profile 选择已更新', + }); + } catch (error) { + const message = error instanceof Error ? error.message : '保存失败'; + toast({ + title: '保存失败', + description: message, + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + // 更新单个工具的选择 + const handleSelectChange = ( + key: 'claude' | 'codex' | 'gemini', + toolId: ProfileToolId, + value: string, + ) => { + if (value === '__none__') { + setSelection((prev) => ({ ...prev, [key]: null })); + } else { + setSelection((prev) => ({ + ...prev, + [key]: { tool_id: toolId, profile_name: value }, + })); + } + }; + + // 检查引用是否有效 + const isRefValid = (ref: ProfileRef | null, profiles: ProfileDescriptor[]): boolean => { + if (!ref) return true; + return profiles.some((p) => p.name === ref.profile_name); + }; + + if (loading) { + return ( + + + + 加载中... + + + ); + } + + return ( + + +
+ AMP Code +
+ AMP Code 配置 + AMP 使用其他工具的 Profile 配置,请从下方选择 +
+
+
+ + {TOOL_CONFIG.map(({ key, toolId, label }) => { + const profiles = profilesByTool[toolId]; + const currentRef = selection[key]; + const isValid = isRefValid(currentRef, profiles); + + return ( +
+
+ {label} + +
+ + {profiles.length === 0 ? ( + + + + 还没有 {label} 配置 + + + + ) : ( + <> + + {!isValid && currentRef && ( +

+ 引用的 Profile "{currentRef.profile_name}" 已失效,请重新选择 +

+ )} + + )} +
+ ); + })} + +
+ +
+
+
+ ); +} diff --git a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts index 7a81cdd..4421e58 100644 --- a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts +++ b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useToast } from '@/hooks/use-toast'; -import type { ProfileFormData, ProfileGroup, ToolId, ProfilePayload } from '@/types/profile'; +import type { ProfileFormData, ProfileGroup, ProfileToolId, ProfilePayload } from '@/types/profile'; import { pmListAllProfiles, pmSaveProfile, @@ -19,6 +19,7 @@ import { TOOL_NAMES } from '@/types/profile'; interface UseProfileManagementReturn { // 状态 profileGroups: ProfileGroup[]; + allProfiles: import('@/types/profile').ProfileDescriptor[]; loading: boolean; error: string | null; allProxyStatus: AllProxyStatus; @@ -26,16 +27,17 @@ interface UseProfileManagementReturn { // 操作方法 refresh: () => Promise; loadAllProxyStatus: () => Promise; - createProfile: (toolId: ToolId, data: ProfileFormData) => Promise; - updateProfile: (toolId: ToolId, name: string, data: ProfileFormData) => Promise; - deleteProfile: (toolId: ToolId, name: string) => Promise; - activateProfile: (toolId: ToolId, name: string) => Promise; - captureFromNative: (toolId: ToolId, name: string) => Promise; + createProfile: (toolId: ProfileToolId, data: ProfileFormData) => Promise; + updateProfile: (toolId: ProfileToolId, name: string, data: ProfileFormData) => Promise; + deleteProfile: (toolId: ProfileToolId, name: string) => Promise; + activateProfile: (toolId: ProfileToolId, name: string) => Promise; + captureFromNative: (toolId: ProfileToolId, name: string) => Promise; } export function useProfileManagement(): UseProfileManagementReturn { const { toast } = useToast(); const [profileGroups, setProfileGroups] = useState([]); + const [allProfiles, setAllProfiles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [allProxyStatus, setAllProxyStatus] = useState({}); @@ -46,22 +48,23 @@ export function useProfileManagement(): UseProfileManagementReturn { setError(null); try { - const allProfiles = await pmListAllProfiles(); + const profiles = await pmListAllProfiles(); + setAllProfiles(profiles); - // 按工具分组 - const groups: ProfileGroup[] = (['claude-code', 'codex', 'gemini-cli'] as ToolId[]).map( - (toolId) => { - const toolProfiles = allProfiles.filter((p) => p.tool_id === toolId); - const activeProfile = toolProfiles.find((p) => p.is_active); + // 按工具分组(仅可创建 profile 的工具) + const groups: ProfileGroup[] = ( + ['claude-code', 'codex', 'gemini-cli'] as ProfileToolId[] + ).map((toolId) => { + const toolProfiles = profiles.filter((p) => p.tool_id === toolId); + const activeProfile = toolProfiles.find((p) => p.is_active); - return { - tool_id: toolId, - tool_name: TOOL_NAMES[toolId], - profiles: toolProfiles, - active_profile: activeProfile, - }; - }, - ); + return { + tool_id: toolId, + tool_name: TOOL_NAMES[toolId], + profiles: toolProfiles, + active_profile: activeProfile, + }; + }); setProfileGroups(groups); } catch (err) { @@ -94,7 +97,7 @@ export function useProfileManagement(): UseProfileManagementReturn { // 创建 Profile const createProfile = useCallback( - async (toolId: ToolId, data: ProfileFormData) => { + async (toolId: ProfileToolId, data: ProfileFormData) => { try { const payload = buildProfilePayload(toolId, data); await pmSaveProfile(toolId, data.name, payload); @@ -118,7 +121,7 @@ export function useProfileManagement(): UseProfileManagementReturn { // 更新 Profile const updateProfile = useCallback( - async (toolId: ToolId, name: string, data: ProfileFormData) => { + async (toolId: ProfileToolId, name: string, data: ProfileFormData) => { try { const payload = buildProfilePayload(toolId, data); await pmSaveProfile(toolId, name, payload); @@ -142,7 +145,7 @@ export function useProfileManagement(): UseProfileManagementReturn { // 删除 Profile const deleteProfile = useCallback( - async (toolId: ToolId, name: string) => { + async (toolId: ProfileToolId, name: string) => { try { await pmDeleteProfile(toolId, name); toast({ @@ -165,7 +168,7 @@ export function useProfileManagement(): UseProfileManagementReturn { // 激活 Profile const activateProfile = useCallback( - async (toolId: ToolId, name: string) => { + async (toolId: ProfileToolId, name: string) => { try { await pmActivateProfile(toolId, name); toast({ @@ -188,7 +191,7 @@ export function useProfileManagement(): UseProfileManagementReturn { // 从原生配置捕获 const captureFromNative = useCallback( - async (toolId: ToolId, name: string) => { + async (toolId: ProfileToolId, name: string) => { try { await pmCaptureFromNative(toolId, name); toast({ @@ -216,6 +219,7 @@ export function useProfileManagement(): UseProfileManagementReturn { return { profileGroups, + allProfiles, loading, error, allProxyStatus, @@ -234,7 +238,7 @@ export function useProfileManagement(): UseProfileManagementReturn { /** * 构建 ProfilePayload(工具分组即类型,无需 type 字段) */ -function buildProfilePayload(toolId: ToolId, data: ProfileFormData): ProfilePayload { +function buildProfilePayload(toolId: ProfileToolId, data: ProfileFormData): ProfilePayload { switch (toolId) { case 'claude-code': return { diff --git a/src/pages/ProfileManagementPage/index.tsx b/src/pages/ProfileManagementPage/index.tsx index 6fc7e8c..0b02098 100644 --- a/src/pages/ProfileManagementPage/index.tsx +++ b/src/pages/ProfileManagementPage/index.tsx @@ -25,13 +25,15 @@ import { ProfileEditor } from './components/ProfileEditor'; import { ActiveProfileCard } from './components/ActiveProfileCard'; import { ImportFromProviderDialog } from './components/ImportFromProviderDialog'; import { CreateCustomProfileDialog } from './components/CreateCustomProfileDialog'; +import { AmpProfileSelector } from './components/AmpProfileSelector'; import { useProfileManagement } from './hooks/useProfileManagement'; -import type { ToolId, ProfileFormData, ProfileDescriptor } from '@/types/profile'; +import type { ProfileToolId, ProfileFormData, ProfileDescriptor, ToolId } from '@/types/profile'; import { logoMap } from '@/utils/constants'; export default function ProfileManagementPage() { const { profileGroups, + allProfiles, loading, error, allProxyStatus, @@ -69,10 +71,11 @@ export default function ProfileManagementPage() { // 保存 Profile const handleSaveProfile = async (data: ProfileFormData) => { + if (selectedTab === 'amp-code') return; // AMP 不支持创建 profile if (editorMode === 'create') { - await createProfile(selectedTab, data); + await createProfile(selectedTab as ProfileToolId, data); } else if (editingProfile) { - await updateProfile(selectedTab, editingProfile.name, data); + await updateProfile(selectedTab as ProfileToolId, editingProfile.name, data); } setEditorOpen(false); // 对话框关闭后刷新数据 @@ -81,12 +84,14 @@ export default function ProfileManagementPage() { // 激活 Profile const handleActivateProfile = async (profileName: string) => { - await activateProfile(selectedTab, profileName); + if (selectedTab === 'amp-code') return; // AMP 不支持激活 profile + await activateProfile(selectedTab as ProfileToolId, profileName); }; // 删除 Profile const handleDeleteProfile = async (profileName: string) => { - await deleteProfile(selectedTab, profileName); + if (selectedTab === 'amp-code') return; // AMP 不支持删除 profile + await deleteProfile(selectedTab as ProfileToolId, profileName); }; // 构建编辑器初始数据 @@ -146,13 +151,18 @@ export default function ProfileManagementPage() { <> {/* 工具 Tab 切换 */} setSelectedTab(v as ToolId)}> - + {profileGroups.map((group) => ( {group.tool_name} {group.tool_name} ))} + {/* AMP Code Tab */} + + AMP Code + AMP Code + {/* 每个工具的 Profile 列表 */} @@ -215,19 +225,29 @@ export default function ProfileManagementPage() { )} ))} + + {/* AMP Code Tab 内容 */} + + setSelectedTab(toolId)} + /> + )} - {/* Profile 编辑器对话框 */} - + {/* Profile 编辑器对话框(AMP 不需要) */} + {selectedTab !== 'amp-code' && ( + + )} {/* 帮助弹窗 */} diff --git a/src/pages/TransparentProxyPage/components/ProxyControlBar.tsx b/src/pages/TransparentProxyPage/components/ProxyControlBar.tsx index 217d4af..627bf07 100644 --- a/src/pages/TransparentProxyPage/components/ProxyControlBar.tsx +++ b/src/pages/TransparentProxyPage/components/ProxyControlBar.tsx @@ -223,8 +223,9 @@ export function ProxyControlBar({ }; }, [tool.id]); - // 检查上游配置是否缺失 - const isUpstreamConfigMissing = isRunning && (!config?.real_base_url || !config?.real_api_key); + // 检查上游配置是否缺失(amp-code 除外,它使用 amp_selection) + const isUpstreamConfigMissing = + isRunning && tool.id !== 'amp-code' && (!config?.real_base_url || !config?.real_api_key); // 当前配置名称 const currentProfileName = config?.real_profile_name; @@ -256,6 +257,11 @@ export function ProxyControlBar({ // 启动代理处理:检查上游配置 const handleStartProxy = () => { + // amp-code 不需要 real_base_url/real_api_key,跳过检查 + if (tool.id === 'amp-code') { + onStart(); + return; + } // 检查上游配置是否缺失 if (!config?.real_base_url || !config?.real_api_key) { // 配置缺失,标记需要自动启动,然后打开配置选择对话框 @@ -284,12 +290,15 @@ export function ProxyControlBar({ {isRunning ? `运行中 (端口 ${port})` : '已停止'} - - 配置:{currentProfileName || '未知'} - + {/* amp-code 使用 amp_selection 而非 real_profile_name,不显示此标签 */} + {tool.id !== 'amp-code' && ( + + 配置:{currentProfileName || '未知'} + + )}

{isRunning @@ -341,8 +350,8 @@ export function ProxyControlBar({ 代理设置 - {/* 切换配置按钮(运行时显示) */} - {isRunning && ( + {/* 切换配置按钮(运行时显示,amp-code 除外) */} + {isRunning && tool.id !== 'amp-code' && ( + +

+ 用于登录 AMP 并获取用户信息,可在{' '} + + ampcode.com/settings + {' '} + 获取 +

+ {ampUserInfo && ( +
+ + + 已登录: {ampUserInfo.username || ampUserInfo.email || ampUserInfo.id} + +
+ )} + + {/* Tavily API Key(用于本地搜索) */} +
+ + setTavilyApiKey(e.target.value)} + disabled={isRunning} + className="font-mono" + /> +

+ 用于本地处理 webSearch2 请求,不配置则使用 DuckDuckGo 搜索。可在{' '} + + tavily.com + {' '} + 免费获取(每月 1000 次) +

+
+ + )} + {/* 允许公网访问 */}
@@ -252,20 +401,22 @@ export function ProxySettingsDialog({ />
- {/* 会话级端点配置 */} -
-
- -

- 允许为每个代理会话单独配置 API 端点 -

+ {/* 会话级端点配置(仅非 AMP) */} + {toolId !== 'amp-code' && ( +
+
+ +

+ 允许为每个代理会话单独配置 API 端点 +

+
+
- -
+ )} {/* 应用启动时自动运行 */}
diff --git a/src/pages/TransparentProxyPage/hooks/useToolProxyData.ts b/src/pages/TransparentProxyPage/hooks/useToolProxyData.ts index 32446e8..4dd91ee 100644 --- a/src/pages/TransparentProxyPage/hooks/useToolProxyData.ts +++ b/src/pages/TransparentProxyPage/hooks/useToolProxyData.ts @@ -56,6 +56,7 @@ export function useToolProxyData() { loadToolConfig('claude-code'), loadToolConfig('codex'), loadToolConfig('gemini-cli'), + loadToolConfig('amp-code'), ]); } finally { setConfigLoading(false); @@ -92,7 +93,7 @@ export function useToolProxyData() { * 获取所有工具的数据 */ const getAllToolsData = useCallback((): ToolData[] => { - const toolIds: ToolId[] = ['claude-code', 'codex', 'gemini-cli']; + const toolIds: ToolId[] = ['claude-code', 'codex', 'gemini-cli', 'amp-code']; return toolIds.map((toolId) => getToolData(toolId)); }, [getToolData]); @@ -103,7 +104,16 @@ export function useToolProxyData() { async (toolId: ToolId, updates: Partial): Promise => { const currentConfig = configs.get(toolId) || { enabled: false, - port: toolId === 'claude-code' ? 8787 : toolId === 'codex' ? 8788 : 8789, + port: + toolId === 'claude-code' + ? 8787 + : toolId === 'codex' + ? 8788 + : toolId === 'gemini-cli' + ? 8789 + : toolId === 'amp-code' + ? 8790 + : 8791, local_api_key: null, real_api_key: null, real_base_url: null, diff --git a/src/pages/TransparentProxyPage/index.tsx b/src/pages/TransparentProxyPage/index.tsx index cee08e9..e26e27d 100644 --- a/src/pages/TransparentProxyPage/index.tsx +++ b/src/pages/TransparentProxyPage/index.tsx @@ -18,6 +18,7 @@ const SUPPORTED_TOOLS: ToolMetadata[] = [ { id: 'claude-code', name: 'Claude Code', icon: logoMap['claude-code'] }, { id: 'codex', name: 'CodeX', icon: logoMap.codex }, { id: 'gemini-cli', name: 'Gemini CLI', icon: logoMap['gemini-cli'] }, + { id: 'amp-code', name: 'AMP Code', icon: logoMap['amp-code'] }, ]; interface TransparentProxyPageProps { @@ -48,7 +49,8 @@ export function TransparentProxyPage({ selectedToolId: initialToolId }: Transpar initialToolId && (initialToolId === 'claude-code' || initialToolId === 'codex' || - initialToolId === 'gemini-cli') + initialToolId === 'gemini-cli' || + initialToolId === 'amp-code') ) { setSelectedToolId(initialToolId as ToolId); } @@ -106,9 +108,9 @@ export function TransparentProxyPage({ selectedToolId: initialToolId }: Transpar

- {/* 三工具 Tab 切换 */} + {/* 四工具 Tab 切换 */} setSelectedToolId(val as ToolId)}> - + {SUPPORTED_TOOLS.map((tool) => ( {tool.name} diff --git a/src/pages/TransparentProxyPage/types/proxy-history.ts b/src/pages/TransparentProxyPage/types/proxy-history.ts index ebf4a8c..c54a579 100644 --- a/src/pages/TransparentProxyPage/types/proxy-history.ts +++ b/src/pages/TransparentProxyPage/types/proxy-history.ts @@ -14,9 +14,9 @@ export interface ProxySessionRecord { } /** - * 工具 ID 类型(严格限制为三个支持的工具) + * 工具 ID 类型(严格限制为四个支持的工具) */ -export type ToolId = 'claude-code' | 'codex' | 'gemini-cli'; +export type ToolId = 'claude-code' | 'codex' | 'gemini-cli' | 'amp-code'; /** * 工具元数据(用于 UI 展示) diff --git a/src/types/profile.ts b/src/types/profile.ts index c6e8437..a1582ac 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -34,6 +34,7 @@ export interface GeminiProfilePayload { * Profile Payload 联合类型(前端传递给后端) * * 使用 tagged union 确保类型正确匹配 + * 注意:AMP 不创建 profile,使用 AmpProfileSelection 选择其他工具的 profile */ export type ProfilePayload = | ({ type: 'claude-code' } & ClaudeProfilePayload) @@ -94,9 +95,33 @@ export interface ProfileDescriptor { } /** - * 工具 ID 类型 + * 可创建 Profile 的工具 ID(不含 AMP) */ -export type ToolId = 'claude-code' | 'codex' | 'gemini-cli'; +export type ProfileToolId = 'claude-code' | 'codex' | 'gemini-cli'; + +/** + * 所有工具 ID(包含 AMP) + */ +export type ToolId = ProfileToolId | 'amp-code'; + +/** + * Profile 引用(指向某工具的某个 profile) + */ +export interface ProfileRef { + tool_id: ProfileToolId; + profile_name: string; +} + +/** + * AMP Profile 选择(引用其他工具的 profile) + * AMP 不创建独立 profile,而是从 3 个工具中选择 + */ +export interface AmpProfileSelection { + claude: ProfileRef | null; + codex: ProfileRef | null; + gemini: ProfileRef | null; + updated_at: string; // ISO 8601 时间字符串 +} /** * 工具显示名称映射 @@ -105,6 +130,7 @@ export const TOOL_NAMES: Record = { 'claude-code': 'Claude Code', codex: 'CodeX', 'gemini-cli': 'Gemini CLI', + 'amp-code': 'AMP Code', }; /** @@ -114,6 +140,7 @@ export const TOOL_COLORS: Record = { 'claude-code': 'bg-orange-500', codex: 'bg-green-500', 'gemini-cli': 'bg-blue-500', + 'amp-code': 'bg-purple-500', }; /** @@ -135,10 +162,10 @@ export interface ProfileFormData { export type ProfileOperation = 'create' | 'edit' | 'delete' | 'activate'; /** - * Profile 分组(按工具) + * Profile 分组(按工具,仅可创建 profile 的工具) */ export interface ProfileGroup { - tool_id: ToolId; + tool_id: ProfileToolId; tool_name: string; profiles: ProfileDescriptor[]; active_profile?: ProfileDescriptor; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a091792..23c34ab 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -2,11 +2,13 @@ import ClaudeLogo from '@/assets/claude-logo.png'; import CodexLogo from '@/assets/codex-logo.png'; import GeminiLogo from '@/assets/gemini-logo.png'; +import AmpLogo from '@/assets/amp-logo.svg'; export const logoMap: Record = { 'claude-code': ClaudeLogo, codex: CodexLogo, 'gemini-cli': GeminiLogo, + 'amp-code': AmpLogo, }; // 工具描述映射