From 3108c2fda8484d2aaa1cb70e6b23134beebfb9c6 Mon Sep 17 00:00:00 2001 From: ehgen0ng Date: Wed, 7 Jan 2026 17:23:03 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(amp-profile):=20=E5=BC=95=E5=85=A5=20a?= =?UTF-8?q?mp=20profile=20=E9=80=89=E6=8B=A9=E5=8A=9F=E8=83=BD=20-=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20AMP=20profile=20=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E7=9A=84=E5=90=8E=E7=AB=AF=E6=9C=8D=E5=8A=A1=E5=92=8C=20Tauri?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=20-=20=E5=AE=9E=E7=8E=B0=20AMP=20profile?= =?UTF-8?q?=20=E9=80=89=E6=8B=A9=E5=89=8D=E7=AB=AF=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E5=85=81=E8=AE=B8=E9=80=89=E6=8B=A9=20claude,=20codex?= =?UTF-8?q?,=20gemini=20profile=20-=20=E5=B0=86=20AMP=20profile=20?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=8A=9F=E8=83=BD=E9=9B=86=E6=88=90=E8=87=B3?= =?UTF-8?q?=20Profile=20=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=8A=A0=E4=B8=93=E7=94=A8=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=A1=B5=20-=20=E5=AE=9A=E4=B9=89=20AMP=20profile=20=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E7=B1=BB=E5=9E=8B=EF=BC=8C=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20AMP=20logo=20=E8=B5=84=E6=BA=90=20-=20=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E6=98=AF=E8=AE=A9=20AMP=20Code=20=E5=A4=8D=E7=94=A8=E7=8E=B0?= =?UTF-8?q?=E6=9C=89=E5=B7=A5=E5=85=B7=E7=9A=84=20Profile=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/profile_commands.rs | 54 ++++- src-tauri/src/main.rs | 2 + .../src/services/profile_manager/manager.rs | 76 ++++++ src-tauri/src/services/profile_manager/mod.rs | 5 +- .../src/services/profile_manager/types.rs | 33 +++ src/assets/amp-logo.svg | 5 + src/lib/tauri-commands/profile.ts | 22 ++ .../components/AmpProfileSelector.tsx | 224 ++++++++++++++++++ .../hooks/useProfileManagement.ts | 56 +++-- src/pages/ProfileManagementPage/index.tsx | 50 ++-- src/types/profile.ts | 35 ++- src/utils/constants.ts | 2 + 12 files changed, 516 insertions(+), 48 deletions(-) create mode 100644 src/assets/amp-logo.svg create mode 100644 src/pages/ProfileManagementPage/components/AmpProfileSelector.tsx 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/main.rs b/src-tauri/src/main.rs index 53357cf..455b0fe 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -370,6 +370,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, 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/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/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/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/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, }; // 工具描述映射 From cc721319b4ea22e1015732f67ce853c2875b3631 Mon Sep 17 00:00:00 2001 From: ehgen0ng Date: Wed, 7 Jan 2026 21:57:43 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(proxy):=20=E6=96=B0=E5=A2=9E=20amp=20c?= =?UTF-8?q?ode=20=E9=80=8F=E6=98=8E=E4=BB=A3=E7=90=86=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?-=20=E5=9C=A8=E4=BB=A3=E7=90=86=E6=9C=8D=E5=8A=A1=E4=B8=AD?= =?UTF-8?q?=E9=9B=86=E6=88=90=20amp=20code=20=E5=B7=A5=E5=85=B7=EF=BC=8C?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=8A=A8=E6=80=81=E8=B7=AF=E7=94=B1=E5=88=B0?= =?UTF-8?q?=20claude/codex/gemini=20-=20=E5=BC=95=E5=85=A5=20`AmpHeadersPr?= =?UTF-8?q?ocessor`=EF=BC=8C=E6=A0=B9=E6=8D=AE=E8=AF=B7=E6=B1=82=E7=89=B9?= =?UTF-8?q?=E5=BE=81=EF=BC=88=E8=B7=AF=E5=BE=84=E3=80=81=E5=A4=B4=E9=83=A8?= =?UTF-8?q?=E3=80=81=E6=A8=A1=E5=9E=8B=E5=AD=97=E6=AE=B5=EF=BC=89=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AF=86=E5=88=AB=E4=B8=8A=E6=B8=B8=20api=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=20-=20=E6=9B=B4=E6=96=B0=E5=90=8E=E7=AB=AF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=A8=A1=E5=9E=8B=E5=92=8C=E5=AD=98=E5=82=A8=EF=BC=8C?= =?UTF-8?q?=E4=B8=BA=20amp=20code=20=E6=8F=90=E4=BE=9B=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE=E5=92=8C=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=AB=AF=E5=8F=A3=20(8790)=20-=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=90=AF=E5=8A=A8=E5=92=8C=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8Camp=20code=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=8E=B7=E5=8F=96=20api=20key=20=E5=92=8C=20?= =?UTF-8?q?base=20url=EF=BC=8C=E8=B7=B3=E8=BF=87=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=AA=8C=E8=AF=81=20-=20=E5=9C=A8=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=95=8C=E9=9D=A2=EF=BC=88=E5=B7=A5=E5=85=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E3=80=81=E6=8E=A7=E5=88=B6=E6=A0=8F=E3=80=81=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=EF=BC=89=E4=B8=AD=E5=85=A8=E9=9D=A2=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20amp=20code=20=E7=9A=84=E6=98=BE=E7=A4=BA=E5=92=8C=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=20-=20=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=8E=A7=E5=88=B6=E6=A0=8F=E6=98=BE=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?amp=20code=20=E4=B8=8D=E6=98=BE=E7=A4=BA=20profile=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=92=8C=E5=88=87=E6=8D=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/proxy_commands.rs | 99 +++++---- src-tauri/src/models/config.rs | 17 ++ src-tauri/src/models/proxy_config.rs | 11 + .../services/proxy/headers/amp_processor.rs | 193 ++++++++++++++++++ src-tauri/src/services/proxy/headers/mod.rs | 3 + .../src/services/proxy/proxy_instance.rs | 15 +- .../components/ProxyControlBar.tsx | 29 ++- .../components/ProxySettingsDialog.tsx | 20 +- .../hooks/useToolProxyData.ts | 14 +- src/pages/TransparentProxyPage/index.tsx | 8 +- .../types/proxy-history.ts | 4 +- 11 files changed, 352 insertions(+), 61 deletions(-) create mode 100644 src-tauri/src/services/proxy/headers/amp_processor.rs diff --git a/src-tauri/src/commands/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index 08915fc..d240f2e 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -161,51 +161,75 @@ 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))?; + // 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" - ); + tracing::info!( + tool_id = %tool_id, + original_profile = ?original_profile, + proxy_profile = %proxy_profile_name, + "已切换到代理 Profile" + ); + } else { + // amp-code 使用 resolve_amp_selection 动态获取配置,无需 Profile 切换 + tracing::info!( + tool_id = %tool_id, + "Amp Code 代理启动,将动态路由到用户选择的 Profile" + ); + } // ========== 启动代理 ========== @@ -345,7 +369,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 +377,7 @@ pub async fn get_all_proxy_status( "claude-code" => 8787, "codex" => 8788, "gemini-cli" => 8789, + "amp-code" => 8790, _ => 8790, }); 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..9acf2fb 100644 --- a/src-tauri/src/models/proxy_config.rs +++ b/src-tauri/src/models/proxy_config.rs @@ -50,6 +50,7 @@ impl ToolProxyConfig { "claude-code" => 8787, "codex" => 8788, "gemini-cli" => 8789, + "amp-code" => 8790, _ => 8787, } } @@ -64,9 +65,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 +81,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 +94,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 +105,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 +116,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/proxy/headers/amp_processor.rs b/src-tauri/src/services/proxy/headers/amp_processor.rs new file mode 100644 index 0000000..4728dd8 --- /dev/null +++ b/src-tauri/src/services/proxy/headers/amp_processor.rs @@ -0,0 +1,193 @@ +// Amp Code 请求处理器 +// +// Amp CLI 的请求本质上是 Anthropic/OpenAI/Gemini 兼容的 API 调用, +// 此处理器根据请求路径/headers 判断请求类型,然后委托给对应的 processor 处理。 +// +// 核心逻辑: +// 1. 检测请求类型(Claude/Codex/Gemini) +// 2. 从 ProfileManager::resolve_amp_selection() 获取用户选择的 profile +// 3. 委托给对应的 processor 完成实际的请求转换 + +use super::{ + ClaudeHeadersProcessor, CodexHeadersProcessor, GeminiHeadersProcessor, ProcessedRequest, + RequestProcessor, +}; +use crate::services::profile_manager::ProfileManager; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use hyper::HeaderMap as HyperHeaderMap; + +/// Amp Code 请求处理器 +/// +/// 作为路由层,根据请求特征判断应该使用哪个上游 API, +/// 然后委托给对应的 processor 完成实际转换。 +/// +/// # 路由规则(按优先级) +/// 1. `/v1/messages` → Claude (Anthropic API) +/// 2. `/v1/chat/completions`, `/v1/responses`, `/v1/completions` → Codex (OpenAI API) +/// 3. `/v1beta/`, `:generateContent`, `:streamGenerateContent` → Gemini API +/// 4. 兜底:检查 headers 中的 `anthropic-version` 或请求体中的 model 字段 +#[derive(Debug)] +pub struct AmpHeadersProcessor; + +/// 检测请求类型 +#[derive(Debug, Clone, Copy, PartialEq)] +enum DetectedApiType { + Claude, + Codex, + Gemini, +} + +impl AmpHeadersProcessor { + /// 根据请求路径和 headers 检测 API 类型 + fn detect_api_type(path: &str, headers: &HyperHeaderMap, body: &[u8]) -> DetectedApiType { + // 1. 优先按路径判断(最稳定) + let path_lower = path.to_lowercase(); + + // Anthropic Claude API + if path_lower.contains("/messages") && !path_lower.contains("/chat/completions") { + return DetectedApiType::Claude; + } + + // OpenAI API + if path_lower.contains("/chat/completions") + || path_lower.contains("/responses") + || path_lower.ends_with("/completions") + { + return DetectedApiType::Codex; + } + + // Gemini API + if path_lower.contains("/v1beta") + || path_lower.contains(":generatecontent") + || path_lower.contains(":streamgeneratecontent") + { + return DetectedApiType::Gemini; + } + + // 2. 其次按 headers 判断 + if headers.contains_key("anthropic-version") { + return DetectedApiType::Claude; + } + + // 3. 最后按 body 判断(解析 JSON 中的 model 字段) + 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()) { + let model_lower = model.to_lowercase(); + + // Gemini models + if model_lower.contains("gemini") { + return DetectedApiType::Gemini; + } + + // Claude models + if model_lower.contains("claude") { + return DetectedApiType::Claude; + } + + // OpenAI models (gpt, o1, etc.) + if model_lower.contains("gpt") + || model_lower.starts_with("o1") + || model_lower.starts_with("o3") + { + return DetectedApiType::Codex; + } + } + } + } + + // 默认使用 Claude(因为 Amp 主要面向 Claude API) + DetectedApiType::Claude + } +} + +#[async_trait] +impl RequestProcessor for AmpHeadersProcessor { + fn tool_id(&self) -> &str { + "amp-code" + } + + async fn process_outgoing_request( + &self, + _base_url: &str, // 忽略传入的 base_url,使用 resolve_amp_selection 获取 + _api_key: &str, // 忽略传入的 api_key,使用 resolve_amp_selection 获取 + path: &str, + query: Option<&str>, + original_headers: &HyperHeaderMap, + body: &[u8], + ) -> Result { + // 1. 检测请求类型 + let api_type = Self::detect_api_type(path, original_headers, body); + tracing::debug!("Amp Code 请求路由: path={}, api_type={:?}", path, api_type); + + // 2. 获取 ProfileManager 并解析 AMP 选择 + let profile_manager = + ProfileManager::new().map_err(|e| anyhow!("无法初始化 ProfileManager: {}", e))?; + + let (claude_profile, codex_profile, gemini_profile) = profile_manager + .resolve_amp_selection() + .map_err(|e| anyhow!("无法解析 AMP Code Profile 选择: {}", e))?; + + // 3. 根据检测到的 API 类型选择对应的 profile 和 processor + match api_type { + DetectedApiType::Claude => { + let profile = claude_profile.ok_or_else(|| { + anyhow!("AMP Code 未配置 Claude Profile,请先在 Profile 管理页面选择") + })?; + + tracing::info!("Amp Code 请求转发到 Claude: base_url={}", profile.base_url); + + // 委托给 Claude processor + ClaudeHeadersProcessor + .process_outgoing_request( + &profile.base_url, + &profile.api_key, + path, + query, + original_headers, + body, + ) + .await + } + DetectedApiType::Codex => { + let profile = codex_profile.ok_or_else(|| { + anyhow!("AMP Code 未配置 Codex Profile,请先在 Profile 管理页面选择") + })?; + + tracing::info!("Amp Code 请求转发到 Codex: base_url={}", profile.base_url); + + // 委托给 Codex processor + CodexHeadersProcessor + .process_outgoing_request( + &profile.base_url, + &profile.api_key, + path, + query, + original_headers, + body, + ) + .await + } + DetectedApiType::Gemini => { + let profile = gemini_profile.ok_or_else(|| { + anyhow!("AMP Code 未配置 Gemini Profile,请先在 Profile 管理页面选择") + })?; + + tracing::info!("Amp Code 请求转发到 Gemini: base_url={}", profile.base_url); + + // 委托给 Gemini processor + GeminiHeadersProcessor + .process_outgoing_request( + &profile.base_url, + &profile.api_key, + path, + query, + original_headers, + body, + ) + .await + } + } + } +} 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..92eefb9 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -208,9 +208,12 @@ async fn handle_request_inner( tool_id: &str, ) -> Result> { // 获取配置 + // amp-code 使用 amp_selection 动态路由,不需要 real_api_key/real_base_url let proxy_config = { let cfg = config.read().await; - if cfg.real_api_key.is_none() || cfg.real_base_url.is_none() { + if tool_id != "amp-code" + && (cfg.real_api_key.is_none() || cfg.real_base_url.is_none()) + { return Ok(error_responses::configuration_missing(tool_id)); } cfg.clone() @@ -244,11 +247,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 +262,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, 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} + +
+ )} + + )} + {/* 允许公网访问 */}
@@ -268,20 +370,22 @@ export function ProxySettingsDialog({ />
- {/* 会话级端点配置 */} -
-
- -

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

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

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

+
+
- -
+ )} {/* 应用启动时自动运行 */}
From 765545b97bd98b5637e0eaa86869d04673809da9 Mon Sep 17 00:00:00 2001 From: ehgen0ng Date: Fri, 9 Jan 2026 22:19:11 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(amp-code):=20=E5=85=81=E8=AE=B8?= =?UTF-8?q?=E6=B8=85=E9=99=A4=20AMP=20Code=20=E8=AE=BF=E9=97=AE=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=20=20=20-=20=E7=A1=AE=E4=BF=9D=E5=BD=93=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=B8=BA=E7=A9=BA=E6=97=B6=EF=BC=8CAMP=20Access=20Tok?= =?UTF-8?q?en=20=E5=92=8C=E5=9F=BA=E7=A1=80=20URL=20=E8=83=BD=E5=A4=9F?= =?UTF-8?q?=E8=A2=AB=E6=AD=A3=E7=A1=AE=E4=BF=9D=E5=AD=98=E4=B8=BA=20null?= =?UTF-8?q?=EF=BC=8C=E4=BB=8E=E8=80=8C=E5=85=81=E8=AE=B8=E6=B8=85=E9=99=A4?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20=20=20-=20=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=E6=97=A0=E6=B3=95=E9=80=9A=E8=BF=87=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=E8=BE=93=E5=85=A5=E6=A1=86=E6=9D=A5=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=20AMP=20Access=20Token=20=E9=85=8D=E7=BD=AE=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit style(amp-code): 统一 AMP Code 品牌命名为大写 - 将原生配置文件中的“Amp Code”注释更新为“AMP Code”以保持一致性 - 将透明代理页面工具列表中的“Amp Code”显示名称更新为“AMP Code” --- src-tauri/src/services/amp_native_config.rs | 2 +- .../services/proxy/headers/amp_processor.rs | 48 +++++++++++++++---- .../src/services/proxy/proxy_instance.rs | 34 +++++++++++-- .../components/ProxySettingsDialog.tsx | 8 ++-- src/pages/TransparentProxyPage/index.tsx | 2 +- 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/services/amp_native_config.rs b/src-tauri/src/services/amp_native_config.rs index fefcc03..9248ebb 100644 --- a/src-tauri/src/services/amp_native_config.rs +++ b/src-tauri/src/services/amp_native_config.rs @@ -1,4 +1,4 @@ -//! Amp Code 原生配置文件管理 +//! AMP Code 原生配置文件管理 //! //! 配置文件位置: //! - ~/.config/amp/settings.json - 存储 amp.url diff --git a/src-tauri/src/services/proxy/headers/amp_processor.rs b/src-tauri/src/services/proxy/headers/amp_processor.rs index 26a2b28..ecd6d81 100644 --- a/src-tauri/src/services/proxy/headers/amp_processor.rs +++ b/src-tauri/src/services/proxy/headers/amp_processor.rs @@ -1,4 +1,4 @@ -// Amp Code 请求处理器 +// AMP Code 请求处理器 // // 路由逻辑: // 1. /api/provider/anthropic/* → Claude Profile(提取 /v1/messages) @@ -27,6 +27,8 @@ enum ApiType { Gemini, } +const TOOL_PREFIX: &str = "dc_"; + impl AmpHeadersProcessor { fn detect_api_type(path: &str, headers: &HyperHeaderMap, body: &[u8]) -> ApiType { let path_lower = path.to_lowercase(); @@ -154,6 +156,29 @@ impl AmpHeadersProcessor { } } + fn add_tool_prefix(body: &[u8]) -> Vec { + 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(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>, @@ -166,11 +191,11 @@ impl AmpHeadersProcessor { let config = proxy_mgr .get_config("amp-code") .map_err(|e| anyhow!("读取配置失败: {}", e))? - .ok_or_else(|| anyhow!("Amp Code 代理未配置"))?; + .ok_or_else(|| anyhow!("AMP Code 代理未配置"))?; let token = config .real_api_key - .ok_or_else(|| anyhow!("Amp Code Access Token 未配置"))?; + .ok_or_else(|| anyhow!("AMP Code Access Token 未配置"))?; let base_url = config .real_base_url @@ -181,7 +206,7 @@ impl AmpHeadersProcessor { None => format!("{}{}", base_url, path), }; - tracing::info!("Amp Code → ampcode.com: {}", target_url); + tracing::info!("AMP Code → ampcode.com: {}", target_url); let mut new_headers = headers.clone(); new_headers.remove(hyper::header::AUTHORIZATION); @@ -217,7 +242,7 @@ impl RequestProcessor for AmpHeadersProcessor { body: &[u8], ) -> Result { let api_type = Self::detect_api_type(path, original_headers, body); - tracing::debug!("Amp Code 路由: path={}, type={:?}", path, api_type); + 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; @@ -236,7 +261,9 @@ impl RequestProcessor for AmpHeadersProcessor { match api_type { ApiType::Claude => { let p = claude.ok_or_else(|| anyhow!("未配置 Claude Profile"))?; - tracing::info!("Amp Code → Claude: {}{}", p.base_url, llm_path); + 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, @@ -244,7 +271,7 @@ impl RequestProcessor for AmpHeadersProcessor { &llm_path, query, original_headers, - body, + &prefixed_body, ) .await?; @@ -258,6 +285,9 @@ impl RequestProcessor for AmpHeadersProcessor { 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(), @@ -276,7 +306,7 @@ impl RequestProcessor for AmpHeadersProcessor { } ApiType::Codex => { let p = codex.ok_or_else(|| anyhow!("未配置 Codex Profile"))?; - tracing::info!("Amp Code → Codex: {}{}", p.base_url, llm_path); + tracing::info!("AMP Code → Codex: {}{}", p.base_url, llm_path); let mut result = CodexHeadersProcessor .process_outgoing_request( &p.base_url, @@ -295,7 +325,7 @@ impl RequestProcessor for AmpHeadersProcessor { } ApiType::Gemini => { let p = gemini.ok_or_else(|| anyhow!("未配置 Gemini Profile"))?; - tracing::info!("Amp Code → Gemini: {}{}", p.base_url, llm_path); + tracing::info!("AMP Code → Gemini: {}{}", p.base_url, llm_path); let mut result = GeminiHeadersProcessor .process_outgoing_request( &p.base_url, diff --git a/src-tauri/src/services/proxy/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index b53baa0..51b2baa 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -358,11 +358,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*"dc_([^"]+)""#).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) }); @@ -371,8 +389,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*"dc_([^"]+)""#).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/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx b/src/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx index a36a1bd..1706401 100644 --- a/src/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx +++ b/src/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx @@ -199,10 +199,10 @@ export function ProxySettingsDialog({ session_endpoint_config_enabled: sessionEndpointEnabled, auto_start: autoStart, }; - // AMP Access Token 需要一起保存 - if (toolId === 'amp-code' && ampAccessToken) { - updates.real_api_key = ampAccessToken; - updates.real_base_url = 'https://ampcode.com'; + // AMP Access Token 需要一起保存(空值也需要保存以清除配置) + if (toolId === 'amp-code') { + updates.real_api_key = ampAccessToken || null; + updates.real_base_url = ampAccessToken ? 'https://ampcode.com' : null; } await onSave(updates); // 触发配置更新事件 diff --git a/src/pages/TransparentProxyPage/index.tsx b/src/pages/TransparentProxyPage/index.tsx index 3c0cd4f..e26e27d 100644 --- a/src/pages/TransparentProxyPage/index.tsx +++ b/src/pages/TransparentProxyPage/index.tsx @@ -18,7 +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'] }, + { id: 'amp-code', name: 'AMP Code', icon: logoMap['amp-code'] }, ]; interface TransparentProxyPageProps { From 0df28cceb596cd23200f1cb591e661d764b18673 Mon Sep 17 00:00:00 2001 From: ehgen0ng Date: Sat, 10 Jan 2026 01:33:27 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(amp-code):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E7=BD=91=E9=A1=B5=E6=90=9C=E7=B4=A2=E4=B8=8E?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=8F=90=E5=8F=96=20-=20=E3=80=90=E6=96=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=91=E5=9C=A8=20AMP=20Code=20=E4=B8=AD?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20`webSearch2`=20=E5=92=8C=20`extractWebPage?= =?UTF-8?q?Content`=20=E6=9C=AC=E5=9C=B0=E5=B7=A5=E5=85=B7=20-=20=E3=80=90?= =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD=E3=80=91=E6=B7=BB=E5=8A=A0=20Tavily?= =?UTF-8?q?=20API=20=E5=AF=86=E9=92=A5=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E7=BD=91=E9=A1=B5=E6=90=9C=E7=B4=A2=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=20-=20=E3=80=90=E6=96=B0=E5=8A=9F=E8=83=BD=E3=80=91Tavily=20?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E6=9C=AA=E9=85=8D=E7=BD=AE=E6=97=B6=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=20DuckDuckGo=20HTML=20=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E7=BD=91=E9=A1=B5=E6=90=9C=E7=B4=A2=E5=A4=87?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E6=A1=88=20-=20=E3=80=90=E6=96=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E3=80=91=E4=B8=BA=20`extractWebPageContent`=20?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E5=81=A5=E5=A3=AE=E7=9A=84=20SSRF=20?= =?UTF-8?q?=E9=98=B2=E6=8A=A4=EF=BC=8C=E9=98=B2=E6=AD=A2=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=86=85=E9=83=A8=E7=BD=91=E7=BB=9C=20-=20=E3=80=90=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E3=80=91=E7=BB=9F=E4=B8=80=E4=BB=A3=E7=A0=81=E5=BA=93?= =?UTF-8?q?=E4=B8=AD=E6=89=80=E6=9C=89=E2=80=9CAmp=E2=80=9D=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E4=B8=BA=E2=80=9CAMP=20Code=E2=80=9D=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/proxy_commands.rs | 18 +- src-tauri/src/models/proxy_config.rs | 8 +- src-tauri/src/services/amp_native_config.rs | 14 +- .../migrations/proxy_config_split.rs | 4 + src-tauri/src/services/mod.rs | 2 +- .../services/proxy/headers/amp_processor.rs | 475 ++++++++++++++++++ .../src/services/proxy/proxy_instance.rs | 20 + src/lib/tauri-commands/types.ts | 1 + .../components/ProxySettingsDialog.tsx | 31 ++ 11 files changed, 556 insertions(+), 20 deletions(-) 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/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index d855532..1a79aa7 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -225,16 +225,16 @@ async fn try_start_proxy_internal( "已切换到代理 Profile" ); } else { - // amp-code:直接修改 Amp 原生配置文件 + // 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 配置 + // 1. 完整备份当前 AMP Code 配置 let backup = amp_native_config::backup_amp_config() - .map_err(|e| format!("备份 Amp 配置失败: {}", e))?; + .map_err(|e| format!("备份 AMP Code 配置失败: {}", e))?; tool_config.original_amp_settings = backup.settings_json; tool_config.original_amp_secrets = backup.secrets_json; @@ -246,14 +246,14 @@ async fn try_start_proxy_internal( // 3. 应用代理配置 amp_native_config::apply_proxy_config(&proxy_url, local_key) - .map_err(|e| format!("应用 Amp 代理配置失败: {}", e))?; + .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 代理配置" + "已应用 AMP Code 代理配置" ); } @@ -350,14 +350,14 @@ pub async fn stop_tool_proxy( // ========== 还原逻辑 ========== if tool_id == "amp-code" { - // amp-code:完整还原 Amp 原生配置文件 + // amp-code:完整还原 AMP Code 原生配置文件 let backup = amp_native_config::AmpConfigBackup { settings_json: tool_config.original_amp_settings.take(), secrets_json: tool_config.original_amp_secrets.take(), }; amp_native_config::restore_amp_config(&backup) - .map_err(|e| format!("还原 Amp 配置失败: {}", e))?; + .map_err(|e| format!("还原 AMP Code 配置失败: {}", e))?; // 清空备份字段 proxy_config_mgr @@ -368,10 +368,10 @@ pub async fn stop_tool_proxy( tool_id = %tool_id, had_settings = backup.settings_json.is_some(), had_secrets = backup.secrets_json.is_some(), - "已完整还原 Amp 配置" + "已完整还原 AMP Code 配置" ); - return Ok(format!("✅ {tool_id} 透明代理已停止\n已完整还原 Amp 配置")); + return Ok(format!("✅ {tool_id} 透明代理已停止\n已完整还原 AMP Code 配置")); } // 其他工具:Profile 还原逻辑 diff --git a/src-tauri/src/models/proxy_config.rs b/src-tauri/src/models/proxy_config.rs index 170596c..15578c7 100644 --- a/src-tauri/src/models/proxy_config.rs +++ b/src-tauri/src/models/proxy_config.rs @@ -25,12 +25,15 @@ pub struct ToolProxyConfig { /// 启动代理前激活的 Profile 名称(用于关闭时还原) #[serde(default, skip_serializing_if = "Option::is_none")] pub original_active_profile: Option, - /// Amp 原始 settings.json 完整内容(用于关闭时还原) + /// AMP Code 原始 settings.json 完整内容(用于关闭时还原) #[serde(default, skip_serializing_if = "Option::is_none")] pub original_amp_settings: Option, - /// Amp 原始 secrets.json 完整内容(用于关闭时还原) + /// 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 { @@ -49,6 +52,7 @@ impl ToolProxyConfig { original_active_profile: None, original_amp_settings: None, original_amp_secrets: None, + tavily_api_key: None, } } diff --git a/src-tauri/src/services/amp_native_config.rs b/src-tauri/src/services/amp_native_config.rs index 9248ebb..d806984 100644 --- a/src-tauri/src/services/amp_native_config.rs +++ b/src-tauri/src/services/amp_native_config.rs @@ -9,21 +9,21 @@ use serde_json::Value; use std::fs; use std::path::PathBuf; -/// Amp 配置备份信息(完整文件内容) +/// AMP Code 配置备份信息(完整文件内容) #[derive(Debug, Clone)] pub struct AmpConfigBackup { pub settings_json: Option, pub secrets_json: Option, } -/// 获取 Amp settings.json 路径 +/// 获取 AMP Code settings.json 路径 /// macOS/Linux: ~/.config/amp/settings.json fn amp_settings_path() -> Result { let home = dirs::home_dir().ok_or_else(|| anyhow!("无法获取 home 目录"))?; Ok(home.join(".config").join("amp").join("settings.json")) } -/// 获取 Amp secrets.json 路径 +/// 获取 AMP Code secrets.json 路径 /// macOS/Linux: ~/.local/share/amp/secrets.json fn amp_secrets_path() -> Result { let home = dirs::home_dir().ok_or_else(|| anyhow!("无法获取 home 目录"))?; @@ -60,7 +60,7 @@ fn delete_file_if_exists(path: &PathBuf) -> Result<()> { Ok(()) } -/// 读取当前 Amp 配置(完整备份) +/// 读取当前 AMP Code 配置(完整备份) pub fn backup_amp_config() -> Result { let settings_path = amp_settings_path()?; let secrets_path = amp_secrets_path()?; @@ -111,13 +111,13 @@ pub fn apply_proxy_config(proxy_url: &str, local_api_key: &str) -> Result<()> { tracing::info!( proxy_url = %proxy_url, - "已应用 Amp 代理配置" + "已应用 AMP Code 代理配置" ); Ok(()) } -/// 完整还原 Amp 配置到原始状态 +/// 完整还原 AMP Code 配置到原始状态 pub fn restore_amp_config(backup: &AmpConfigBackup) -> Result<()> { let settings_path = amp_settings_path()?; let secrets_path = amp_secrets_path()?; @@ -140,7 +140,7 @@ pub fn restore_amp_config(backup: &AmpConfigBackup) -> Result<()> { tracing::debug!("已删除 secrets.json(原本不存在)"); } - tracing::info!("已完整还原 Amp 配置"); + 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 7d82974..ce7232b 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 @@ -290,5 +290,9 @@ fn parse_old_config(value: &Value) -> Result { .get("original_amp_secrets") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + 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 528261a..ec3607e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,7 +11,7 @@ // - provider_manager: 供应商配置管理 // - new_api: NEW API 客户端服务 -pub mod amp_native_config; // Amp 原生配置管理 +pub mod amp_native_config; // AMP Code 原生配置管理 pub mod balance; pub mod config; pub mod dashboard_manager; // 仪表板状态管理 diff --git a/src-tauri/src/services/proxy/headers/amp_processor.rs b/src-tauri/src/services/proxy/headers/amp_processor.rs index ecd6d81..e3cbea2 100644 --- a/src-tauri/src/services/proxy/headers/amp_processor.rs +++ b/src-tauri/src/services/proxy/headers/amp_processor.rs @@ -1,6 +1,7 @@ // 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/...) @@ -14,7 +15,27 @@ use super::{ 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; @@ -165,6 +186,12 @@ impl AmpHeadersProcessor { 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()) { @@ -224,6 +251,441 @@ impl AmpHeadersProcessor { 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] @@ -241,6 +703,19 @@ impl RequestProcessor for AmpHeadersProcessor { 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); diff --git a/src-tauri/src/services/proxy/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index 51b2baa..d4ab300 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -307,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)); diff --git a/src/lib/tauri-commands/types.ts b/src/lib/tauri-commands/types.ts index 19f4a54..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 { diff --git a/src/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx b/src/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx index 1706401..c1e973a 100644 --- a/src/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx +++ b/src/pages/TransparentProxyPage/components/ProxySettingsDialog.tsx @@ -89,6 +89,8 @@ export function ProxySettingsDialog({ const [ampAccessToken, setAmpAccessToken] = useState(config?.real_api_key ?? ''); const [ampUserInfo, setAmpUserInfo] = useState(null); const [validatingToken, setValidatingToken] = useState(false); + // Tavily API Key 状态(仅 amp-code,用于本地搜索) + const [tavilyApiKey, setTavilyApiKey] = useState(config?.tavily_api_key ?? ''); // 打开弹窗时重置表单状态 useEffect(() => { @@ -102,6 +104,8 @@ export function ProxySettingsDialog({ // AMP Access Token setAmpAccessToken(config.real_api_key ?? ''); setAmpUserInfo(null); + // Tavily API Key + setTavilyApiKey(config.tavily_api_key ?? ''); // 如果有保存的 token,自动获取用户信息 if (toolId === 'amp-code' && config.real_api_key) { @@ -203,6 +207,7 @@ export function ProxySettingsDialog({ if (toolId === 'amp-code') { updates.real_api_key = ampAccessToken || null; updates.real_base_url = ampAccessToken ? 'https://ampcode.com' : null; + updates.tavily_api_key = tavilyApiKey || null; } await onSave(updates); // 触发配置更新事件 @@ -354,6 +359,32 @@ export function ProxySettingsDialog({
)} + + {/* Tavily API Key(用于本地搜索) */} +
+ + setTavilyApiKey(e.target.value)} + disabled={isRunning} + className="font-mono" + /> +

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

+
)} From e352ffbb5b7c890929cddc99151424614f197ca4 Mon Sep 17 00:00:00 2001 From: ehgen0ng Date: Sun, 11 Jan 2026 01:15:46 +0800 Subject: [PATCH 6/7] =?UTF-8?q?style(proxy):=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=9C=8D=E5=8A=A1=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整多行表达式和函数签名的换行,提升代码可读性 - 统一代码缩进,保持项目代码风格一致性 --- src-tauri/src/commands/proxy_commands.rs | 4 ++- .../services/proxy/headers/amp_processor.rs | 36 ++++++++++++------- .../src/services/proxy/proxy_instance.rs | 4 +-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/commands/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index 1a79aa7..55dfbfd 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -371,7 +371,9 @@ pub async fn stop_tool_proxy( "已完整还原 AMP Code 配置" ); - return Ok(format!("✅ {tool_id} 透明代理已停止\n已完整还原 AMP Code 配置")); + return Ok(format!( + "✅ {tool_id} 透明代理已停止\n已完整还原 AMP Code 配置" + )); } // 其他工具:Profile 还原逻辑 diff --git a/src-tauri/src/services/proxy/headers/amp_processor.rs b/src-tauri/src/services/proxy/headers/amp_processor.rs index e3cbea2..36ad6de 100644 --- a/src-tauri/src/services/proxy/headers/amp_processor.rs +++ b/src-tauri/src/services/proxy/headers/amp_processor.rs @@ -177,7 +177,7 @@ impl AmpHeadersProcessor { } } - fn add_tool_prefix(body: &[u8]) -> Vec { + fn add_tool_prefix(body: &[u8]) -> Vec { if body.is_empty() { return body.to_vec(); } @@ -205,7 +205,7 @@ impl AmpHeadersProcessor { serde_json::to_vec(&json).unwrap_or_else(|_| body.to_vec()) } - + async fn forward_to_amp( path: &str, query: Option<&str>, @@ -283,10 +283,13 @@ impl AmpHeadersProcessor { } /// 处理网页搜索请求 - async fn handle_web_search(body: &[u8], tavily_api_key: Option<&str>) -> Result { + 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 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(""); @@ -316,12 +319,18 @@ impl AmpHeadersProcessor { Ok(r) => (r, "tavily"), Err(e) => { tracing::warn!("Tavily 搜索失败,降级 DuckDuckGo: {}", e); - (Self::search_duckduckgo(&queries, max_results).await?, "local-duckduckgo") + ( + 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") + ( + Self::search_duckduckgo(&queries, max_results).await?, + "local-duckduckgo", + ) }; let response = json!({ @@ -549,8 +558,8 @@ impl AmpHeadersProcessor { /// 处理网页内容提取请求(增强 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 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 参数"))?; @@ -574,7 +583,7 @@ impl AmpHeadersProcessor { // 流式读取并限制大小(防止 chunked 编码绕过 Content-Length 检查) let html = Self::read_response_with_limit(resp, MAX_RESPONSE_SIZE).await?; - + // 返回原始 HTML(与 AMP-Manager 行为一致) let response = json!({ "ok": true, @@ -638,7 +647,8 @@ impl AmpHeadersProcessor { || 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) + || ipv4.octets()[0] == 100 && (ipv4.octets()[1] & 0xc0) == 64 + // 100.64.0.0/10 (CGN) } IpAddr::V6(ipv6) => { ipv6.is_loopback() // ::1 @@ -706,13 +716,13 @@ impl RequestProcessor for AmpHeadersProcessor { // 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; } diff --git a/src-tauri/src/services/proxy/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index d4ab300..2989ed1 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -317,11 +317,11 @@ async fn handle_request_inner( 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()); From f6c84ec9f02b4cac8656a431ecdab9bca7fc6989 Mon Sep 17 00:00:00 2001 From: ehgen0ng Date: Sun, 11 Jan 2026 02:44:00 +0800 Subject: [PATCH 7/7] =?UTF-8?q?refactor(amp-config):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20amp=20=E9=85=8D=E7=BD=AE=E7=9A=84=E8=AF=AD=E4=B9=89=E5=8C=96?= =?UTF-8?q?=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 AMP Code 配置备份从原始 JSON 字符串切换为 `serde_json::Value` - 实现配置的语义化备份,增强处理健壮性并避免误删用户文件 - 更新 `amp_native_config` 服务,直接操作 JSON 对象而非字符串 - 引入 `DataManager` 统一文件读写,提升跨平台兼容性和错误处理 - 优化 home 目录获取逻辑,确保不同系统下路径识别正确 - 修复旧版配置迁移中 `original_amp_settings` 和 `original_amp_secrets` 的解析 --- src-tauri/src/commands/proxy_commands.rs | 12 +- src-tauri/src/models/proxy_config.rs | 9 +- src-tauri/src/services/amp_native_config.rs | 159 ++++++++++-------- .../migrations/proxy_config_split.rs | 10 +- .../services/proxy/headers/amp_processor.rs | 4 +- .../src/services/proxy/proxy_instance.rs | 4 +- 6 files changed, 105 insertions(+), 93 deletions(-) diff --git a/src-tauri/src/commands/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index 55dfbfd..790cc5f 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -236,8 +236,8 @@ async fn try_start_proxy_internal( let backup = amp_native_config::backup_amp_config() .map_err(|e| format!("备份 AMP Code 配置失败: {}", e))?; - tool_config.original_amp_settings = backup.settings_json; - tool_config.original_amp_secrets = backup.secrets_json; + tool_config.original_amp_settings = backup.settings; + tool_config.original_amp_secrets = backup.secrets; // 2. 保存备份到 proxy.json proxy_config_mgr @@ -352,8 +352,8 @@ pub async fn stop_tool_proxy( if tool_id == "amp-code" { // amp-code:完整还原 AMP Code 原生配置文件 let backup = amp_native_config::AmpConfigBackup { - settings_json: tool_config.original_amp_settings.take(), - secrets_json: tool_config.original_amp_secrets.take(), + settings: tool_config.original_amp_settings.take(), + secrets: tool_config.original_amp_secrets.take(), }; amp_native_config::restore_amp_config(&backup) @@ -366,8 +366,8 @@ pub async fn stop_tool_proxy( tracing::info!( tool_id = %tool_id, - had_settings = backup.settings_json.is_some(), - had_secrets = backup.secrets_json.is_some(), + had_settings = backup.settings.is_some(), + had_secrets = backup.secrets.is_some(), "已完整还原 AMP Code 配置" ); diff --git a/src-tauri/src/models/proxy_config.rs b/src-tauri/src/models/proxy_config.rs index 15578c7..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,12 +26,12 @@ pub struct ToolProxyConfig { /// 启动代理前激活的 Profile 名称(用于关闭时还原) #[serde(default, skip_serializing_if = "Option::is_none")] pub original_active_profile: Option, - /// AMP Code 原始 settings.json 完整内容(用于关闭时还原) + /// AMP Code 原始 settings.json 完整内容(用于关闭时还原,语义备份) #[serde(default, skip_serializing_if = "Option::is_none")] - pub original_amp_settings: Option, - /// AMP Code 原始 secrets.json 完整内容(用于关闭时还原) + pub original_amp_settings: Option, + /// AMP Code 原始 secrets.json 完整内容(用于关闭时还原,语义备份) #[serde(default, skip_serializing_if = "Option::is_none")] - pub original_amp_secrets: Option, + pub original_amp_secrets: Option, /// Tavily API Key(用于本地搜索,可选,无则降级 DuckDuckGo) #[serde(default, skip_serializing_if = "Option::is_none")] pub tavily_api_key: Option, diff --git a/src-tauri/src/services/amp_native_config.rs b/src-tauri/src/services/amp_native_config.rs index d806984..3b7f102 100644 --- a/src-tauri/src/services/amp_native_config.rs +++ b/src-tauri/src/services/amp_native_config.rs @@ -3,111 +3,125 @@ //! 配置文件位置: //! - ~/.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::fs; use std::path::PathBuf; -/// AMP Code 配置备份信息(完整文件内容) +/// AMP Code 配置备份信息(语义备份) #[derive(Debug, Clone)] pub struct AmpConfigBackup { - pub settings_json: Option, - pub secrets_json: Option, + 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 路径 -/// macOS/Linux: ~/.config/amp/settings.json +/// 所有平台: ~/.config/amp/settings.json fn amp_settings_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("无法获取 home 目录"))?; - Ok(home.join(".config").join("amp").join("settings.json")) + Ok(home_dir()? + .join(".config") + .join("amp") + .join("settings.json")) } /// 获取 AMP Code secrets.json 路径 -/// macOS/Linux: ~/.local/share/amp/secrets.json +/// 所有平台: ~/.local/share/amp/secrets.json fn amp_secrets_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("无法获取 home 目录"))?; - Ok(home + Ok(home_dir()? .join(".local") .join("share") .join("amp") .join("secrets.json")) } -/// 读取文件内容,不存在则返回 None -fn read_file_content(path: &PathBuf) -> Option { - if path.exists() { - fs::read_to_string(path).ok() - } else { - None - } -} - -/// 写入文件,自动创建目录 -fn write_file_content(path: &PathBuf, content: &str) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, content)?; - Ok(()) -} - -/// 删除文件(如果存在) -fn delete_file_if_exists(path: &PathBuf) -> Result<()> { - if path.exists() { - fs::remove_file(path)?; - } - Ok(()) -} - -/// 读取当前 AMP Code 配置(完整备份) +/// 读取当前 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()?; - Ok(AmpConfigBackup { - settings_json: read_file_content(&settings_path), - secrets_json: read_file_content(&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 settings_content = read_file_content(&settings_path); - let secrets_content = read_file_content(&secrets_path); - - // 更新 settings.json - let mut settings: Value = settings_content - .as_ref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_else(|| serde_json::json!({})); + // 读取现有配置或创建新的(文件存在但格式错误时返回错误) + 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 格式错误"))?; + .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)?; - let new_settings = serde_json::to_string_pretty(&settings)?; - write_file_content(&settings_path, &new_settings)?; - - // 更新 secrets.json - let mut secrets: Value = secrets_content - .as_ref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_else(|| serde_json::json!({})); + // 读取 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 格式错误"))?; + .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())); - - let new_secrets = serde_json::to_string_pretty(&secrets)?; - write_file_content(&secrets_path, &new_secrets)?; + jm.write(&secrets_path, &secrets)?; tracing::info!( proxy_url = %proxy_url, @@ -119,24 +133,27 @@ pub fn apply_proxy_config(proxy_url: &str, local_api_key: &str) -> Result<()> { /// 完整还原 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(content) = &backup.settings_json { - write_file_content(&settings_path, content)?; + if let Some(value) = &backup.settings { + jm.write(&settings_path, value)?; tracing::debug!("已还原 settings.json"); - } else { - delete_file_if_exists(&settings_path)?; + } else if settings_path.exists() { + jm.delete(&settings_path, None)?; tracing::debug!("已删除 settings.json(原本不存在)"); } // 还原 secrets.json - if let Some(content) = &backup.secrets_json { - write_file_content(&secrets_path, content)?; + if let Some(value) = &backup.secrets { + jm.write(&secrets_path, value)?; tracing::debug!("已还原 secrets.json"); - } else { - delete_file_if_exists(&secrets_path)?; + } else if secrets_path.exists() { + jm.delete(&secrets_path, None)?; tracing::debug!("已删除 secrets.json(原本不存在)"); } 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 ce7232b..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,14 +282,8 @@ 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") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - original_amp_secrets: obj - .get("original_amp_secrets") - .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()) diff --git a/src-tauri/src/services/proxy/headers/amp_processor.rs b/src-tauri/src/services/proxy/headers/amp_processor.rs index 36ad6de..13061df 100644 --- a/src-tauri/src/services/proxy/headers/amp_processor.rs +++ b/src-tauri/src/services/proxy/headers/amp_processor.rs @@ -48,8 +48,6 @@ enum ApiType { Gemini, } -const TOOL_PREFIX: &str = "dc_"; - impl AmpHeadersProcessor { fn detect_api_type(path: &str, headers: &HyperHeaderMap, body: &[u8]) -> ApiType { let path_lower = path.to_lowercase(); @@ -178,6 +176,8 @@ impl AmpHeadersProcessor { } fn add_tool_prefix(body: &[u8]) -> Vec { + const TOOL_PREFIX: &str = "mcp_"; + if body.is_empty() { return body.to_vec(); } diff --git a/src-tauri/src/services/proxy/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index 2989ed1..84b6de2 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -384,7 +384,7 @@ async fn handle_request_inner( // amp-code 需要移除工具名前缀 let is_amp_code = tool_id == "amp-code"; - let prefix_regex = Regex::new(r#""name"\s*:\s*"dc_([^"]+)""#).ok(); + let prefix_regex = Regex::new(r#""name"\s*:\s*"mcp_([^"]+)""#).ok(); let mapped_stream = stream.map(move |result| { result @@ -412,7 +412,7 @@ async fn handle_request_inner( 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*"dc_([^"]+)""#).unwrap(); + 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 {