diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 6e7a0dd30..7daf67f3a 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -226,6 +226,20 @@ pub fn is_official_provider(provider: &Provider) -> bool { provider.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID } +/// 当前供应商是不是模型映射卡:Claude Desktop 的请求要经本地路由服务转发,服务得一直在跑。 +pub fn current_provider_uses_proxy(db: &Database) -> bool { + if current_platform_paths().is_err() { + return false; + } + crate::mode::current::direct_provider(db, &crate::app_config::AppType::ClaudeDesktop) + .ok() + .flatten() + .is_some_and(|provider| { + !is_official_provider(&provider) + && matches!(provider_mode(&provider), ClaudeDesktopMode::Proxy) + }) +} + pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode { provider .meta diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs index 265fc5d3a..1a1cb74f0 100644 --- a/src-tauri/src/commands/profile.rs +++ b/src-tauri/src/commands/profile.rs @@ -1,8 +1,9 @@ //! 项目 Profile 管理命令 use serde::Serialize; -use tauri::{Emitter, State}; +use tauri::{Emitter, Manager, State}; +use crate::app_config::AppType; use crate::database::Profile; use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService}; use crate::store::AppState; @@ -95,6 +96,14 @@ pub fn emit_profile_apply_events( log::error!("发射 profile-applied 事件失败: {e}"); } crate::tray::refresh_tray_menu(app); + if scope.apps().contains(&AppType::ClaudeDesktop) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(state) = app.try_state::() { + crate::mode::controller::ensure_desktop_mapping_service(state.inner()).await; + } + }); + } } #[tauri::command] diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index dc1b83142..f36834265 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -165,14 +165,22 @@ pub async fn switch_provider( id: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - tauri::async_runtime::spawn_blocking(move || { - let state = app_handle + let is_desktop = matches!(app_type, AppType::ClaudeDesktop); + let handle = app_handle.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let state = handle .try_state::() .ok_or_else(|| "应用状态不可用".to_string())?; switch_provider_internal(state.inner(), app_type, &id).map_err(|e| e.to_string()) }) .await - .map_err(|e| format!("供应商切换任务执行失败: {e}"))? + .map_err(|e| format!("供应商切换任务执行失败: {e}"))??; + if is_desktop { + if let Some(state) = app_handle.try_state::() { + crate::mode::controller::ensure_desktop_mapping_service(state.inner()).await; + } + } + Ok(result) } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { diff --git a/src-tauri/src/mode/controller.rs b/src-tauri/src/mode/controller.rs index 096d0044b..5a78bf468 100644 --- a/src-tauri/src/mode/controller.rs +++ b/src-tauri/src/mode/controller.rs @@ -694,9 +694,11 @@ fn exit_locked(state: &AppState, app: &AppType, keep_mode: bool) -> Result<(), S ) } -/// 没有应用在代理模式了就停掉代理服务(Claude Desktop 的模型映射另外自己启停)。 +/// 没有应用在代理模式、Claude Desktop 也没在用模型映射时,停掉代理服务。 async fn stop_server_if_unused(state: &AppState) { - if current::proxy_flags(PROXY_APPS).contains(&true) { + if current::proxy_flags(PROXY_APPS).contains(&true) + || crate::claude_desktop_config::current_provider_uses_proxy(&state.db) + { return; } if state.proxy_service.is_running().await { @@ -706,6 +708,19 @@ async fn stop_server_if_unused(state: &AppState) { } } +/// Claude Desktop 的当前供应商是模型映射卡、代理服务却没在跑时把它拉起来:启动、切到映射卡、 +/// 应用项目之后各调一次。拉不起来只记日志,Desktop 页的状态横幅会提示服务没在运行。 +pub async fn ensure_desktop_mapping_service(state: &AppState) { + if !crate::claude_desktop_config::current_provider_uses_proxy(&state.db) + || state.proxy_service.is_running().await + { + return; + } + if let Err(error) = state.proxy_service.start().await { + log::error!("Claude Desktop 正在用模型映射,启动代理服务失败: {error}"); + } +} + /// 「关闭本地路由」:全部退回直连,再停掉代理服务。 pub async fn exit_all(state: &AppState) -> Result<(), String> { let mut errors = Vec::new(); @@ -1253,6 +1268,7 @@ pub async fn startup(state: &AppState) { } // 接上失败退回直连的应用可能已经把代理拉起来了。 stop_server_if_unused(state).await; + ensure_desktop_mapping_service(state).await; // 记一次新启动的 Codex 会读到的目录:兜住启动时补完的操作和 CC Switch 没开时的外部修改。 codex_client_catalog::observe(&DeviceStore::for_device()); } @@ -5005,6 +5021,54 @@ model_provider = "c" assert_eq!(stack_state().members, vec!["kimi", "a"], "the list stays"); } + #[tokio::test] + #[serial] + async fn claude_desktop_model_mapping_keeps_the_server_running() { + let _home = Home::new(); + seed_settings(USER_SETTINGS); + let state = state_with( + AppType::Claude, + &[claude("a", "https://a.example", json!({}))], + "a", + ) + .await; + let mut mapping = Provider::with_id( + "map".to_string(), + "Map".to_string(), + json!({ "env": { "ANTHROPIC_BASE_URL": "https://map.example" } }), + None, + ); + mapping.meta = Some(crate::provider::ProviderMeta { + claude_desktop_mode: Some(crate::provider::ClaudeDesktopMode::Proxy), + ..Default::default() + }); + let desktop = AppType::ClaudeDesktop; + state + .db + .save_provider(desktop.as_str(), &mapping) + .expect("save mapping provider"); + crate::settings::set_current_provider(&desktop, Some("map")).expect("desktop current"); + + // Claude Code 退出路由时,Desktop 还在用模型映射,服务不能跟着停。 + enter(&state, &AppType::Claude, false).await.expect("enter"); + exit(&state, &AppType::Claude).await.expect("exit"); + assert!(state.proxy_service.is_running().await); + + // 服务被手动停掉后,下一次检查(启动、切换、应用项目)会把它拉起来。 + state.proxy_service.stop().await.expect("stop"); + ensure_desktop_mapping_service(&state).await; + assert!(state.proxy_service.is_running().await); + + // 换成直连卡后不再算在用。 + crate::settings::set_current_provider(&desktop, None).expect("clear desktop current"); + state + .db + .delete_provider(desktop.as_str(), "map") + .expect("drop mapping"); + stop_server_if_unused(&state).await; + assert!(!state.proxy_service.is_running().await); + } + #[tokio::test] #[serial] async fn changing_the_main_page_switch_with_nothing_to_exit_keeps_the_server() { diff --git a/src/App.tsx b/src/App.tsx index 2e3d5366f..3ea965975 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -341,7 +341,6 @@ function App() { setAsDefaultModel, } = useProviderActions( activeApp, - currentAppUsesProxy && isProxyRunning, isProxyRunning && isCurrentAppTakeoverActive, ); const handleEnablePiProvider = async (provider: Provider) => { diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 7a263583c..cba5471da 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -44,7 +44,6 @@ import { isOAuthProviderType } from "@/config/constants"; */ export function useProviderActions( activeApp: AppId, - isProxyRunning?: boolean, isProxyTakeover?: boolean, ) { const { t } = useTranslation(); @@ -199,13 +198,10 @@ export function useProviderActions( ), ))); - // Claude Desktop 的路由开关就是代理进程本身;其余应用还必须开启当前 - // 应用的 takeover。不能只看全局进程,否则其它应用已接管时会漏判;也 - // 不能只看 takeover,否则 Desktop 在路由已运行时会持续误报。 + // Claude Desktop 切到模型映射卡时,后端会自动拉起路由服务,不用提醒; + // 其余应用必须开启当前应用的 takeover(只看全局进程会漏判别的应用已接管的情况)。 const routingReady = - activeApp === "claude-desktop" - ? isProxyRunning === true - : isProxyTakeover === true; + activeApp === "claude-desktop" || isProxyTakeover === true; // Determine why this provider requires the proxy. let proxyRequiredReason: string | null = null; @@ -247,13 +243,6 @@ export function useProviderActions( defaultValue: "使用 Anthropic Messages 接口格式", }, ); - } else if ( - activeApp === "claude-desktop" && - provider.meta?.claudeDesktopMode === "proxy" - ) { - proxyRequiredReason = t("notifications.proxyReasonClaudeDesktop", { - defaultValue: "使用 Claude Desktop 本地路由模式", - }); } else if ( provider.meta?.isFullUrl && (activeApp === "claude" || @@ -373,14 +362,7 @@ export function useProviderActions( // 错误提示由 mutation 处理 } }, - [ - switchProviderMutation, - syncClaudePlugin, - activeApp, - isProxyRunning, - isProxyTakeover, - t, - ], + [switchProviderMutation, syncClaudePlugin, activeApp, isProxyTakeover, t], ); // 删除供应商 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 68f0726ef..a0b1e9349 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -299,8 +299,7 @@ "codexAuthCleanupFailed": "Switched successfully, but auth.json could not be removed — the official login credentials are still on disk; delete auth.json from the Codex config directory to remove them", "windowControlFailed": "Window control failed: {{error}}", "officialBlockedByProxy": "Cannot switch to official provider while local routing is active. Using routing with official APIs may cause account bans.", - "proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing.", - "proxyReasonClaudeDesktop": "Using Claude Desktop local routing mode" + "proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing." }, "confirm": { "deleteProvider": "Delete Provider", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a4737e6cd..8689d0c8f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -299,8 +299,7 @@ "codexAuthCleanupFailed": "切り替えは成功しましたが、auth.json を削除できませんでした。公式ログイン資格情報がディスクに残っています。完全に削除するには Codex 設定ディレクトリの auth.json を手動で削除してください", "windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}", "officialBlockedByProxy": "ローカルルーティングモード中は公式プロバイダーに切り替えできません。ルーティング経由で公式 API にアクセスするとアカウントが停止される可能性があります。", - "proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。", - "proxyReasonClaudeDesktop": "Claude Desktop ローカルルーティングモードを使用中" + "proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。" }, "confirm": { "deleteProvider": "プロバイダーを削除", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 3033ce267..9c563b64f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -299,8 +299,7 @@ "codexAuthCleanupFailed": "切換成功,但未能刪除 auth.json,官方登入憑證仍留在磁碟上;如需徹底移除請手動刪除 Codex 設定目錄中的 auth.json", "windowControlFailed": "視窗控制失敗:{{error}}", "officialBlockedByProxy": "本地路由模式下不能切換至官方供應商,使用路由存取官方 API 可能導致帳號被封鎖", - "proxyOfficialWarning": "目前供應商 {{name}} 是官方供應商,建議切換至第三方供應商後再使用本地路由", - "proxyReasonClaudeDesktop": "使用 Claude Desktop 本機路由模式" + "proxyOfficialWarning": "目前供應商 {{name}} 是官方供應商,建議切換至第三方供應商後再使用本地路由" }, "confirm": { "deleteProvider": "刪除供應商", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cfeecec38..f69c23143 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -299,8 +299,7 @@ "codexAuthCleanupFailed": "切换成功,但未能删除 auth.json,官方登录凭据仍留在磁盘上;如需彻底移除请手动删除 Codex 配置目录中的 auth.json", "windowControlFailed": "窗口控制失败:{{error}}", "officialBlockedByProxy": "本地路由模式下不能切换到官方供应商,使用路由访问官方 API 可能导致账号被封禁", - "proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由", - "proxyReasonClaudeDesktop": "使用 Claude Desktop 本地路由模式" + "proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由" }, "confirm": { "deleteProvider": "删除供应商", diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index 7cf8be00e..8bbafebbe 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -233,7 +233,7 @@ describe("useProviderActions", () => { }, }); - const { result } = renderHook(() => useProviderActions("claude", false), { + const { result } = renderHook(() => useProviderActions("claude"), { wrapper, }); @@ -255,7 +255,7 @@ describe("useProviderActions", () => { }, }); - const { result } = renderHook(() => useProviderActions("codex", false), { + const { result } = renderHook(() => useProviderActions("codex"), { wrapper, }); @@ -275,7 +275,7 @@ describe("useProviderActions", () => { meta: { apiFormat: "anthropic" }, }); - const { result } = renderHook(() => useProviderActions("codex", false), { + const { result } = renderHook(() => useProviderActions("codex"), { wrapper, }); @@ -310,10 +310,9 @@ describe("useProviderActions", () => { }), ]; - const { result } = renderHook( - () => useProviderActions("grokbuild", false), - { wrapper }, - ); + const { result } = renderHook(() => useProviderActions("grokbuild"), { + wrapper, + }); for (const provider of providers) { await act(async () => { @@ -336,10 +335,9 @@ describe("useProviderActions", () => { }, }); - const { result } = renderHook( - () => useProviderActions("codex", true, false), - { wrapper }, - ); + const { result } = renderHook(() => useProviderActions("codex", false), { + wrapper, + }); await act(async () => { await result.current.switchProvider(provider); @@ -362,8 +360,28 @@ describe("useProviderActions", () => { }, }); + const { result } = renderHook(() => useProviderActions("codex", true), { + wrapper, + }); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(toastWarningMock).not.toHaveBeenCalled(); + expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); + }); + + it("does not warn for Claude Desktop model mapping, which starts routing itself", async () => { + switchProviderMutateAsync.mockResolvedValue(undefined); + const { wrapper } = createWrapper(); + const provider = createProvider({ + category: "custom", + meta: { claudeDesktopMode: "proxy" }, + }); + const { result } = renderHook( - () => useProviderActions("codex", true, true), + () => useProviderActions("claude-desktop", false), { wrapper }, ); @@ -375,36 +393,6 @@ describe("useProviderActions", () => { expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); }); - it("uses proxy process readiness for Claude Desktop routing", async () => { - switchProviderMutateAsync.mockResolvedValue(undefined); - const { wrapper } = createWrapper(); - const provider = createProvider({ - category: "custom", - meta: { claudeDesktopMode: "proxy" }, - }); - - const { result, rerender } = renderHook( - ({ isProxyRunning }) => - useProviderActions("claude-desktop", isProxyRunning, false), - { initialProps: { isProxyRunning: true }, wrapper }, - ); - - await act(async () => { - await result.current.switchProvider(provider); - }); - expect(toastWarningMock).not.toHaveBeenCalled(); - - rerender({ isProxyRunning: false }); - await act(async () => { - await result.current.switchProvider(provider); - }); - - expect(toastWarningMock).toHaveBeenCalledTimes(1); - expect(toastWarningMock).toHaveBeenCalledWith( - expect.stringContaining("Claude Desktop 本地路由模式"), - ); - }); - it("allows the native Codex official provider during takeover", async () => { switchProviderMutateAsync.mockResolvedValueOnce(undefined); const { wrapper } = createWrapper(); @@ -413,10 +401,9 @@ describe("useProviderActions", () => { category: "official", }); - const { result } = renderHook( - () => useProviderActions("codex", true, true), - { wrapper }, - ); + const { result } = renderHook(() => useProviderActions("codex", true), { + wrapper, + }); await act(async () => { await result.current.switchProvider(provider); @@ -433,10 +420,9 @@ describe("useProviderActions", () => { category: "official", }); - const { result } = renderHook( - () => useProviderActions("claude", true, true), - { wrapper }, - ); + const { result } = renderHook(() => useProviderActions("claude", true), { + wrapper, + }); await act(async () => { await result.current.switchProvider(provider); @@ -462,10 +448,9 @@ describe("useProviderActions", () => { }, }, }); - const { result } = renderHook( - () => useProviderActions("codex", true, true), - { wrapper }, - ); + const { result } = renderHook(() => useProviderActions("codex", true), { + wrapper, + }); await act(async () => { await result.current.switchProvider(provider);