From a514f279372ca83636d873dd31ea886837e11fef Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 11 Apr 2026 11:43:49 +0800 Subject: [PATCH 01/28] feat: block official provider switching during proxy takeover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent users from switching to official providers (Anthropic/OpenAI/Google) when proxy takeover is active, as using a proxy with official APIs may cause account bans. Defense-in-depth across 4 layers: - Backend: ProviderService::switch(), hot_switch_provider(), switch_proxy_provider command - Frontend: useProviderActions soft guard with error toast - UI: ProviderActions button disabled with ShieldAlert icon - Tray menu: official provider items disabled with ⛔ indicator Also warns when enabling proxy takeover while current provider is official. --- src-tauri/src/commands/proxy.rs | 13 ++++++++ src-tauri/src/services/provider/mod.rs | 10 ++++++ src-tauri/src/services/proxy.rs | 29 ++++++++++++++++++ src-tauri/src/tray.rs | 24 +++++++++++++-- src/App.tsx | 32 +++++++++++++++++++- src/components/providers/ProviderActions.tsx | 13 ++++++++ src/components/providers/ProviderCard.tsx | 3 ++ src/hooks/useProviderActions.ts | 27 +++++++++++++++-- src/i18n/locales/en.json | 5 ++- src/i18n/locales/ja.json | 5 ++- src/i18n/locales/zh.json | 5 ++- 11 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index c0daa601c..fbab52858 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -253,6 +253,19 @@ pub async fn switch_proxy_provider( app_type: String, provider_id: String, ) -> Result<(), String> { + // Block official providers during proxy takeover + let provider = state + .db + .get_provider_by_id(&provider_id, &app_type) + .map_err(|e| format!("读取供应商失败: {e}"))? + .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; + if provider.category.as_deref() == Some("official") { + return Err( + "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" + .to_string(), + ); + } + state .proxy_service .switch_proxy_target(&app_type, &provider_id) diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index fd0a4d03f..d16f18089 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1407,6 +1407,16 @@ impl ProviderService { // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running; + // Block switching to official providers when proxy takeover is active. + // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans. + if should_hot_switch && _provider.category.as_deref() == Some("official") { + return Err(AppError::localized( + "switch.official_blocked_by_proxy", + "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。", + "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.", + )); + } + if should_hot_switch { // Proxy takeover mode: hot-switch only, don't write Live config log::info!( diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index b713670cc..267d71ca7 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -15,6 +15,7 @@ use crate::services::provider::{ use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; +use tauri::Emitter; use tokio::sync::RwLock; /// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token) @@ -375,6 +376,26 @@ impl ProxyService { // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) let _ = self.db.set_live_takeover_active(true).await; + + // 8) Warn if the current provider is official (risk of account ban via proxy) + if let Ok(Some(current_id)) = + crate::settings::get_effective_current_provider(&self.db, &app) + { + if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) { + if provider.category.as_deref() == Some("official") { + if let Some(handle) = self.app_handle.read().await.as_ref() { + let _ = handle.emit( + "proxy-official-warning", + serde_json::json!({ + "appType": app_type_str, + "providerName": provider.name, + }), + ); + } + } + } + } + return Ok(()); } @@ -1548,6 +1569,14 @@ impl ProxyService { .map_err(|e| format!("读取供应商失败: {e}"))? .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; + // Defense-in-depth: block official providers during proxy takeover + if provider.category.as_deref() == Some("official") { + return Err( + "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" + .to_string(), + ); + } + let logical_target_changed = crate::settings::get_effective_current_provider(&self.db, &app_type_enum) .map_err(|e| format!("读取当前供应商失败: {e}"))? diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 31af81b42..4bd63f1f6 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -297,6 +297,9 @@ pub fn create_tray_menu( .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?; menu_builder = menu_builder.item(&show_main_item).separator(); + // Pre-compute proxy running state (used to disable official providers in tray menu) + let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running()); + // 每个应用类型折叠为子菜单,避免供应商过多时菜单过长 for section in TRAY_SECTIONS.iter() { if !visible_apps.is_visible(§ion.app_type) { @@ -327,15 +330,32 @@ pub fn create_tray_menu( }; let submenu_id = format!("submenu_{}", app_type_str); + // Check if this app is under proxy takeover (for disabling official providers) + let is_app_taken_over = is_proxy_running + && (futures::executor::block_on(app_state.db.get_live_backup(app_type_str)) + .ok() + .flatten() + .is_some() + || app_state + .proxy_service + .detect_takeover_in_live_config_for_app(§ion.app_type)); + let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label); for (id, provider) in sort_providers(&providers) { let is_current = current_id == *id; + let is_official_blocked = + is_app_taken_over && provider.category.as_deref() == Some("official"); + let label = if is_official_blocked { + format!("{} \u{26D4}", &provider.name) // ⛔ emoji + } else { + provider.name.clone() + }; let item = CheckMenuItem::with_id( app, format!("{}{}", section.prefix, id), - &provider.name, - true, + &label, + !is_official_blocked, // disabled when blocked is_current, None::<&str>, ) diff --git a/src/App.tsx b/src/App.tsx index f7470a18a..4406ac753 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -270,7 +270,11 @@ function App() { deleteProvider, saveUsageScript, setAsDefaultModel, - } = useProviderActions(activeApp, isProxyRunning); + } = useProviderActions( + activeApp, + isProxyRunning, + isProxyRunning && isCurrentAppTakeoverActive, + ); const disableOmoMutation = useDisableCurrentOmo(); const handleDisableOmo = () => { @@ -401,6 +405,32 @@ function App() { }; }, [queryClient, t]); + // Listen for proxy-official-warning: warn when takeover is enabled with an official provider + useEffect(() => { + let unsubscribe: (() => void) | undefined; + + const setup = async () => { + unsubscribe = await listen("proxy-official-warning", (event) => { + const { providerName } = event.payload as { + appType: string; + providerName: string; + }; + toast.warning( + t("notifications.proxyOfficialWarning", { + name: providerName, + defaultValue: `当前供应商 ${providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`, + }), + { duration: 8000 }, + ); + }); + }; + + void setup(); + return () => { + unsubscribe?.(); + }; + }, [t]); + useEffect(() => { let active = true; let unlistenResize: (() => void) | undefined; diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 98db9307d..344a71da8 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -7,6 +7,7 @@ import { Minus, Play, Plus, + ShieldAlert, Terminal, TestTube2, Trash2, @@ -36,6 +37,7 @@ interface ProviderActionsProps { isAutoFailoverEnabled?: boolean; isInFailoverQueue?: boolean; onToggleFailover?: (enabled: boolean) => void; + isOfficialBlockedByProxy?: boolean; // OpenClaw: default model isDefaultModel?: boolean; onSetAsDefault?: () => void; @@ -60,6 +62,7 @@ export function ProviderActions({ isAutoFailoverEnabled = false, isInFailoverQueue = false, onToggleFailover, + isOfficialBlockedByProxy = false, // OpenClaw: default model isDefaultModel = false, onSetAsDefault, @@ -166,6 +169,16 @@ export function ProviderActions({ }; } + if (isOfficialBlockedByProxy) { + return { + disabled: true, + variant: "secondary" as const, + className: "opacity-40 cursor-not-allowed", + icon: , + text: t("provider.blockedByProxy", { defaultValue: "已拦截" }), + }; + } + if (isCurrent) { return { disabled: true, diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index e85973e5b..c0a36bfd4 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -175,6 +175,8 @@ export function ProviderCard({ const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const isOfficial = isOfficialProvider(provider, appId); + const isOfficialBlockedByProxy = + isProxyTakeover && (provider.category === "official" || isOfficial); const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; @@ -424,6 +426,7 @@ export function ProviderCard({ isInConfig={isInConfig} isTesting={isTesting} isProxyTakeover={isProxyTakeover} + isOfficialBlockedByProxy={isOfficialBlockedByProxy} isOmo={isAnyOmo} onSwitch={() => onSwitch(provider)} onEdit={() => onEdit(provider)} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 85d125c34..64b2edf1c 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -23,7 +23,11 @@ import { openclawKeys } from "@/hooks/useOpenClaw"; * Hook for managing provider actions (add, update, delete, switch) * Extracts business logic from App.tsx */ -export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { +export function useProviderActions( + activeApp: AppId, + isProxyRunning?: boolean, + isProxyTakeover?: boolean, +) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -185,6 +189,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { ); } + // Block official providers when proxy takeover is active + if (isProxyTakeover && provider.category === "official") { + toast.error( + t("notifications.officialBlockedByProxy", { + defaultValue: + "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁", + }), + { duration: 6000 }, + ); + return; + } + try { const result = await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); @@ -220,7 +236,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 错误提示由 mutation 处理 } }, - [switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t], + [ + switchProviderMutation, + syncClaudePlugin, + activeApp, + isProxyRunning, + isProxyTakeover, + t, + ], ); // 删除供应商 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b28e876c1..8dbb44b59 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -108,6 +108,7 @@ "currentlyUsing": "Currently Using", "enable": "Enable", "inUse": "In Use", + "blockedByProxy": "Blocked", "editProvider": "Edit Provider", "editProviderHint": "Configuration will be applied to the current provider immediately after update.", "deleteProvider": "Delete Provider", @@ -206,7 +207,9 @@ "openclawDefaultModelSetFailed": "Failed to set default model", "openclawNoModels": "No models configured", "backfillWarning": "Switched successfully, but failed to save changes back to the previous provider", - "windowControlFailed": "Window control failed: {{error}}" + "windowControlFailed": "Window control failed: {{error}}", + "officialBlockedByProxy": "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.", + "proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using proxy takeover." }, "confirm": { "deleteProvider": "Delete Provider", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a8ba0cf51..43e87c067 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -108,6 +108,7 @@ "currentlyUsing": "現在使用中", "enable": "有効化", "inUse": "使用中", + "blockedByProxy": "ブロック", "editProvider": "プロバイダーを編集", "editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。", "deleteProvider": "プロバイダーを削除", @@ -206,7 +207,9 @@ "openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました", "openclawNoModels": "モデルが設定されていません", "backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました", - "windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}" + "windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}", + "officialBlockedByProxy": "プロキシ接管モード中は公式プロバイダーに切り替えできません。プロキシ経由で公式 API にアクセスするとアカウントが停止される可能性があります。", + "proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。プロキシ接管を使用する前にサードパーティプロバイダーに切り替えてください。" }, "confirm": { "deleteProvider": "プロバイダーを削除", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 421d7f859..bf4b35766 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -108,6 +108,7 @@ "currentlyUsing": "当前使用", "enable": "启用", "inUse": "使用中", + "blockedByProxy": "已拦截", "editProvider": "编辑供应商", "editProviderHint": "更新配置后将立即应用到当前供应商。", "deleteProvider": "删除供应商", @@ -206,7 +207,9 @@ "openclawDefaultModelSetFailed": "设置默认模型失败", "openclawNoModels": "该供应商没有配置模型", "backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存", - "windowControlFailed": "窗口控制失败:{{error}}" + "windowControlFailed": "窗口控制失败:{{error}}", + "officialBlockedByProxy": "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁", + "proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用代理接管" }, "confirm": { "deleteProvider": "删除供应商", From bb7c83c214b5f15db5f1be1c6a30cc1b69def192 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 11 Apr 2026 23:07:59 +0800 Subject: [PATCH 02/28] feat(pricing): add ~50 new model pricing entries and fix outdated prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pricing data for 4 new providers (Qwen, xAI Grok, Mistral, Cohere) and supplement existing providers (MiniMax M2.5/M2.7, GLM-5/5.1, Doubao Seed 2.0, MiMo V2 Pro, OpenAI o1/o3/codex-mini/gpt-5-mini/nano). Fix outdated prices for deepseek-chat, deepseek-reasoner, and kimi-k2.5. Fix display_name casing "Mimo" → "MiMo" for consistency. Use prepared statement in seed_model_pricing() to avoid recompiling SQL on each of ~130 INSERT iterations. Schema migration v8→v9: DELETE + re-seed model_pricing for existing users. --- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 270 ++++++++++++++++++++++++++++--- 2 files changed, 252 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 1e5f1fe23..1c7618072 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -44,7 +44,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 8; +pub(crate) const SCHEMA_VERSION: i32 = 9; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index d755116fd..295641457 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -418,6 +418,11 @@ impl Database { Self::migrate_v7_to_v8(conn)?; Self::set_user_version(conn, 8)?; } + 8 => { + log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)"); + Self::migrate_v8_to_v9(conn)?; + Self::set_user_version(conn, 9)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -1144,6 +1149,25 @@ impl Database { Ok(()) } + /// v8 → v9: 全面补充模型定价(清空 + 重新 seed) + fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> { + conn.execute( + "CREATE TABLE IF NOT EXISTS model_pricing ( + model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", + [], + ) + .map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?; + conn.execute("DELETE FROM model_pricing", []) + .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?; + Self::seed_model_pricing(conn)?; + log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 @@ -1491,6 +1515,38 @@ impl Database { "0.02", "0", ), + ( + "doubao-seed-2-0-pro", + "Doubao Seed 2.0 Pro", + "0.47", + "2.37", + "0", + "0", + ), + ( + "doubao-seed-2-0-code", + "Doubao Seed 2.0 Code", + "0.47", + "2.37", + "0", + "0", + ), + ( + "doubao-seed-2-0-lite", + "Doubao Seed 2.0 Lite", + "0.25", + "2", + "0", + "0", + ), + ( + "doubao-seed-2-0-mini", + "Doubao Seed 2.0 Mini", + "0.03", + "0.31", + "0", + "0", + ), // DeepSeek 系列 ( "deepseek-v3.2", @@ -1512,17 +1568,17 @@ impl Database { ( "deepseek-chat", "DeepSeek Chat", - "0.28", - "0.42", - "0.028", + "0.27", + "1.10", + "0.07", "0", ), ( "deepseek-reasoner", "DeepSeek Reasoner", - "0.28", - "0.42", - "0.028", + "0.55", + "2.19", + "0.14", "0", ), // Kimi (月之暗面) @@ -1543,7 +1599,7 @@ impl Database { "0.14", "0", ), - ("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"), + ("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"), // MiniMax 系列 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( @@ -1555,35 +1611,211 @@ impl Database { "0", ), ("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"), + ("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"), + ( + "minimax-m2.5-lightning", + "MiniMax M2.5 Lightning", + "0.30", + "2.40", + "0.03", + "0", + ), + ( + "minimax-m2.7", + "MiniMax M2.7", + "0.30", + "1.20", + "0.06", + "0.375", + ), + ( + "minimax-m2.7-highspeed", + "MiniMax M2.7 Highspeed", + "0.60", + "2.40", + "0.06", + "0.375", + ), // GLM (智谱) ("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"), ("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"), - // Mimo (小米) + ("glm-5", "GLM-5", "0.72", "2.30", "0", "0"), + ("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"), + // MiMo (小米) ( "mimo-v2-flash", - "Mimo V2 Flash", + "MiMo V2 Flash", "0.09", "0.29", "0.009", "0", ), + ("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"), + // Qwen 系列 (阿里巴巴) + ("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"), + ("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"), + ("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"), + ( + "qwen3-235b-a22b", + "Qwen3 235B-A22B", + "0.70", + "8.40", + "0", + "0", + ), + ( + "qwen3-coder-plus", + "Qwen3 Coder Plus", + "0.65", + "3.25", + "0", + "0", + ), + ( + "qwen3-coder-flash", + "Qwen3 Coder Flash", + "0.195", + "0.975", + "0", + "0", + ), + ( + "qwen3-coder-next", + "Qwen3 Coder Next", + "0.12", + "0.75", + "0", + "0", + ), + ("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"), + ("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"), + ("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"), + // Grok 系列 (xAI) + ( + "grok-4.20-0309-reasoning", + "Grok 4.20 Reasoning", + "2", + "6", + "0.20", + "0", + ), + ( + "grok-4.20-0309-non-reasoning", + "Grok 4.20", + "2", + "6", + "0.20", + "0", + ), + ( + "grok-4-1-fast-reasoning", + "Grok 4.1 Fast Reasoning", + "0.20", + "0.50", + "0.05", + "0", + ), + ( + "grok-4-1-fast-non-reasoning", + "Grok 4.1 Fast", + "0.20", + "0.50", + "0.05", + "0", + ), + ("grok-4", "Grok 4", "3", "15", "0.75", "0"), + ( + "grok-code-fast-1", + "Grok Code Fast", + "0.20", + "1.50", + "0.02", + "0", + ), + ("grok-3", "Grok 3", "3", "15", "0.75", "0"), + ("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"), + // Mistral 系列 + ("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"), + ( + "devstral-small-1.1", + "Devstral Small 1.1", + "0.07", + "0.28", + "0.01", + "0", + ), + ("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"), + ( + "devstral-medium", + "Devstral Medium", + "0.40", + "2", + "0.04", + "0", + ), + ( + "mistral-large-3-2512", + "Mistral Large 3", + "0.50", + "1.50", + "0.05", + "0", + ), + ( + "mistral-medium-3.1", + "Mistral Medium 3.1", + "0.40", + "2", + "0.04", + "0", + ), + ( + "mistral-small-3.2-24b", + "Mistral Small 3.2", + "0.075", + "0.20", + "0.01", + "0", + ), + ("magistral-medium", "Magistral Medium", "2", "5", "0", "0"), + // Cohere 系列 + ("command-a", "Cohere Command A", "2.50", "10", "0", "0"), + ( + "command-r-plus", + "Cohere Command R+", + "2.50", + "10", + "0", + "0", + ), + ("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"), + // OpenAI 补充 + ("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"), + ("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"), + ("o1", "OpenAI o1", "15", "60", "7.50", "0"), + ("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"), + ("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"), + ("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"), + ("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"), ]; - for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { - conn.execute( + let mut stmt = conn + .prepare( "INSERT OR IGNORE INTO model_pricing ( model_id, display_name, input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![ - model_id, - display_name, - input, - output, - cache_read, - cache_creation - ], ) + .map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?; + for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { + stmt.execute(rusqlite::params![ + model_id, + display_name, + input, + output, + cache_read, + cache_creation + ]) .map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?; } From 8669b408e9e9595f604143afc9d0f54709916475 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 12 Apr 2026 00:10:01 +0800 Subject: [PATCH 03/28] fix(usage): deduplicate proxy and session log usage records Extract message_id from Claude API responses (msg_xxx) and use it to generate a shared request_id format (session:{msg_xxx}) between the proxy logger and session log sync. When session sync encounters the same request_id via INSERT OR IGNORE, it skips the duplicate. - Add message_id field to TokenUsage, extracted from Claude responses - Add TokenUsage::dedup_request_id() to generate shared request IDs - Define SESSION_REQUEST_ID_PREFIX constant to eliminate magic strings - Change proxy logger to INSERT OR REPLACE for richer-data-wins semantics --- src-tauri/src/proxy/handlers.rs | 2 +- src-tauri/src/proxy/response_processor.rs | 4 +- src-tauri/src/proxy/usage/calculator.rs | 4 ++ src-tauri/src/proxy/usage/logger.rs | 3 +- src-tauri/src/proxy/usage/parser.rs | 42 +++++++++++++++++-- src-tauri/src/services/session_usage.rs | 7 +++- src-tauri/src/services/session_usage_codex.rs | 1 + .../src/services/session_usage_gemini.rs | 1 + 8 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 08816246a..3a2bd4ca4 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -603,7 +603,7 @@ async fn log_usage( model }; - let request_id = uuid::Uuid::new_v4().to_string(); + let request_id = usage.dedup_request_id(); if let Err(e) = logger.log_with_calculation( request_id, diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 3f4fe8425..1a185f93b 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -528,7 +528,7 @@ async fn log_usage_internal( model }; - let request_id = uuid::Uuid::new_v4().to_string(); + let request_id = usage.dedup_request_id(); log::debug!( "[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}", @@ -784,6 +784,7 @@ mod tests { cache_read_tokens: 0, cache_creation_tokens: 0, model: None, + message_id: None, }; log_usage_internal( @@ -843,6 +844,7 @@ mod tests { cache_read_tokens: 0, cache_creation_tokens: 0, model: None, + message_id: None, }; log_usage_internal( diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index 340150237..6499ac0c7 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -114,6 +114,7 @@ mod tests { cache_read_tokens: 200, cache_creation_tokens: 100, model: None, + message_id: None, }; let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap(); @@ -144,6 +145,7 @@ mod tests { cache_read_tokens: 0, cache_creation_tokens: 0, model: None, + message_id: None, }; let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap(); @@ -165,6 +167,7 @@ mod tests { cache_read_tokens: 0, cache_creation_tokens: 0, model: None, + message_id: None, }; let multiplier = Decimal::from_str("1.0").unwrap(); @@ -181,6 +184,7 @@ mod tests { cache_read_tokens: 1, cache_creation_tokens: 1, model: None, + message_id: None, }; let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap(); diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index c1f3c3774..94f028017 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -73,7 +73,7 @@ impl<'a> UsageLogger<'a> { }); conn.execute( - "INSERT INTO proxy_request_logs ( + "INSERT OR REPLACE INTO proxy_request_logs ( request_id, provider_id, app_type, model, request_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, @@ -358,6 +358,7 @@ mod tests { cache_read_tokens: 0, cache_creation_tokens: 0, model: None, + message_id: None, }; logger.log_with_calculation( diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 2541652c9..f2b797ee3 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致 +pub const SESSION_REQUEST_ID_PREFIX: &str = "session:"; + /// Token 使用量统计 #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TokenUsage { @@ -18,6 +21,22 @@ pub struct TokenUsage { pub cache_creation_tokens: u32, /// 从响应中提取的实际模型名称(如果可用) pub model: Option, + /// 从响应中提取的消息 ID(用于跨源去重) + /// + /// Claude API: `msg_xxx`,与 session JSONL 中的 `message.id` 一致 + #[serde(skip)] + pub message_id: Option, +} + +impl TokenUsage { + /// 生成与 session 日志共享的 request_id,用于跨源去重。 + /// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。 + pub fn dedup_request_id(&self) -> String { + self.message_id + .as_ref() + .map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}")) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } } /// API 类型 @@ -39,6 +58,10 @@ impl TokenUsage { .get("model") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let message_id = body + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); Some(Self { input_tokens: usage.get("input_tokens")?.as_u64()? as u32, @@ -52,6 +75,7 @@ impl TokenUsage { .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, model, + message_id, }) } @@ -60,18 +84,23 @@ impl TokenUsage { pub fn from_claude_stream_events(events: &[Value]) -> Option { let mut usage = Self::default(); let mut model: Option = None; + let mut message_id: Option = None; for event in events { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { match event_type { "message_start" => { - // 从 message_start 提取模型名称 - if model.is_none() { - if let Some(message) = event.get("message") { + if let Some(message) = event.get("message") { + if model.is_none() { if let Some(m) = message.get("model").and_then(|v| v.as_str()) { model = Some(m.to_string()); } } + if message_id.is_none() { + if let Some(id) = message.get("id").and_then(|v| v.as_str()) { + message_id = Some(id.to_string()); + } + } } if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) { // 从 message_start 获取 input_tokens(原生 Claude API) @@ -137,6 +166,7 @@ impl TokenUsage { if usage.input_tokens > 0 || usage.output_tokens > 0 { usage.model = model; + usage.message_id = message_id; Some(usage) } else { None @@ -153,6 +183,7 @@ impl TokenUsage { cache_read_tokens: 0, cache_creation_tokens: 0, model: None, + message_id: None, }) } @@ -202,6 +233,7 @@ impl TokenUsage { .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, model, + message_id: None, }) } @@ -245,6 +277,7 @@ impl TokenUsage { .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, model, + message_id: None, }) } @@ -339,6 +372,7 @@ impl TokenUsage { cache_read_tokens: cached_tokens, cache_creation_tokens: 0, model, + message_id: None, }) } @@ -383,6 +417,7 @@ impl TokenUsage { .unwrap_or(0) as u32, cache_creation_tokens: 0, model, + message_id: None, }) } @@ -433,6 +468,7 @@ impl TokenUsage { cache_read_tokens: total_cache_read, cache_creation_tokens: 0, model, + message_id: None, }) } else { None diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index d821f35d2..2b9c5af9f 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -278,7 +278,11 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr continue; } - let request_id = format!("session:{}", msg.message_id); + let request_id = format!( + "{}{}", + crate::proxy::usage::parser::SESSION_REQUEST_ID_PREFIX, + msg.message_id + ); // 跳过 output_tokens 为 0 的无意义条目 if msg.output_tokens == 0 { @@ -379,6 +383,7 @@ fn insert_session_log_entry( cache_read_tokens: msg.cache_read_tokens, cache_creation_tokens: msg.cache_creation_tokens, model: Some(msg.model.clone()), + message_id: None, }; let pricing = find_model_pricing_for_session(&conn, &msg.model); diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 13dff44b0..bd92e2ea1 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -474,6 +474,7 @@ fn insert_codex_session_entry( cache_read_tokens: delta.cached_input, cache_creation_tokens: 0, model: Some(model.to_string()), + message_id: None, }; let pricing = find_codex_pricing(&conn, model); diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index 8bac1fa4f..24ff25c37 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -261,6 +261,7 @@ fn insert_gemini_session_entry( cache_read_tokens: tokens.cached, cache_creation_tokens: 0, model: Some(model.to_string()), + message_id: None, }; let pricing = find_gemini_pricing(&conn, model); From ed269cc20e9169ab67bddb2eca11db2ce7405565 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 12 Apr 2026 22:24:14 +0800 Subject: [PATCH 04/28] feat(sessions): extract meaningful titles for Codex and OpenClaw sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously Codex and OpenClaw sessions only showed the working directory basename as the title, making it hard to distinguish sessions in the same project. Now both providers extract the first real user message as the session title, matching the existing Claude Code behavior. - Codex: first user message → dir basename (skips AGENTS.md injection) - OpenClaw: displayName (sessions.json) → first user message → dir basename - Move TITLE_MAX_CHARS constant to shared utils.rs - Use Option<&HashMap> for OpenClaw parse_session to avoid leaky abstraction --- .../src/session_manager/providers/claude.rs | 2 +- .../src/session_manager/providers/codex.rs | 115 +++++++++++- .../src/session_manager/providers/openclaw.rs | 172 ++++++++++++++++-- .../src/session_manager/providers/utils.rs | 3 + 4 files changed, 266 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index 226613d13..d02eaba22 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -9,10 +9,10 @@ use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, + TITLE_MAX_CHARS, }; const PROVIDER_ID: &str = "claude"; -const TITLE_MAX_CHARS: usize = 80; pub fn scan_sessions() -> Vec { let root = get_claude_config_dir().join("projects"); diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index ce40e31f7..5d3c7d364 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -11,6 +11,7 @@ use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, + TITLE_MAX_CHARS, }; const PROVIDER_ID: &str = "codex"; @@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option { let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; + let mut first_user_message: Option = None; - // Extract metadata from head lines + // Extract metadata and first user message from head lines for line in &head { let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, @@ -158,6 +160,29 @@ fn parse_session(path: &Path) -> Option { } } } + // Extract first user message as title candidate + if first_user_message.is_none() + && value.get("type").and_then(Value::as_str) == Some("response_item") + { + if let Some(payload) = value.get("payload") { + if payload.get("type").and_then(Value::as_str) == Some("message") + && payload.get("role").and_then(Value::as_str) == Some("user") + { + let text = payload.get("content").map(extract_text).unwrap_or_default(); + let trimmed = text.trim(); + if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") { + first_user_message = Some(trimmed.to_string()); + } + } + } + } + if session_id.is_some() + && project_dir.is_some() + && created_at.is_some() + && first_user_message.is_some() + { + break; + } } // Extract last_active_at and summary from tail lines (reverse order) @@ -190,10 +215,14 @@ fn parse_session(path: &Path) -> Option { let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); let session_id = session_id?; - let title = project_dir - .as_deref() - .and_then(path_basename) - .map(|value| value.to_string()); + let title = first_user_message + .map(|t| truncate_summary(&t, TITLE_MAX_CHARS)) + .or_else(|| { + project_dir + .as_deref() + .and_then(path_basename) + .map(|v| v.to_string()) + }); let summary = summary.map(|text| truncate_summary(&text, 160)); @@ -261,6 +290,82 @@ mod tests { assert!(!path.exists()); } + #[test] + fn parse_session_uses_first_user_message_as_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); + } + + #[test] + fn parse_session_skips_agents_md_injection() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\nDo stuff\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + // Should skip AGENTS.md injection and use the real user message + assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); + } + + #[test] + fn parse_session_falls_back_to_dir_basename() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/my-project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + // No user message → falls back to dir basename + assert_eq!(meta.title.as_deref(), Some("my-project")); + } + + #[test] + fn parse_session_truncates_long_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + let long_msg = "a".repeat(200); + std::fs::write( + &path, + format!( + "{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\ + {{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + let title = meta.title.unwrap(); + assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..." + assert!(title.ends_with("...")); + } + #[test] fn load_messages_includes_function_call_and_output() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index a56b56389..875a20ee4 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; @@ -12,6 +13,7 @@ use crate::{ use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, + TITLE_MAX_CHARS, }; const PROVIDER_ID: &str = "openclaw"; @@ -46,22 +48,15 @@ pub fn scan_sessions() -> Vec { Err(_) => continue, }; + let display_names = load_display_names(&sessions_dir); + for entry in session_entries.flatten() { let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { continue; } - // Skip sessions.json index file - if path - .file_name() - .and_then(|n| n.to_str()) - .map(|n| n == "sessions.json") - .unwrap_or(false) - { - continue; - } - if let Some(meta) = parse_session(&path) { + if let Some(meta) = parse_session(&path, Some(&display_names)) { sessions.push(meta); } } @@ -119,7 +114,7 @@ pub fn load_messages(path: &Path) -> Result, String> { } pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { - let meta = parse_session(path).ok_or_else(|| { + let meta = parse_session(path, None).ok_or_else(|| { format!( "Failed to parse OpenClaw session metadata: {}", path.display() @@ -149,15 +144,43 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option { +/// Read `sessions.json` index and build a sessionId → displayName lookup map. +/// Returns an empty map if the file does not exist or cannot be parsed. +fn load_display_names(sessions_dir: &Path) -> HashMap { + let index_path = sessions_dir.join("sessions.json"); + let content = match std::fs::read_to_string(&index_path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + let index: serde_json::Map = match serde_json::from_str(&content) { + Ok(m) => m, + Err(_) => return HashMap::new(), + }; + + let mut map = HashMap::new(); + for (_key, entry) in &index { + if let (Some(id), Some(name)) = ( + entry.get("sessionId").and_then(Value::as_str), + entry.get("displayName").and_then(Value::as_str), + ) { + if !name.is_empty() { + map.insert(id.to_string(), name.to_string()); + } + } + } + map +} + +fn parse_session(path: &Path, display_names: Option<&HashMap>) -> Option { let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut cwd: Option = None; let mut created_at: Option = None; let mut summary: Option = None; + let mut first_user_message: Option = None; - // Extract metadata and first message summary from head lines + // Extract metadata, summary, and first user message from head lines for line in &head { let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, @@ -189,15 +212,30 @@ fn parse_session(path: &Path) -> Option { continue; } - // OpenClaw summary is the first message content - if event_type == "message" && summary.is_none() { + if event_type == "message" { if let Some(message) = value.get("message") { let text = message.get("content").map(extract_text).unwrap_or_default(); if !text.trim().is_empty() { - summary = Some(text); + if first_user_message.is_none() + && message.get("role").and_then(Value::as_str) == Some("user") + { + first_user_message = Some(text.trim().to_string()); + } + if summary.is_none() { + summary = Some(text); + } } } } + + if session_id.is_some() + && cwd.is_some() + && created_at.is_some() + && summary.is_some() + && first_user_message.is_some() + { + break; + } } // Extract last_active_at from tail lines (reverse order) @@ -221,10 +259,17 @@ fn parse_session(path: &Path) -> Option { }); let session_id = session_id?; - let title = cwd - .as_deref() - .and_then(path_basename) - .map(|s| s.to_string()); + // Title priority: displayName (from sessions.json) > first user message > dir basename + let title = display_names + .and_then(|m| m.get(&session_id)) + .filter(|s| !s.is_empty()) + .map(|t| truncate_summary(t, TITLE_MAX_CHARS)) + .or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))) + .or_else(|| { + cwd.as_deref() + .and_then(path_basename) + .map(|s| s.to_string()) + }); let summary = summary.map(|text| truncate_summary(&text, 160)); @@ -284,6 +329,93 @@ mod tests { use super::*; use tempfile::tempdir; + #[test] + fn parse_session_uses_first_user_message_as_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-abc.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path, None).unwrap(); + assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); + } + + #[test] + fn parse_session_display_name_overrides_user_message() { + let temp = tempdir().expect("tempdir"); + let sessions_dir = temp.path(); + + let path = sessions_dir.join("session-abc.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n" + ), + ) + .expect("write session"); + + std::fs::write( + sessions_dir.join("sessions.json"), + r#"{ + "agent:main:main": { + "sessionId": "session-abc", + "displayName": "重构登录模块" + } + }"#, + ) + .expect("write index"); + + let display_names = load_display_names(sessions_dir); + let meta = parse_session(&path, Some(&display_names)).unwrap(); + assert_eq!(meta.title.as_deref(), Some("重构登录模块")); + } + + #[test] + fn parse_session_falls_back_to_dir_basename() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-def.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"session\",\"id\":\"session-def\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path, None).unwrap(); + // No user message and no displayName → falls back to dir basename + assert_eq!(meta.title.as_deref(), Some("my-project")); + } + + #[test] + fn parse_session_truncates_long_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-trunc.jsonl"); + let long_msg = "a".repeat(200); + std::fs::write( + &path, + format!( + "{{\"type\":\"session\",\"id\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\ + {{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path, None).unwrap(); + let title = meta.title.unwrap(); + assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..." + assert!(title.ends_with("...")); + } + #[test] fn delete_session_updates_index_and_removes_jsonl() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/session_manager/providers/utils.rs b/src-tauri/src/session_manager/providers/utils.rs index dbbaa001d..4339ae3e7 100644 --- a/src-tauri/src/session_manager/providers/utils.rs +++ b/src-tauri/src/session_manager/providers/utils.rs @@ -5,6 +5,9 @@ use std::path::Path; use chrono::{DateTime, FixedOffset}; use serde_json::Value; +/// Maximum number of characters for session titles (shared across providers). +pub const TITLE_MAX_CHARS: usize = 80; + /// Read the first `head_n` lines and last `tail_n` lines from a file. /// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking. pub fn read_head_tail_lines( From 420f4c8c23d56c4d6e11f99688e126d506dec86c Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 12 Apr 2026 22:37:09 +0800 Subject: [PATCH 05/28] fix(sessions): strip OpenClaw message_id suffix and allow 2-line titles OpenClaw gateway injects `[message_id: UUID]` metadata at the end of every message, wasting display space. Strip this suffix from both title and summary fields. Also change session title display from single-line truncate to line-clamp-2, so longer titles (e.g. OpenClaw's timestamp-prefixed messages) can show more meaningful content across two lines. --- .../src/session_manager/providers/openclaw.rs | 16 +++++++++++++--- src/components/sessions/SessionItem.tsx | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index 875a20ee4..25d1c851e 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -18,6 +18,15 @@ use super::utils::{ const PROVIDER_ID: &str = "openclaw"; +/// Strip trailing `\n[message_id: ...]` metadata injected by OpenClaw gateway. +fn strip_message_id_suffix(text: &str) -> &str { + if let Some(pos) = text.rfind("\n[message_id:") { + text[..pos].trim_end() + } else { + text + } +} + pub fn scan_sessions() -> Vec { let agents_dir = get_openclaw_dir().join("agents"); if !agents_dir.exists() { @@ -215,14 +224,15 @@ fn parse_session(path: &Path, display_names: Option<&HashMap>) - if event_type == "message" { if let Some(message) = value.get("message") { let text = message.get("content").map(extract_text).unwrap_or_default(); - if !text.trim().is_empty() { + let cleaned = strip_message_id_suffix(&text); + if !cleaned.trim().is_empty() { if first_user_message.is_none() && message.get("role").and_then(Value::as_str) == Some("user") { - first_user_message = Some(text.trim().to_string()); + first_user_message = Some(cleaned.trim().to_string()); } if summary.is_none() { - summary = Some(text); + summary = Some(cleaned.trim().to_string()); } } } diff --git a/src/components/sessions/SessionItem.tsx b/src/components/sessions/SessionItem.tsx index 1cacc1405..6cad46975 100644 --- a/src/components/sessions/SessionItem.tsx +++ b/src/components/sessions/SessionItem.tsx @@ -85,7 +85,7 @@ export function SessionItem({ {getProviderLabel(session.providerId, t)} - + {searchQuery ? highlightText(title, searchQuery) : title} Date: Mon, 13 Apr 2026 08:35:55 +0800 Subject: [PATCH 06/28] feat(usage): improve pagination with first/last 3 pages and page jump input Show first 3 and last 3 page buttons instead of just first/last, with Set-based deduplication for clean edge merging. Add a page number input field with Go button for direct page navigation. --- src/components/usage/RequestLogTable.tsx | 58 ++++++++++++++++++------ src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh.json | 2 + 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index fe485ec20..7519a39d0 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -57,6 +57,7 @@ export function RequestLogTable({ const [appliedFilters, setAppliedFilters] = useState({}); const [draftFilters, setDraftFilters] = useState({}); const [page, setPage] = useState(0); + const [pageInput, setPageInput] = useState(""); const pageSize = 20; const [validationError, setValidationError] = useState(null); @@ -131,6 +132,15 @@ export function RequestLogTable({ setPage(0); }; + const handleGoToPage = () => { + const trimmed = pageInput.trim(); + if (!/^\d+$/.test(trimmed)) return; + const parsed = Number(trimmed); + if (parsed < 1 || parsed > totalPages) return; + setPage(parsed - 1); + setPageInput(""); + }; + const handleRefresh = () => { const key = { timeMode: appliedTimeMode, @@ -561,30 +571,33 @@ export function RequestLogTable({ > - {/* 页码按钮 */} {(() => { const pages: (number | string)[] = []; - if (totalPages <= 7) { + // 3 head + 3 tail + 3 neighborhood = 9 max distinct pages + if (totalPages <= 9) { for (let i = 0; i < totalPages; i++) pages.push(i); } else { - pages.push(0); - if (page > 2) pages.push("..."); + const pageSet = new Set(); + for (let i = 0; i < 3; i++) pageSet.add(i); + for (let i = totalPages - 3; i < totalPages; i++) + pageSet.add(i); for ( - let i = Math.max(1, page - 1); - i <= Math.min(totalPages - 2, page + 1); + let i = Math.max(0, page - 1); + i <= Math.min(totalPages - 1, page + 1); i++ - ) { - pages.push(i); + ) + pageSet.add(i); + const sorted = Array.from(pageSet).sort((a, b) => a - b); + for (let i = 0; i < sorted.length; i++) { + if (i > 0 && sorted[i] - sorted[i - 1] > 1) { + pages.push(`ellipsis-${i}`); + } + pages.push(sorted[i]); } - if (page < totalPages - 3) pages.push("..."); - pages.push(totalPages - 1); } - return pages.map((p, idx) => + return pages.map((p) => typeof p === "string" ? ( - + ... ) : ( @@ -608,6 +621,21 @@ export function RequestLogTable({ > +
+ setPageInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleGoToPage(); + }} + placeholder={t("usage.pageInputPlaceholder")} + className="h-8 w-16 text-center text-xs" + /> + +
)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8dbb44b59..597f10d38 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1107,6 +1107,8 @@ "failed": "Session sync failed" }, "totalRecords": "{{total}} records total", + "goToPage": "Go", + "pageInputPlaceholder": "Page", "modelPricing": "Model Pricing", "loadPricingError": "Failed to load pricing data", "modelPricingDesc": "Configure token costs for each model", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 43e87c067..707497f21 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1107,6 +1107,8 @@ "failed": "セッション同期に失敗しました" }, "totalRecords": "全 {{total}} 件", + "goToPage": "移動", + "pageInputPlaceholder": "ページ", "modelPricing": "モデル料金", "loadPricingError": "料金データの読み込みに失敗しました", "modelPricingDesc": "各モデルのトークンコストを設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index bf4b35766..7659c6d0e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1108,6 +1108,8 @@ "failed": "会话同步失败" }, "totalRecords": "共 {{total}} 条记录", + "goToPage": "跳转", + "pageInputPlaceholder": "页码", "modelPricing": "模型定价", "loadPricingError": "加载定价数据失败", "modelPricingDesc": "配置各模型的 Token 成本", From 5c1b4575206155f9dc3f7a8319dc6aa5d3c58705 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 13 Apr 2026 09:03:05 +0800 Subject: [PATCH 07/28] fix(usage): sync request log time range with dashboard 1d/7d/30d selector The RequestLogTable had a hardcoded 24-hour rolling window, ignoring the dashboard's time range selector. Now it accepts a timeRange prop and dynamically adjusts the query window, so users can view logs beyond just the last day. --- src/components/usage/RequestLogTable.tsx | 22 ++++++++++++++++++---- src/components/usage/UsageDashboard.tsx | 1 + 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 7519a39d0..0b4cd6c51 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Table, @@ -31,24 +31,33 @@ import { interface RequestLogTableProps { appType?: string; refreshIntervalMs: number; + timeRange?: "1d" | "7d" | "30d"; } const ONE_DAY_SECONDS = 24 * 60 * 60; const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; +const TIME_RANGE_SECONDS: Record = { + "1d": ONE_DAY_SECONDS, + "7d": 7 * ONE_DAY_SECONDS, + "30d": 30 * ONE_DAY_SECONDS, +}; + type TimeMode = "rolling" | "fixed"; export function RequestLogTable({ appType: dashboardAppType, refreshIntervalMs, + timeRange = "1d", }: RequestLogTableProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); + const rollingWindowSeconds = TIME_RANGE_SECONDS[timeRange] ?? ONE_DAY_SECONDS; + const getRollingRange = () => { const now = Math.floor(Date.now() / 1000); - const oneDayAgo = now - ONE_DAY_SECONDS; - return { startDate: oneDayAgo, endDate: now }; + return { startDate: now - rollingWindowSeconds, endDate: now }; }; const [appliedTimeMode, setAppliedTimeMode] = useState("rolling"); @@ -61,6 +70,11 @@ export function RequestLogTable({ const pageSize = 20; const [validationError, setValidationError] = useState(null); + // Reset page when the dashboard time range changes + useEffect(() => { + setPage(0); + }, [timeRange]); + // When dashboard-level app filter is active (not "all"), override the local appType filter const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; const effectiveFilters: LogFilters = dashboardAppTypeActive @@ -70,7 +84,7 @@ export function RequestLogTable({ const { data: result, isLoading } = useRequestLogs({ filters: effectiveFilters, timeMode: appliedTimeMode, - rollingWindowSeconds: ONE_DAY_SECONDS, + rollingWindowSeconds, page, pageSize, options: { diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index d0ad19a7e..381053c7f 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -188,6 +188,7 @@ export function UsageDashboard() { From d6fa6118333c6b8a050d5f245f0db021b8efd5c3 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 13 Apr 2026 11:19:08 +0800 Subject: [PATCH 08/28] chore: remove X-Code API (XCodeAPI) provider preset and sponsorship --- README.md | 5 ----- README_JA.md | 5 ----- README_ZH.md | 5 ----- assets/partners/logos/xcodeapi.png | Bin 7575 -> 0 bytes src/config/claudeProviderPresets.ts | 17 --------------- src/config/codexProviderPresets.ts | 17 --------------- src/config/opencodeProviderPresets.ts | 30 -------------------------- src/i18n/locales/en.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/zh.json | 1 - src/icons/extracted/index.ts | 2 -- src/icons/extracted/metadata.ts | 7 ------ src/icons/extracted/xcode.svg | 1 - 13 files changed, 92 deletions(-) delete mode 100644 assets/partners/logos/xcodeapi.png delete mode 100644 src/icons/extracted/xcode.svg diff --git a/README.md b/README.md index a173e85d9..2c0687163 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,6 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via this link and enter promo code "ccswitch" when topping up to enjoy a 10% discount! - -XCodeAPI -Thanks to XCodeAPI for sponsoring this project! XCodeAPI offers a special benefit for CC Switch users: register via this link and get an extra 10% credit bonus on your first order! (Contact the site admin to claim) - - CTok Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register! diff --git a/README_JA.md b/README_JA.md index 6d1e41603..905194af2 100644 --- a/README_JA.md +++ b/README_JA.md @@ -97,11 +97,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:こちらのリンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると 10% 割引 が適用されます! - -XCodeAPI -XCodeAPI のご支援に感謝します!CC Switch ユーザー向けの特別特典:こちらのリンクから登録すると、初回注文で 10% の追加クレジットボーナスがもらえます!(サイト管理者に連絡して受け取りください) - - CTok CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。こちらから登録してください! diff --git a/README_ZH.md b/README_ZH.md index b60d1b080..8f5214dac 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -98,11 +98,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"ccswitch"优惠码可享九折优惠! - -XCodeAPI -感谢 XCodeAPI 赞助了本项目!XCodeAPI 为本软件的用户提供特别福利,使用此链接注册后首单加赠10%的额度!(联系站长领取) - - CTok 感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里注册! diff --git a/assets/partners/logos/xcodeapi.png b/assets/partners/logos/xcodeapi.png deleted file mode 100644 index 9c0b9999fcaed73431fb4355ce492046ab550b78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7575 zcmV;I9cbc-P)eh4bY)!^&fNe29V|&iK~#90?R|Ni zT~(FtxAr-Ao~!Csr7BgGq*5URBtT*elLQDuBPxi3@&E<2^@kJU6CX`C;A?%NfFcO} zT3fIIK_Cb=^Ay4mVj~bkCK8fVQWqnyf!cK#NZa`Bce>m1L~MT)B|yzQm^> z>=4QUEQgxD^N@ntsIX!X$&B`!7h=!6kz)`M>83+q0KKi^>WkX|*mDF zU;!fiGzNzb2?95+o-DsG9Y?$z{FhZ-6hmVsH;y-3KwF#I7Xm8Pl#g3dc@UtNfN}c}?>=o(Q(#sew#`R7@kT9kN^ZofC##G1Az$zYUP+8dFwG#ujJ47Y3IAfV2$3}VELTOJtx{yp)L2?(JQDPRdMgJoZbxcyt5r+?U| zd!8C6W0;hHu-uh|#>f$v@)}+q6IZ`c{$sSP)f-jQR~>4oGb{!m88_cI{4aMV5;>`; z0*cBkAT==0Y!|m(*|}n=&u-H%B(^SO4a5)vLib*Z@qGnGQUAn6jb9yCzOuIbZoJH< zF_C%SCJrUkhr+VPH~o6#`nzImt73q`x^*ZZJIKz` z&SksQ>{7Ec+rc)5$S}AJ=vk$dQvM+({>O{?U8z#jnhs$QOU?Qvos)O0jNEvCd?Hg8 zf`|l!Luw@j+MMai^_PXuJt=5YE4Ii00U9G(l#wD62mph@(6ho+5jVV1e0GS73<$-D zv_b$l(pz}wRA;|m6}oLU<%UBG6~mfHmVfzJ^p*!>qj5<>Fo*;Zgo5&%ZsqC=ThCt+ zG9zF;EZ;-5g$(RUYFEErczQo)S+Fpe7f1n#Mc%@%K4M$cYEwy9N1IIR(1Bqn(RlHf zk410$ZDM3X0t%oK1Z$drHn-b$)n^Z6RWS9?hUI;J{O5{;a%e^^H7P!^>fj9lHfoosf_4w8(gCX&;d2Rpa)V{xVjSdzJ!;&D)ZDWU% zTHHt%kjQmY{(E0+JLjaJU9BwqjYRXe-@AY1bK{wuH2svc3AWiprT}I`MJEzomom6F zd-+8@4x0cV2#1H1s}_Q}_~>?-N=OudqCy(OW3YFj#r(-Sm34pfNM+sXlYnPxQ1n zPdl>xYiG_nth2%FzwEYk_pIKdpTGR@w#TkJ*6UOOsBA4MLx6}~3Y<>ru%EctfA&cM zyK2&HU^tq;{oZ|dJvM#umXTc7^$R@VxrGsJK67fHj&nUeKMXt+T-lD+v?hrC~FUoB`q!N zj5c3)tyM|Jn(_3%4UA>VdLJ`^K+KGcCfCQZf1JpwpwG>2J-a955FZ@FBM=ja`C#nk z7@WxcabJo+OswCgEP3@i;|DM-qf!7>?IFv}pZs#LP?S~;T2(lu1O%7?eRYSc**^iN`P>1aFf%hJ%i2@1 zbe&Sqz>Bf;-dvFxV4h}IPiys7k5XRp?E_;sZy(KS5^MrhGZFxEiRGQ|MsD0RlqpMm zP^j?+e`&1RI-bmyYT64twJ|c0DZNLPz*W^^tI#M17<|hUJHLJRrs|C@`qjoswJlPs zHXASia)J-DBOthnO-EyeJMJI+`GX^gG!qd7NeN0iYJ zIXYgv<2U<3+W-7wpGp525FmgwfSAEjvb7iu0O+u(=eBt7iX?Pp?B!(kSg&iL$7$7P zf1&h5GzHRU7=5NQ&}Of!rfbZ14MtZ*Q^pJ=A|Pl}76x2yMHDsO5YI+(B@pQobIoYF z$36b}{_qFmwM>uom*0PuDQc^?jqB|NjEyDC*dEEO-;+FLe(M3MWYmWEZ==iEY=aySJ6(3n?763MdEr^y13^TPpm^|gP9XP=9tjGhJB z?8-OibSw(Es&BF1X(BrA6Xa@rak7I2eNxwCiH2jq581?%+ zzd3sHyw<5L*>X|%(ux^p9@SobZ1tw-g}1&H&y*No=DSz#S$=q%TD!NdfyaV5d&o_g zp&+D!7_v;d2O_JJP$fu{9&tcmB06lA z$K_P?(iSJq=#xjZN@fBpATix9-1d4Q%&Uk`gnYM+Oh_<;-pLi-Oy^GUy8&#@7G6*0 zh`=BL{!~Z6DJld!tI6LDC-e#sQP3v7w_tj=tDd6udhFLNntt`VeM8w|zt4I3-1hEC zRwWsKh@>7Fn0RL*mn@dSP>ALPoS*7%J+aI0RHtP91x>En9lv|S$j(?Ek`+P6bos7W z*4-C!!F1_*{Tg1LE@~^^9({CEWcyfFN@hmDp&mag@VTX3M^5)On1v=@c{f(lr0$8A z&kHT?4gC1o0cH^B)$J3}bTRCkYUUUm%UYwW#z}{_U3N+nX zHJpEq$I=be#pn|put5MMAZ!p40NB{sN5n*m$&Jw2Zquy_fXA(ze^%%DAMLcZ?itwt zR~ZW#-*s5K?dCJhVXoPQ#dWr|*zH*AasGEItH=6>qlx)0hh5NbM&r_;5Lh1a9^rLV zU$s3`csHH5xYT)Fp~+UIqr+*txUZFfjh$usfb-a~791esxdcmBILx$MmpO$_+Rc15a;_{_`_Caz$C; zJIwK1>50u_Piz`Hc4qJ=pPU=^Hn5LGuKdE*s7{KQaq9fGB{Kqdyf~OEl$o((G_!7Z z;*5pi$rqx^yKyxEGC_o;{UM{ej7*`N$!iE$7n?1r^;qV#3knVjV-uAC2|@by1j7Rn zk;+h*jlEV>J8$$5P38w-1#JS6aj0^pxqM?CYB&cKqK}7s4t*;QFj~@9#nUgvGXuF2 z2t?$wDW3@Y>t^t#RG#%Tgb=bT9}2k}eYb&Aruk2v*1)Q^#&chPZTH@EQKt{)aWeav zvL)@G*6e;{d$evCMl!{3zc?_M$eVu2YAy(L%kTF-yj4FM-EIp$?DWbM=R_NmDt(f%$W|`$pH^c zkMT$}`RmcRH8)NVdApr8-biEKmn&I5rqf{y*y?lR_e8R&-L`ooUDB)x*y>cz=nZ~+ zW@t{(3DYBMjGu2D*&fSPx9a{akv++RMR5f2@)^NT&TU&hBd8CHCa2_B>-Hyadu@;5 zHvpArMSshAM|PaEFdX*UEq}6hApX+!*yL(Iu`yz7b7MZb*WcUXa@xeH3)*0I@#{Nc zL$Uk;8a`E;%8ZvcN3F#bw?lZGO6`T#)`JSnWJ>2xdhI1MOO{+6oLGXv#*X&dDA%}> z^&c95ECsDLC{~?-GlSlBnfz$6WYp4(nA08VX|2Ax3rfr>O|$f>u+#Qu=$~&zGuz^M zn7Ymv7j#_E-zv=M`er13?bFYAX z)@Y`9%?ktThEw1MI+iB+fwxD?Qg0zG>-FDu_S}He7#)rI=dbPi$xDL-X5i5ck<;h3 zI;u!sG+lgUM~sM=8AN9+2ks-}YBF19=HX;<-C(k})zzrc6&-8xxW2SG`oL@Z zEj4KA?3R|=SbU(Y@Ccvy;&c>st054J;{$c$%*-qS)Q7=?i?B`kSjcnE&_WJWV3-FkM$ zb7R?(&hBhPS1#-Bsv3X}kNt{cy1($dtwmT~t1{~bblRXCX{+XJ$PYB%`(Ozj~RHwkK0G5@q0wo}gor&-+k^*QJPjSppo* zmG-9#x&iG@6s&vBX?4vG*87J37Wa{z-Z%H9ChJ)HlZ9kn(@9RMs9pBZ7L}+nSj;7j z$1)|o>1F0>vc!y4+s3Vg>A3#TtTuyLI&I>k3&X45iRyaZYwyMfBH6>HdFm=wtz*5k zdE8W2t@U5BbgWBH={mW*b^)tHCqXwQB4K@+ftG}&bF6{!T(udErHgtqtoBxNHjC_7U6d-k@Xol=MZlSdw^jS3 zWBP(Yw=JG2fjO2f{(fg{vSC&0SXE!owMTKq$7Wx5-dtNFcF{=3GI~b>a41%dOhu|n znj)%D{YGi&`+})V91t4gu~;guNz*!rU~rR2srXOb-_2}7%0yf3sSka=E*QD$H z&@4?t(|t48p(wt_n}sZwNg&>x$e%FXZ`4lz!4z~V_9^b$P{%5>#V~4~`?cC@`3weB zMN~(#H+Cn65_uR)4E*HAvEL6Q%%Mi9ERDpOh!{`39r?_MyPWmyGB}nM6ROt=d)nNm zEee1A%vp09EkaMNV*w02yQ`2b%Cir1xvEy3t3F7AB$5DuiJ2i~wL4<-#K|7QWNlTZ z#Cpx^I=cb3- z3}#+3-T(7X&G$@N>urd#Ri767a^lwYC8?Co?YBD{JPBX13uY+|dBr$38qGiS^zizvd4Q}{KU2pc zu$50A#Ln%VBGn!gMEuHOzP^s$eXwYTEgv!(?w1y&6w?j0DvYkTy9SuN+z zXmP8e#jXY%YPMWtA~0f&6r*UaoGfV8GK0hHuD+lttb->CrF@y|Y$IEe!|6iR)`Lp5 zXM~(aIcPARpU9T}M8g_R6#g)n)N{#dKWF6JWcokW-3ll2+6$Ybb%td*oyt>J?*nA; ztJ;Lpcy|L6?aPaAuPr(hd~BB8QL!z{#~DwcN&=*mY)s`yv~bfecinbxL^31*W_^zX zlOTOHS{9ByDsa&!I$DDc29KrWW9tfAhBUK5m_JQ?@njjEH2SZkvIF^|?gZRIoYxw# z6SdfsPlx@t?u|+YnBPw2Uztds-d5SLHOJ$aXZy z5&%jp@7@ue?zW%Y>G#-_UXOh^qd&05&+dzVq|;Z&-`^Zf=QXKwk+91)-IV@k1{^Rs z%8v2e&Uo(Vj+*fsjOR9uW*X{Py+NnnsU&h`W<=A)R|aAiF7B+W@ROUz*6mAOdD0B8 zqhfP!?MaR#^QKSIf7Y)R)|3M;Y#F=k#2$~M-f*YVvECC6b*w7ZL>MkA-*~O~^kAt_ z*{8w;^!2()B7 z(0uZTVnyxREyEiUx#}A*Fpw_%^ZNaLINK#^$S<~s*&xjOm0Dg{9o~OtX$S7-E4<_MZjZKSt0s#yMJRk zn<`0Wj222yM3R?ww^%1E4Z2Sa`<@$3=`%2zFI}^7e?S!rgD$sFN|HCkvXOj=rK!#k zJ~z89XojBSJA97?W7}d`opb!hmXSwxMwf)WHlnSu?7Oj?IS<%lS1z32ZmlVGdhN@5 zf-AR<>9coBG;_{F8&Bv7v^Z_!+0rY6aVcx~NVS7-sN#|(oojc;Br_PveEHG`Hq8pT z7I*svuwf{*WhAX%v1cOp+dqyh>GrD%?HbRm-5Iku*~LALnC<)7)e`tsRMBWWi9iB!3m*8m2~sUrG2`Qn;% zR-XZa==`>Tw>B8{IBaLP18-TqisdI52oKDqC{D)8OuC7l2|pG zj1)>nIS52w=x;xvqY_O0cIAr0r+ICvDXP$TzWnT-_~W}{+v5coE1Xb>zPxbSoKQuq zBH-d9+I#&DYX(cu%Y%tWw?$tbj5FX=MT=XtR{!eO3Xh!;xbh=2h|IM`qFu4vZ#RrS z^!Di1NQTUX)tRmCD^8xFOO$H|Vu^fN_j6&teaQ^}RD<6YaxUu+S+{`_7kVxhe`W@7kYx?4{V~?ldQhDC&|Pn{6@b zl$LU3EGf+yaDvahpb`oU%V)KF>@~U$GXRC? z`z!j6?+I2aQcGzFxNK?H4gb)uQsr)SI@ZEY@7)(2F+Jb}kT1y>woO>=I(AmDE9k@& zPz9a2Fl;JSym4=8>u{#JFHogp{TY~2$6{ub7&%5xqC|ig%G$1bxmcF?%n?pY>+9)s zf9uj23+KjDSqYY=%3|r^dd&nF0T0eUtHW6(Fz(ow``Lq$2cOR*k`jX8w3ACEw`$hG z$VpyKX9tR9ebNRM<&!OqcOdx`aly3E_XkEL0MOg1+{+W0Gpbm;Ae`9dJ-*drJWP?u zC5oD^Z~0VZw)Z_ArkLS%{OY9s>it**Qp9`j$2zRV_24=4AK4aNxn*o9T_k3wO*x}4 z^raNjxPn;dHg5922 z?(Kakkg*~4^r_>Zj>R(qT>1aVh78<<;lt(7$EQPsFr76U7L-_7Vj`-T!%i{^M#g@F1LsfoXL5KCF@ZgOS5_J!0P z>yVcKtAGYbfQir_;2)pv`iD6VTdi#+^73b|=5AbFWDr;qOMsYcAaGF@H$+OAGC-!b zAQ^I2hj{cyy^H4fD&$e8k4+pZs3$irm40}-Pt%gOuV;-USTd8IuWe7#x1KLLZFKTX zdvyZBjDn<8Suyf)eP6uJ&sk8|kMua8FbO~-=EJ(^*Z;fch&f(sBTu8n>n8rbAttjU zRnSdm`o4BFxmA735dpeVA$KJB>rdxi87!72y+zcLDc%CI9{CT=b6MySyEXF8;Zal;Hm7_(-A&qEAOke`uO^B~Om0Po?Xqi^U_)M2o zEqRh%p|G2XZHhd)-+trQJC9rBQx5cB2{dsipz?NxDiDEeinw;U`|_jJu$v(&LIM%C zCg`hAW>*j7^-YviQT2uXPzDJ=F!a|!*a#t~&7&-yW4rbX;ggp5Y&Baen-+Fx!g|D> zs;tY9_I(a<<#8UDE%*E$X$3{&9ataj&*23H>~$SwcXJq%izi>G^u>*8#gFw8rGo$s$KDKqKUr@nr~Nc t;-H{uSWO%hG!3hXgMy}EHE~ez{{SS(K9V4tsmuTX002ovPDHLkV1mN~*lGX( diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 44aab5213..eb4619437 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -636,23 +636,6 @@ export const providerPresets: ProviderPreset[] = [ icon: "micu", iconColor: "#000000", }, - { - name: "X-Code API", - websiteUrl: "https://x-code.cc", - apiKeyUrl: "https://x-code.cc", - settingsConfig: { - env: { - ANTHROPIC_BASE_URL: "https://x-code.cc", - ANTHROPIC_AUTH_TOKEN: "", - }, - }, - endpointCandidates: ["https://x-code.cc"], - category: "third_party", - isPartner: true, // 合作伙伴 - partnerPromotionKey: "x-code", // 促销信息 i18n key - icon: "xcode", - iconColor: "#000000", - }, { name: "CTok.ai", websiteUrl: "https://ctok.ai", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 02379839f..8d901852b 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -333,23 +333,6 @@ requires_openai_auth = true`, icon: "micu", iconColor: "#000000", }, - { - name: "X-Code API", - websiteUrl: "https://x-code.cc", - apiKeyUrl: "https://x-code.cc", - auth: generateThirdPartyAuth(""), - config: generateThirdPartyConfig( - "x-code", - "https://x-code.cc/v1", - "gpt-5.4", - ), - endpointCandidates: ["https://x-code.cc/v1"], - category: "third_party", - isPartner: true, // 合作伙伴 - partnerPromotionKey: "x-code", // 促销信息 i18n key - icon: "xcode", - iconColor: "#000000", - }, { name: "CTok.ai", websiteUrl: "https://ctok.ai", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 5c44ef295..54f256afe 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -1291,36 +1291,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, - { - name: "X-Code API", - websiteUrl: "https://x-code.cc", - apiKeyUrl: "https://x-code.cc", - settingsConfig: { - npm: "@ai-sdk/anthropic", - name: "X-Code API", - options: { - baseURL: "https://x-code.cc/v1", - apiKey: "", - setCacheKey: true, - }, - models: { - "claude-opus-4-6": { name: "Claude Opus 4.6" }, - "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - }, - }, - category: "third_party", - isPartner: true, - partnerPromotionKey: "x-code", - icon: "xcode", - iconColor: "#000000", - templateValues: { - apiKey: { - label: "API Key", - placeholder: "", - editorValue: "", - }, - }, - }, { name: "CTok.ai", websiteUrl: "https://ctok.ai", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 597f10d38..97f447a6b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -783,7 +783,6 @@ "siliconflow": "SiliconFlow is an official partner of CC Switch", "ucloud": "Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!", "micu": "Micu is an official partner of CC Switch", - "x-code": "XCodeAPI offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first order (contact admin to claim)", "ctok": "Join the CTok community on the official website and subscribe to a plan.", "ddshub": "DDSHub offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first top-up (contact group admin to claim)!", "lionccapi": "LionCCAPI offers exclusive benefits for CC Switch users. After registration, add WeChat HSQBJ088888888 and mention 'cc-switch' to receive $10 free credits!", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 707497f21..715d5697e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -783,7 +783,6 @@ "siliconflow": "SiliconFlow は CC Switch の公式パートナーです", "ucloud": "Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!", "micu": "Micu は CC Switch の公式パートナーです", - "x-code": "XCodeAPI は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回注文で 10% の追加クレジットがもらえます(管理者に連絡して受け取り)", "ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。", "ddshub": "DDSHub は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回チャージで 10% の追加クレジットがもらえます(グループ管理者に連絡して受け取り)!", "lionccapi": "LionCCAPIはCC Switchユーザーに特別特典を提供しています。登録後、WeChat HSQBJ088888888を追加し、「cc-switch」と伝えると$10分のクレジットがもらえます!", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7659c6d0e..7cd0d2516 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -783,7 +783,6 @@ "siliconflow": "硅基流动是 CC Switch 的官方合作伙伴", "ucloud": "优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!", "micu": "Micu 是 CC Switch 的官方合作伙伴", - "x-code": "XCodeAPI 为CC Switch 的用户提供特别福利,使用此链接注册后首单加赠10%的额度(联系站长领取)", "ctok": "官网加入CTok社群,订阅套餐。", "ddshub": "呆呆兽为CC Switch 的用户提供了特别福利,通过此链接注册后,首单充值可额外赠送 10% 额度(联系群主领取)!", "lionccapi": "LionCCAPI 为 CC Switch 的用户提供了特别福利,注册后添加客服微信HSQBJ088888888,发暗号cc-switch备注即可送10美金额度!", diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 5402dc151..821de195f 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -5,7 +5,6 @@ import _dds from "./dds.svg?url"; import _eflowcode from "./eflowcode.png"; import _pipellm from "./pipellm.png"; import _shengsuanyun from "./shengsuanyun.svg?url"; -import _xcode from "./xcode.svg?url"; export const icons: Record = { aicodemirror: `AICodeMirror`, @@ -84,7 +83,6 @@ export const iconUrls: Record = { eflowcode: _eflowcode, pipellm: _pipellm, shengsuanyun: _shengsuanyun, - xcode: _xcode, }; export const iconList = [ diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 161b30c3b..04e986459 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -345,13 +345,6 @@ export const iconMetadata: Record = { keywords: [], defaultColor: "currentColor", }, - xcode: { - name: "xcode", - displayName: "Xcode", - category: "tool", - keywords: ["apple", "xcode", "ide"], - defaultColor: "currentColor", - }, zhipu: { name: "zhipu", displayName: "Zhipu AI", diff --git a/src/icons/extracted/xcode.svg b/src/icons/extracted/xcode.svg deleted file mode 100644 index a5dd83f39..000000000 --- a/src/icons/extracted/xcode.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 8c94c23c3c2a2ae04ca1b031f12bb06440e35c63 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 13 Apr 2026 16:20:19 +0800 Subject: [PATCH 09/28] chore: update PIPELLM website URL to code.pipellm.ai --- src/config/claudeProviderPresets.ts | 2 +- src/config/codexProviderPresets.ts | 2 +- src/config/openclawProviderPresets.ts | 2 +- src/config/opencodeProviderPresets.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index eb4619437..55408f56b 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -820,7 +820,7 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", settingsConfig: { env: { diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 8d901852b..0e8caa046 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -391,7 +391,7 @@ model_auto_compact_token_limit = 9000000`, }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", auth: { OPENAI_API_KEY: "", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index b62c08118..d75822197 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -1009,7 +1009,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", settingsConfig: { baseUrl: "https://cc-api.pipellm.ai", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 54f256afe..f52080f6c 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -996,7 +996,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", settingsConfig: { npm: "@ai-sdk/anthropic", From c01338ac3321fdb75fda2784f24039750ba0380c Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 13 Apr 2026 23:09:27 +0800 Subject: [PATCH 10/28] fix(usage): remove unnecessary private IP restrictions from usage script SSRF protection (private IP blocking, suspicious hostname detection) was originally added for web-server threat models but is unnecessary for a local desktop app where the user already has full network access. This removal unblocks legitimate use cases like enterprise intranet APIs, Docker container addresses, and self-hosted services. Retained: HTTPS enforcement and same-origin checks which still provide meaningful security (protecting API keys in transit and preventing scripts from leaking keys to unrelated domains). --- src-tauri/src/usage_script.rs | 256 +--------------------------------- 1 file changed, 2 insertions(+), 254 deletions(-) diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index f9afdb220..7d121e4fa 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -104,8 +104,7 @@ pub async fn execute_usage_script( ) })?; - // 5. 验证请求 URL 是否安全(防止 SSRF) - // 如果提供了 base_url,则验证同源;否则只做基本安全检查 + // 5. 验证请求 URL(HTTPS 强制 + 同源检查) validate_request_url(&request.url, base_url, is_custom_template)?; // 6. 发送 HTTP 请求 @@ -468,19 +467,10 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> { )); } - // 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查) - if is_suspicious_hostname(hostname) { - return Err(AppError::localized( - "usage_script.base_url_suspicious", - "base_url 包含可疑的主机名", - "base_url contains a suspicious hostname", - )); - } - Ok(()) } -/// 验证请求 URL 是否安全(防止 SSRF) +/// 验证请求 URL 是否安全(HTTPS 强制 + 同源检查) fn validate_request_url( request_url: &str, base_url: &str, @@ -561,151 +551,11 @@ fn validate_request_url( )); } } - - // 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境) - if let Some(host) = parsed_request.host_str() { - let base_host = parsed_base.host_str().unwrap_or(""); - - // 如果 base_url 不是私有地址,则禁止访问私有IP - if !is_private_ip(base_host) && is_private_ip(host) { - return Err(AppError::localized( - "usage_script.private_ip_blocked", - "禁止访问私有 IP 地址", - "Access to private IP addresses is blocked", - )); - } - } - } else { - // 自定义模板模式:没有 base_url,需要额外的安全检查 - // 禁止访问私有 IP 地址(SSRF 防护) - if let Some(host) = parsed_request.host_str() { - if is_private_ip(host) && !is_request_loopback { - return Err(AppError::localized( - "usage_script.private_ip_blocked", - "禁止访问私有 IP 地址(localhost 除外)", - "Access to private IP addresses is blocked (localhost allowed)", - )); - } - } } Ok(()) } -/// 检查是否为私有 IP 地址 -fn is_private_ip(host: &str) -> bool { - // localhost 检查 - if host.eq_ignore_ascii_case("localhost") { - return true; - } - - // 尝试解析为IP地址 - if let Ok(ip_addr) = host.parse::() { - return is_private_ip_addr(ip_addr); - } - - // 如果不是IP地址,不是私有IP - false -} - -/// 使用标准库API检查IP地址是否为私有地址 -fn is_private_ip_addr(ip: std::net::IpAddr) -> bool { - match ip { - std::net::IpAddr::V4(ipv4) => { - let octets = ipv4.octets(); - - // 0.0.0.0/8 (包括未指定地址) - if octets[0] == 0 { - return true; - } - - // RFC1918 私有地址范围 - // 10.0.0.0/8 - if octets[0] == 10 { - return true; - } - - // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255) - if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 { - return true; - } - - // 192.168.0.0/16 - if octets[0] == 192 && octets[1] == 168 { - return true; - } - - // 其他特殊地址 - // 169.254.0.0/16 (链路本地地址) - if octets[0] == 169 && octets[1] == 254 { - return true; - } - - // 127.0.0.0/8 (环回地址) - if octets[0] == 127 { - return true; - } - - false - } - std::net::IpAddr::V6(ipv6) => { - // IPv6 私有地址检查 - 使用标准库方法 - - // ::1 (环回地址) - if ipv6.is_loopback() { - return true; - } - - // 唯一本地地址 (fc00::/7) - // Rust 1.70+ 可以使用 ipv6.is_unique_local() - // 但为了兼容性,我们手动检查 - let first_segment = ipv6.segments()[0]; - if (first_segment & 0xfe00) == 0xfc00 { - return true; - } - - // 链路本地地址 (fe80::/10) - if (first_segment & 0xffc0) == 0xfe80 { - return true; - } - - // 未指定地址 :: - if ipv6.is_unspecified() { - return true; - } - - false - } - } -} - -/// 检查是否为可疑的主机名(只检查明显不安全的模式) -fn is_suspicious_hostname(hostname: &str) -> bool { - // 空主机名 - if hostname.is_empty() { - return true; - } - - // 检查明显的主机名格式问题 - if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") { - return true; - } - - // 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查) - if hostname.parse::().is_ok() { - // IP地址格式的,在这里不直接拒绝,让同源检查来处理 - return false; - } - - // 检查是否包含明显不当的字符 - let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0']; - if hostname.chars().any(|c| suspicious_chars.contains(&c)) { - return true; - } - - false -} - /// 判断 URL 是否指向本机(localhost / loopback) fn is_loopback_host(url: &Url) -> bool { match url.host() { @@ -720,77 +570,6 @@ fn is_loopback_host(url: &Url) -> bool { mod tests { use super::*; - #[test] - fn test_private_ip_validation() { - // 测试IPv4私网地址 - - // RFC1918私网地址 - 应该返回true - assert!(is_private_ip("10.0.0.1")); - assert!(is_private_ip("10.255.255.254")); - assert!(is_private_ip("172.16.0.1")); - assert!(is_private_ip("172.31.255.255")); - assert!(is_private_ip("192.168.0.1")); - assert!(is_private_ip("192.168.255.255")); - - // 链路本地地址 - 应该返回true - assert!(is_private_ip("169.254.0.1")); - assert!(is_private_ip("169.254.255.255")); - - // 环回地址 - 应该返回true - assert!(is_private_ip("127.0.0.1")); - assert!(is_private_ip("localhost")); - - // 公网172.x.x.x地址 - 应该返回false(这是修复的重点) - assert!(!is_private_ip("172.0.0.1")); - assert!(!is_private_ip("172.15.255.255")); - assert!(!is_private_ip("172.32.0.1")); - assert!(!is_private_ip("172.64.0.1")); - assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN - assert!(!is_private_ip("172.68.0.1")); - assert!(!is_private_ip("172.100.50.25")); - assert!(!is_private_ip("172.255.255.255")); - - // 其他公网地址 - 应该返回false - assert!(!is_private_ip("8.8.8.8")); // Google DNS - assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS - assert!(!is_private_ip("208.67.222.222")); // OpenDNS - assert!(!is_private_ip("180.76.76.76")); // Baidu DNS - - // 域名 - 应该返回false - assert!(!is_private_ip("api.example.com")); - assert!(!is_private_ip("www.google.com")); - } - - #[test] - fn test_ipv6_private_validation() { - // IPv6私网地址 - assert!(is_private_ip("::1")); // 环回地址 - assert!(is_private_ip("fc00::1")); // 唯一本地地址 - assert!(is_private_ip("fd00::1")); // 唯一本地地址 - assert!(is_private_ip("fe80::1")); // 链路本地地址 - assert!(is_private_ip("::")); // 未指定地址 - - // IPv6公网地址 - 应该返回false(修复的重点) - assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6 - assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6 - assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式) - assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网) - - // 测试包含 ::1 子串但不是环回地址的公网地址 - assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回 - assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回 - } - - #[test] - fn test_hostname_bypass_prevention() { - // 看起来像本地,但实际是域名 - assert!(!is_private_ip("127.0.0.1.evil.com")); - assert!(!is_private_ip("localhost.evil.com")); - - // 0.0.0.0 应该被视为本地/阻断 - assert!(is_private_ip("0.0.0.0")); - } - #[test] fn test_https_bypass_prevention() { // 非本地域名的 HTTP 应该被拒绝 @@ -801,37 +580,6 @@ mod tests { ); } - #[test] - fn test_edge_cases() { - // 边界情况测试 - assert!(is_private_ip("172.16.0.0")); // RFC1918起始 - assert!(is_private_ip("172.31.255.255")); // RFC1918结束 - assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始 - assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束 - assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始 - assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束 - - // 紧邻RFC1918的公网地址 - 应该返回false - assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个 - assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个 - } - - #[test] - fn test_ip_addr_parsing() { - // 测试IP地址解析功能 - let ipv4_private = "10.0.0.1".parse::().unwrap(); - assert!(is_private_ip_addr(ipv4_private)); - - let ipv4_public = "172.67.0.1".parse::().unwrap(); - assert!(!is_private_ip_addr(ipv4_public)); - - let ipv6_private = "fc00::1".parse::().unwrap(); - assert!(is_private_ip_addr(ipv6_private)); - - let ipv6_public = "2001:4860:4860::8888".parse::().unwrap(); - assert!(!is_private_ip_addr(ipv6_public)); - } - #[test] fn test_port_comparison() { // 测试端口比较逻辑是否正确处理默认端口和显式端口 From 0739b60341f1b7d86ff17ecbedababad855c400d Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 10:12:45 +0800 Subject: [PATCH 11/28] fix(proxy): reduce unnecessary Copilot premium interaction consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix request classification: treat messages containing tool_result as agent continuation instead of user-initiated, preventing false premium charges on every tool call - Add subagent detection via __SUBAGENT_MARKER__ and metadata._agent_ fallback, setting x-interaction-type=conversation-subagent - Add deterministic x-interaction-id derived from session ID to group requests into a single billing interaction - Add orphan tool_result sanitization to prevent upstream API errors that could cause retries and duplicate billing - Reorder pipeline: classify (on original body) → sanitize → merge → warmup, ensuring classification sees raw tool_result semantics - Enable warmup downgrade by default with gpt-5-mini model - Enhance session ID extraction priority chain for Copilot cache keys - Detect infinite whitespace bug in streaming tool call arguments --- src-tauri/src/proxy/copilot_optimizer.rs | 561 ++++++++++++++++-- src-tauri/src/proxy/forwarder.rs | 100 +++- src-tauri/src/proxy/providers/claude.rs | 46 +- src-tauri/src/proxy/providers/streaming.rs | 32 +- src-tauri/src/proxy/session.rs | 2 +- src-tauri/src/proxy/types.rs | 13 +- .../src/session_manager/providers/openclaw.rs | 5 +- 7 files changed, 684 insertions(+), 75 deletions(-) diff --git a/src-tauri/src/proxy/copilot_optimizer.rs b/src-tauri/src/proxy/copilot_optimizer.rs index 0092e02a5..a58d1ef26 100644 --- a/src-tauri/src/proxy/copilot_optimizer.rs +++ b/src-tauri/src/proxy/copilot_optimizer.rs @@ -8,6 +8,8 @@ //! //! 参考实现: https://github.com/caozhiyuan/copilot-api +use std::collections::HashSet; + use serde_json::Value; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -21,6 +23,9 @@ pub struct CopilotClassification { pub is_warmup: bool, /// 是否为上下文压缩请求 pub is_compact: bool, + /// 是否为 Claude Code 子代理请求(Agent tool 生成的 subagent) + /// 子代理请求应设置 x-interaction-type=conversation-subagent,不计 premium interaction + pub is_subagent: bool, } /// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。 @@ -38,12 +43,17 @@ pub struct CopilotClassification { /// /// `compact_detection`:是否启用 compact 检测。为 false 时跳过, /// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。 +/// +/// `subagent_detection`:是否启用子代理检测。为 true 时,会扫描首条用户消息 +/// 中的 `__SUBAGENT_MARKER__` 标记,将子代理请求标记为不计费。 pub fn classify_request( body: &Value, has_anthropic_beta: bool, compact_detection: bool, + subagent_detection: bool, ) -> CopilotClassification { let is_compact = compact_detection && is_compact_request(body); + let is_subagent = subagent_detection && detect_subagent(body); let messages = match body.get("messages").and_then(|m| m.as_array()) { Some(msgs) if !msgs.is_empty() => msgs, @@ -52,6 +62,7 @@ pub fn classify_request( initiator: "user", is_warmup: is_warmup_request(body, has_anthropic_beta, false), is_compact: false, + is_subagent, } } }; @@ -62,29 +73,33 @@ pub fn classify_request( // 只有 role=user 的消息需要细分 if role != "user" { return CopilotClassification { - initiator: "user", + initiator: if is_subagent { "agent" } else { "user" }, is_warmup: false, is_compact, + is_subagent, }; } - // 参考实现的判定逻辑(Messages API 路径): - // 如果 content 是数组,检查是否有非 tool_result 的 block - // 有 → "user",全是 tool_result → "agent" - // 如果 content 是字符串 → "user" + // 判定逻辑(与 copilot-api 的 merge-then-classify 效果对齐): + // 只要 content 数组中包含 tool_result → 视为工具续写 → agent + // 这覆盖了 skill/edit hook/plan follow-up 等常见场景, + // 它们的 content 通常是 [tool_result, text] 混合形态。 + // copilot-api 通过先 merge(text 吸收进 tool_result)再 classify 实现同等效果; + // 直接在分类层处理更稳健,不依赖 merge 启用状态和执行顺序。 let is_user_initiated = match last_msg.get("content") { Some(content) if content.is_array() => { let blocks = content.as_array().unwrap(); - // 存在非 tool_result block → 用户发起 - blocks + // 含有 tool_result → 工具续写(agent),否则 → 用户发起(user) + !blocks .iter() - .any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result")) + .any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")) } Some(content) if content.is_string() => true, _ => false, }; - let initiator = if !is_user_initiated || is_compact { + // 子代理请求始终标记为 agent(即使首条消息包含用户文本) + let initiator = if is_subagent || !is_user_initiated || is_compact { "agent" } else { "user" @@ -94,6 +109,7 @@ pub fn classify_request( initiator, is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact), is_compact, + is_subagent, } } @@ -123,23 +139,11 @@ fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) - fn is_compact_request(body: &Value) -> bool { // 信号 1: system prompt 以 Claude Code compact 专用前缀开头 // 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号 - if let Some(system) = body.get("system") { - let system_text = if let Some(s) = system.as_str() { - s.to_string() - } else if let Some(arr) = system.as_array() { - arr.iter() - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join(" ") - } else { - String::new() - }; - - if system_text - .starts_with("You are a helpful AI assistant tasked with summarizing conversations") - { - return true; - } + let system_text = extract_system_text(body); + if system_text + .starts_with("You are a helpful AI assistant tasked with summarizing conversations") + { + return true; } // 信号 2 & 3: 检查最后一条用户消息中的机器生成特征 @@ -259,8 +263,9 @@ pub fn merge_tool_results(mut body: Value) -> Value { /// 基于最后一条用户消息内容生成确定性 Request ID。 /// -/// 与参考实现对齐: +/// CC Switch 额外策略(参考项目 copilot-api 使用随机 UUID): /// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control) +/// - 相同内容产生相同 ID,可能帮助 Copilot 去重 /// - 找不到用户内容时退化为随机 UUID /// - 使用 UUID v4 格式 pub fn deterministic_request_id(body: &Value, session_id: &str) -> String { @@ -285,8 +290,176 @@ pub fn deterministic_request_id(body: &Value, session_id: &str) -> String { } } +/// 基于 session ID 生成稳定的 Interaction ID。 +/// +/// 与参考实现(copilot-api session.ts)对齐: +/// - 同一主对话的所有请求共享同一个 interaction ID +/// - 哈希输入: 仅 session ID(不包含消息内容,与 request ID 不同) +/// - Copilot 用此 ID 将请求聚合为同一个 "interaction",影响 premium 计费归属 +/// - 空 session ID 时返回 None(不应注入随机值,避免 interaction 碎片化) +pub fn deterministic_interaction_id(session_id: &str) -> Option { + if session_id.is_empty() { + return None; + } + + let mut hasher = Sha256::new(); + hasher.update(b"interaction:"); + hasher.update(session_id.as_bytes()); + let result = hasher.finalize(); + + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&result[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + Some(Uuid::from_bytes(bytes).to_string()) +} + +/// 检测请求是否来自 Claude Code 子代理(Agent tool 生成的 subagent)。 +/// +/// Claude Code 的 Agent tool 会在子代理首条用户消息的 `` 标签中 +/// 注入 `__SUBAGENT_MARKER__` JSON 标记,格式如: +/// ```json +/// {"__SUBAGENT_MARKER__": {"session_id": "...", "agent_id": "...", "agent_type": "..."}} +/// ``` +/// +/// 扫描策略(与 copilot-api 的 subagent-marker.ts 对齐): +/// 1. 遍历所有 user 消息(不仅是第一条,因为 context 压缩可能重排消息) +/// 2. 在消息文本中查找 `__SUBAGENT_MARKER__` 关键字 +/// 3. 找到即判定为子代理请求 +fn detect_subagent(body: &Value) -> bool { + // 信号 1: 显式 __SUBAGENT_MARKER__(Claude Code 2.x+ 自动注入) + if extract_system_text(body).contains("__SUBAGENT_MARKER__") { + return true; + } + + if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) { + for msg in messages { + if msg.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + let text = extract_text_from_message(msg); + if text.contains("__SUBAGENT_MARKER__") { + return true; + } + } + } + + // 信号 2(fallback): metadata.user_id 包含子代理标识 + // Claude Code 的 Agent tool 会将 subagent session 标记为 + // "parentSessionId_agent_agentId" 格式,检测 "_agent_" 后缀 + if let Some(user_id) = body.pointer("/metadata/user_id").and_then(|v| v.as_str()) { + // "_agent_" 是 Claude Code Agent tool 的内部标记 + if user_id.contains("_agent_") { + return true; + } + } + + // 信号 3(fallback): system prompt 包含 Claude Code 子代理的典型框架文本 + // Agent tool 生成的子代理会在 system prompt 中包含由 Agent tool 注入的任务描述, + // 但主对话的 system prompt 由 Claude Code CLI 直接生成,两者格式不同 + // 这个信号不够可靠(用户 prompt 也可能包含这些词),因此只作为辅助判据 + // 暂不启用,预留接口 + + false +} + +/// 清理孤立的 tool_result — 没有对应 tool_use 的 tool_result 转为 text block。 +/// +/// 场景:上下文压缩、消息截断等可能导致 assistant 消息中的 tool_use 被删除, +/// 但后续 user 消息中的 tool_result 仍在。上游 API 可能因不匹配而报错/重试。 +/// +/// 与 copilot-api 的 `sanitizeOrphanToolResults` 对齐。 +pub fn sanitize_orphan_tool_results(mut body: Value) -> Value { + let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) { + Some(msgs) if msgs.len() >= 2 => msgs, + _ => return body, + }; + + // Anthropic 协议要求 tool_result 紧跟其对应 tool_use 所在的 assistant turn。 + // 只检查 messages[i-1](紧邻上一条 assistant)来判定是否 orphan, + // 与参考实现 sanitizeOrphanToolResults 对齐。 + for i in 1..messages.len() { + if messages[i].get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + + // 收集紧邻上一条 assistant 的 tool_use id + let prev_tool_use_ids: HashSet = + if messages[i - 1].get("role").and_then(|r| r.as_str()) == Some("assistant") { + messages[i - 1] + .get("content") + .and_then(|c| c.as_array()) + .map(|blocks| { + blocks + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use")) + .filter_map(|b| b.get("id").and_then(|i| i.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default() + } else { + // 上一条不是 assistant → 这条 user 中的所有 tool_result 都是 orphan + HashSet::new() + }; + + let content = match messages[i] + .get_mut("content") + .and_then(|c| c.as_array_mut()) + { + Some(blocks) => blocks, + None => continue, + }; + + for block in content.iter_mut() { + if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") { + continue; + } + let tool_use_id = block + .get("tool_use_id") + .and_then(|id| id.as_str()) + .unwrap_or(""); + // 空 tool_use_id 或不在紧邻 assistant 的 tool_use 中 → orphan + if tool_use_id.is_empty() || !prev_tool_use_ids.contains(tool_use_id) { + let content_text = match block.get("content") { + Some(c) if c.is_string() => c.as_str().unwrap_or("").to_string(), + Some(c) if c.is_array() => c + .as_array() + .unwrap() + .iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"), + _ => String::new(), + }; + *block = serde_json::json!({ + "type": "text", + "text": format!("[Tool result for {}]: {}", tool_use_id, content_text) + }); + } + } + } + + body +} + // ─── 内部辅助 ───────────────────────────────── +/// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。 +fn extract_system_text(body: &Value) -> String { + match body.get("system") { + Some(s) if s.is_string() => s.as_str().unwrap_or("").to_string(), + Some(arr) if arr.is_array() => arr + .as_array() + .unwrap() + .iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(" "), + _ => String::new(), + } +} + /// 查找最后一条 user 消息的非 tool_result 文本内容。 /// /// 与参考实现的 `findLastUserContent` 对齐: @@ -433,7 +606,7 @@ mod tests { {"role": "user", "content": "Hello, please help me write some code"} ] }); - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); assert_eq!(result.initiator, "user"); assert!(!result.is_compact); } @@ -448,7 +621,7 @@ mod tests { ]} ] }); - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); assert_eq!(result.initiator, "user"); } @@ -468,15 +641,16 @@ mod tests { ]} ] }); - let result = classify_request(&body, true, true); + let result = classify_request(&body, true, true, false); assert_eq!(result.initiator, "agent"); assert!(!result.is_warmup); } #[test] fn test_classify_tool_result_with_text_block() { - // 参考实现的关键场景:tool_result + text block - // 有非 tool_result block → 仍然是 "user" + // tool_result + text block(skill/edit hook/plan follow-up 的常见形态) + // 含有 tool_result → 视为工具续写 → agent + // 与 copilot-api 的 merge-then-classify 效果对齐 let body = json!({ "model": "claude-sonnet-4-20250514", "messages": [ @@ -486,8 +660,8 @@ mod tests { ]} ] }); - let result = classify_request(&body, false, true); - assert_eq!(result.initiator, "user"); + let result = classify_request(&body, false, true, false); + assert_eq!(result.initiator, "agent"); } #[test] @@ -496,14 +670,14 @@ mod tests { "model": "claude-sonnet-4-20250514", "messages": [] }); - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); assert_eq!(result.initiator, "user"); } #[test] fn test_classify_no_messages() { let body = json!({"model": "claude-sonnet-4-20250514"}); - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); assert_eq!(result.initiator, "user"); } @@ -517,7 +691,7 @@ mod tests { {"role": "user", "content": "Here is the conversation history to summarize..."} ] }); - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); assert_eq!(result.initiator, "agent"); assert!(result.is_compact); } @@ -533,7 +707,7 @@ mod tests { ]} ] }); - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); assert_eq!(result.initiator, "agent"); assert!(result.is_compact); } @@ -548,7 +722,7 @@ mod tests { {"role": "user", "content": "Summarize"} ] }); - let result = classify_request(&body, false, false); // compact_detection=false + let result = classify_request(&body, false, false, false); // compact_detection=false assert_eq!(result.initiator, "user"); // 不被标记为 agent assert!(!result.is_compact); } @@ -562,7 +736,7 @@ mod tests { {"role": "user", "content": "Please summarize the conversation so far into a concise summary."} ] }); - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); // 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user assert_eq!(result.initiator, "user"); assert!(!result.is_compact); @@ -579,7 +753,7 @@ mod tests { ] }); // has_anthropic_beta=true, 无 tools → warmup - let result = classify_request(&body, true, true); + let result = classify_request(&body, true, true, false); assert!(result.is_warmup); } @@ -592,7 +766,7 @@ mod tests { ] }); // has_anthropic_beta=false → 不是 warmup - let result = classify_request(&body, false, true); + let result = classify_request(&body, false, true, false); assert!(!result.is_warmup); } @@ -606,7 +780,7 @@ mod tests { ] }); // 有 tools → 不是 warmup(即使有 anthropic-beta) - let result = classify_request(&body, true, true); + let result = classify_request(&body, true, true, false); assert!(!result.is_warmup); } @@ -621,7 +795,7 @@ mod tests { ]} ] }); - let result = classify_request(&body, true, true); + let result = classify_request(&body, true, true, false); assert_eq!(result.initiator, "agent"); assert!(!result.is_warmup); } @@ -831,6 +1005,44 @@ mod tests { assert!(Uuid::parse_str(&id).is_ok()); } + // === deterministic_interaction_id 测试 === + + #[test] + fn test_interaction_id_stable_for_same_session() { + let id1 = deterministic_interaction_id("session_abc"); + let id2 = deterministic_interaction_id("session_abc"); + assert_eq!(id1, id2); + } + + #[test] + fn test_interaction_id_differs_across_sessions() { + let id1 = deterministic_interaction_id("session_abc"); + let id2 = deterministic_interaction_id("session_def"); + assert_ne!(id1, id2); + } + + #[test] + fn test_interaction_id_differs_from_request_id() { + let body = json!({ + "messages": [{"role": "user", "content": "Hello"}] + }); + let interaction = deterministic_interaction_id("session_abc").unwrap(); + let request = deterministic_request_id(&body, "session_abc"); + assert_ne!(interaction, request); + } + + #[test] + fn test_interaction_id_empty_session_is_none() { + // 无 session 时不应生成 interaction ID(避免碎片化) + assert!(deterministic_interaction_id("").is_none()); + } + + #[test] + fn test_interaction_id_is_valid_uuid() { + let id = deterministic_interaction_id("test_session").unwrap(); + assert!(Uuid::parse_str(&id).is_ok()); + } + // === compact 检测增强测试 === #[test] @@ -898,4 +1110,265 @@ mod tests { }); assert!(is_compact_request(&body)); } + + // === detect_subagent 测试 === + + #[test] + fn test_detect_subagent_with_marker_in_user_message() { + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc123\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n\nPlease search the codebase for auth handlers"} + ]} + ] + }); + assert!(detect_subagent(&body)); + } + + #[test] + fn test_detect_subagent_with_marker_in_system() { + let body = json!({ + "system": "You are an agent. {\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"plan-1\",\"agent_type\":\"Plan\"}}", + "messages": [ + {"role": "user", "content": "Design the implementation plan"} + ] + }); + assert!(detect_subagent(&body)); + } + + #[test] + fn test_detect_subagent_no_marker() { + let body = json!({ + "messages": [ + {"role": "user", "content": "Hello, please help me write code"} + ] + }); + assert!(!detect_subagent(&body)); + } + + #[test] + fn test_detect_subagent_via_metadata_user_id() { + // fallback 信号: metadata.user_id 包含 "_agent_" 标记 + let body = json!({ + "metadata": { + "user_id": "session_abc123_agent_explore-1" + }, + "messages": [ + {"role": "user", "content": "Search for files"} + ] + }); + assert!(detect_subagent(&body)); + } + + #[test] + fn test_detect_subagent_normal_user_id_not_matched() { + // 普通 session ID 不应被误判 + let body = json!({ + "metadata": { + "user_id": "session_abc123" + }, + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + assert!(!detect_subagent(&body)); + } + + #[test] + fn test_classify_subagent_sets_agent_initiator() { + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n\nSearch for files"} + ]} + ] + }); + let result = classify_request(&body, false, true, true); + assert_eq!(result.initiator, "agent"); + assert!(result.is_subagent); + } + + #[test] + fn test_classify_subagent_disabled_flag() { + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n\nSearch for files"} + ]} + ] + }); + // subagent_detection=false → 不检测子代理 + let result = classify_request(&body, false, true, false); + assert_eq!(result.initiator, "user"); + assert!(!result.is_subagent); + } + + // === sanitize_orphan_tool_results 测试 === + + #[test] + fn test_sanitize_orphan_tool_results_converts_orphans() { + let body = json!({ + "messages": [ + {"role": "user", "content": "Help me"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tool_1", "content": "file contents"}, + {"type": "tool_result", "tool_use_id": "tool_orphan", "content": "orphan data"} + ]} + ] + }); + let result = sanitize_orphan_tool_results(body); + let msgs = result["messages"].as_array().unwrap(); + let last_content = msgs[2]["content"].as_array().unwrap(); + // tool_1 保留为 tool_result + assert_eq!(last_content[0]["type"], "tool_result"); + // tool_orphan 转为 text + assert_eq!(last_content[1]["type"], "text"); + assert!(last_content[1]["text"] + .as_str() + .unwrap() + .contains("tool_orphan")); + } + + #[test] + fn test_sanitize_orphan_tool_results_no_orphans() { + let body = json!({ + "messages": [ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tool_1", "content": "ok"} + ]} + ] + }); + let result = sanitize_orphan_tool_results(body.clone()); + // 无孤立 tool_result,不应有变化 + assert_eq!(result["messages"][1]["content"][0]["type"], "tool_result"); + } + + #[test] + fn test_sanitize_orphan_non_adjacent_assistant_tool_use_is_orphan() { + // tool_use 在更早的 assistant 中,但 tool_result 的紧邻上一条是另一个 assistant + // → 对 Anthropic 协议来说这个 tool_result 是 orphan + let body = json!({ + "messages": [ + {"role": "user", "content": "step 1"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "old_tool", "name": "search", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "old_tool", "content": "found it"} + ]}, + {"role": "assistant", "content": [ + {"type": "text", "text": "OK, now let me think..."} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "old_tool", "content": "stale ref"} + ]} + ] + }); + let result = sanitize_orphan_tool_results(body); + let msgs = result["messages"].as_array().unwrap(); + // messages[2]: 紧邻 assistant 有 old_tool → 保留 + assert_eq!(msgs[2]["content"][0]["type"], "tool_result"); + // messages[4]: 紧邻 assistant 无 tool_use → orphan → text + assert_eq!(msgs[4]["content"][0]["type"], "text"); + } + + #[test] + fn test_sanitize_orphan_prev_not_assistant() { + // tool_result 紧邻上一条是 user(非 assistant)→ 全部 orphan + let body = json!({ + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "data"} + ]} + ] + }); + let result = sanitize_orphan_tool_results(body); + assert_eq!(result["messages"][1]["content"][0]["type"], "text"); + } + + /// 关键场景:orphan tool_result(上下文压缩丢失了紧邻 tool_use) + /// 在分类时仍应被视为 agent continuation,不能因为后续的 sanitize + /// 将其转为 text 而变成 user 请求。 + /// + /// 这个测试验证 classify_request 在原始(未 sanitize)的 body 上 + /// 正确识别 orphan tool_result 为 agent。 + #[test] + fn test_orphan_tool_result_classified_as_agent_before_sanitize() { + // 场景:最后一条 user 消息全是 tool_result,但紧邻的 assistant + // 消息里没有对应的 tool_use(因上下文压缩丢失了) + let body = json!({ + "messages": [ + {"role": "assistant", "content": "I'll help you with that."}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "orphan_tool_1", "content": "file contents here"}, + {"type": "tool_result", "tool_use_id": "orphan_tool_2", "content": "another result"} + ]} + ] + }); + // 在原始 body 上分类 → 全是 tool_result → agent + let classification = classify_request(&body, false, false, false); + assert_eq!(classification.initiator, "agent"); + + // sanitize 后 → tool_result 变为 text → 如果再分类就会变成 user + let sanitized = sanitize_orphan_tool_results(body); + let classification_after = classify_request(&sanitized, false, false, false); + assert_eq!( + classification_after.initiator, "user", + "sanitize 后 orphan tool_result 变为 text,分类变成 user — \ + 这就是为什么分类必须在 sanitize 之前执行" + ); + } + + /// orphan tool_result + text 混合场景: + /// 分类器直接识别含 tool_result 的消息为 agent(无论是否有 text block), + /// 不依赖 merge 的执行顺序。即使 orphan tool_result 后续被 sanitize 转为 text, + /// 分类结果在此之前已经确定为 agent。 + #[test] + fn test_orphan_tool_result_with_text_classified_as_agent() { + let body = json!({ + "messages": [ + {"role": "assistant", "content": "Processing..."}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "orphan_1", "content": "result data"}, + {"type": "text", "text": "Here's the output from the tool"} + ]} + ] + }); + // 含有 tool_result → agent(无论是否有 text block) + let classification = classify_request(&body, false, false, false); + assert_eq!(classification.initiator, "agent"); + + // sanitize 后 orphan tool_result 变为 text → 纯 text → 分类会变成 user + // 但正确的执行顺序是先分类再 sanitize,所以这不是问题 + let sanitized = sanitize_orphan_tool_results(body); + let classification_after = classify_request(&sanitized, false, false, false); + assert_eq!(classification_after.initiator, "user"); + } + + #[test] + fn test_sanitize_orphan_empty_tool_use_id_is_orphan() { + // tool_use_id 为空或缺失 → 无法匹配任何 tool_use → orphan + let body = json!({ + "messages": [ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "tool_1", "name": "read", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "", "content": "empty id"}, + {"type": "tool_result", "content": "missing id field"} + ]} + ] + }); + let result = sanitize_orphan_tool_results(body); + let content = result["messages"][1]["content"].as_array().unwrap(); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["type"], "text"); + } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index c4172ea3d..019a9d833 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -776,32 +776,42 @@ impl RequestForwarder { == Some("github_copilot") || base_url.contains("githubcopilot.com"); - // --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) --- + // --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) --- // 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move + // + // 执行顺序(与 copilot-api 对齐): + // 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user) + // 2. 再清洗孤立 tool_result(防止上游 API 报错) + // 3. 再合并 tool_result + text(减少 premium 计费) let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled { - // 1. Tool result 合并 — 必须在分类之前执行 - // 合并将 [tool_result, text] 变为 [tool_result(含text)], - // 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block) - if self.copilot_optimizer_config.tool_result_merging { - mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body); - } - - // 2. 在合并后的 body 上进行分类 + // 1. 在原始 body 上分类 — 必须在清洗/合并之前执行 + // 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent let has_anthropic_beta = headers.contains_key("anthropic-beta"); let classification = super::copilot_optimizer::classify_request( &mapped_body, has_anthropic_beta, self.copilot_optimizer_config.compact_detection, + self.copilot_optimizer_config.subagent_detection, ); log::debug!( - "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}", + "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}", classification.initiator, classification.is_warmup, - classification.is_compact + classification.is_compact, + classification.is_subagent ); - // 3. Warmup 小模型降级 + // 2. 孤立 tool_result 清理 — 分类完成后再清洗 + // 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费 + mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body); + + // 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)] + if self.copilot_optimizer_config.tool_result_merging { + mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body); + } + + // 4. Warmup 小模型降级 if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup { log::info!( "[Copilot] Warmup 请求降级到模型: {}", @@ -812,22 +822,52 @@ impl RequestForwarder { } // 预计算确定性 Request ID(在 body 被 move 之前) - // 使用 session_id 从 body.metadata.user_id 或请求头提取 - let session_id = body - .pointer("/metadata/user_id") + // Session 提取优先级(与 session.rs extract_from_metadata 对齐): + // 1. metadata.user_id 中的 _session_ 后缀 + // 2. metadata.session_id(直接字段) + // 3. raw metadata.user_id(整串 fallback) + // 4. x-session-id header + let metadata = body.get("metadata"); + let session_id = metadata + .and_then(|m| m.get("user_id")) .and_then(|v| v.as_str()) - .or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok())) - .unwrap_or(""); + .and_then(|uid| super::session::parse_session_from_user_id(uid)) + .or_else(|| { + metadata + .and_then(|m| m.get("session_id")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .or_else(|| { + metadata + .and_then(|m| m.get("user_id")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .or_else(|| { + headers + .get("x-session-id") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); let det_request_id = if self.copilot_optimizer_config.deterministic_request_id { Some(super::copilot_optimizer::deterministic_request_id( &mapped_body, - session_id, + &session_id, )) } else { None }; - Some((classification, det_request_id)) + // 从 session ID 派生稳定的 interaction ID(同一主对话共享) + let interaction_id = + super::copilot_optimizer::deterministic_interaction_id(&session_id); + + Some((classification, det_request_id, interaction_id)) } else { None }; @@ -1039,12 +1079,18 @@ impl RequestForwarder { } // --- Copilot 优化器:动态 header 注入 --- - if let Some((ref classification, ref det_request_id)) = copilot_optimization { + if let Some((ref classification, ref det_request_id, ref interaction_id)) = + copilot_optimization + { for (name, value) in auth_headers.iter_mut() { match name.as_str() { "x-initiator" if self.copilot_optimizer_config.request_classification => { *value = http::HeaderValue::from_static(classification.initiator); } + "x-interaction-type" if classification.is_subagent => { + // 子代理请求:conversation-subagent 不计 premium interaction + *value = http::HeaderValue::from_static("conversation-subagent"); + } "x-request-id" | "x-agent-task-id" => { if let Some(ref det_id) = det_request_id { if let Ok(hv) = http::HeaderValue::from_str(det_id) { @@ -1055,6 +1101,19 @@ impl RequestForwarder { _ => {} } } + + // x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中) + if let Some(ref iid) = interaction_id { + if let Ok(hv) = http::HeaderValue::from_str(iid) { + auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv)); + } + } + + if classification.is_subagent { + log::info!( + "[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent" + ); + } } // Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重) @@ -1069,6 +1128,7 @@ impl RequestForwarder { // 新增 headers "x-initiator", "x-interaction-type", + "x-interaction-id", "x-vscode-user-agent-library-version", "x-request-id", "x-agent-task-id", diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index aa9949995..f5825bcad 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -81,13 +81,50 @@ pub fn transform_claude_request_for_api_format( provider: &Provider, api_format: &str, ) -> Result { - match api_format { - "openai_responses" => { - let cache_key = provider + // Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key + // 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识 + // 同一会话的请求共享 cache key,提升 Copilot 缓存命中率 + let is_copilot = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("github_copilot") + || provider + .settings_config + .get("baseUrl") + .and_then(|v| v.as_str()) + .is_some_and(|u| u.contains("githubcopilot.com")); + let session_cache_key: Option = if is_copilot { + let metadata = body.get("metadata"); + // Session 提取优先级(与 forwarder 和 session.rs 统一): + // 1. metadata.user_id 中的 _session_ 后缀 + // 2. metadata.session_id(直接字段) + metadata + .and_then(|m| m.get("user_id")) + .and_then(|v| v.as_str()) + .and_then(super::super::session::parse_session_from_user_id) + .or_else(|| { + metadata + .and_then(|m| m.get("session_id")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + } else { + None + }; + + let cache_key = session_cache_key + .as_deref() + .or_else(|| { + provider .meta .as_ref() .and_then(|m| m.prompt_cache_key.as_deref()) - .unwrap_or(&provider.id); + }) + .unwrap_or(&provider.id); + match api_format { + "openai_responses" => { // Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false // + include: ["reasoning.encrypted_content"],由 transform 层统一处理。 let is_codex_oauth = provider @@ -455,6 +492,7 @@ impl ProviderAdapter for ClaudeAdapter { HeaderName::from_static("x-interaction-type"), HeaderValue::from_static("conversation-agent"), ), + // x-interaction-id 由 forwarder 按需注入(仅在有 session 时) ( HeaderName::from_static("x-vscode-user-agent-library-version"), HeaderValue::from_static("electron-fetch"), diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index d553ba6b9..2ca647d66 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -85,8 +85,16 @@ struct ToolBlockState { name: String, started: bool, pending_args: String, + /// 连续空白字符计数 — 用于检测 Copilot 无限换行 bug + /// 当 function call 参数中出现连续 20+ 空白字符时,强制终止流 + consecutive_whitespace: usize, + /// 是否已因无限空白 bug 被中止 + aborted: bool, } +/// 无限空白 bug 的连续空白字符阈值 +const INFINITE_WHITESPACE_THRESHOLD: usize = 20; + /// 创建 Anthropic SSE 流 pub fn create_anthropic_sse_stream( stream: impl Stream> + Send + 'static, @@ -297,9 +305,16 @@ pub fn create_anthropic_sse_stream( name: String::new(), started: false, pending_args: String::new(), + consecutive_whitespace: 0, + aborted: false, } }); + // 如果此 tool call 已被中止(无限空白 bug),跳过后续处理 + if state.aborted { + continue; + } + if let Some(id) = &tool_call.id { state.id = id.clone(); } @@ -328,7 +343,22 @@ pub fn create_anthropic_sse_stream( .as_ref() .and_then(|f| f.arguments.clone()); let immediate_delta = if let Some(args) = args_delta { - if state.started { + // 无限空白 bug 检测:跟踪连续空白字符 + for ch in args.chars() { + if ch.is_whitespace() { + state.consecutive_whitespace += 1; + } else { + state.consecutive_whitespace = 0; + } + } + if state.consecutive_whitespace >= INFINITE_WHITESPACE_THRESHOLD { + log::warn!( + "[Copilot] 检测到无限空白 bug (tool: {}), 中止此 tool call 流", + state.name + ); + state.aborted = true; + None + } else if state.started { Some(args) } else { state.pending_args.push_str(&args); diff --git a/src-tauri/src/proxy/session.rs b/src-tauri/src/proxy/session.rs index cf3f964fe..cd94a034d 100644 --- a/src-tauri/src/proxy/session.rs +++ b/src-tauri/src/proxy/session.rs @@ -337,7 +337,7 @@ fn extract_from_metadata(body: &serde_json::Value) -> Option { /// 从 user_id 解析 session_id /// /// 格式: `user_identifier_session_actual_session_id` -fn parse_session_from_user_id(user_id: &str) -> Option { +pub(super) fn parse_session_from_user_id(user_id: &str) -> Option { // 查找 "_session_" 分隔符 if let Some(pos) = user_id.find("_session_") { let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9 diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 08df9e6eb..744e596f7 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -291,8 +291,12 @@ pub struct CopilotOptimizerConfig { /// 确定性 Request ID(默认开启,P3 优先级) #[serde(default = "default_true")] pub deterministic_request_id: bool, - /// Warmup 小模型降级(默认关闭,P4 优先级,opt-in) - #[serde(default)] + /// Subagent 检测(默认开启)— 识别 Claude Code 子代理请求, + /// 设置 x-initiator=agent + x-interaction-type=conversation-subagent,避免子代理计费 + #[serde(default = "default_true")] + pub subagent_detection: bool, + /// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota) + #[serde(default = "default_true")] pub warmup_downgrade: bool, /// Warmup 降级使用的模型(默认 "gpt-4o-mini") #[serde(default = "default_warmup_model")] @@ -300,7 +304,7 @@ pub struct CopilotOptimizerConfig { } fn default_warmup_model() -> String { - "gpt-4o-mini".to_string() + "gpt-5-mini".to_string() } impl Default for CopilotOptimizerConfig { @@ -311,7 +315,8 @@ impl Default for CopilotOptimizerConfig { tool_result_merging: true, compact_detection: true, deterministic_request_id: true, - warmup_downgrade: false, + subagent_detection: true, + warmup_downgrade: true, warmup_model: "gpt-4o-mini".to_string(), } } diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index 25d1c851e..d0a4d92d5 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -180,7 +180,10 @@ fn load_display_names(sessions_dir: &Path) -> HashMap { map } -fn parse_session(path: &Path, display_names: Option<&HashMap>) -> Option { +fn parse_session( + path: &Path, + display_names: Option<&HashMap>, +) -> Option { let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; From d13a8d7353d1f8ef032990970e34e48c0589c7d7 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 10:32:43 +0800 Subject: [PATCH 12/28] fix(clippy): remove redundant closure in session ID parsing Replace `|uid| parse_session_from_user_id(uid)` with direct function reference to satisfy clippy::redundant_closure. --- src-tauri/src/proxy/forwarder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 019a9d833..de5c8542b 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -831,7 +831,7 @@ impl RequestForwarder { let session_id = metadata .and_then(|m| m.get("user_id")) .and_then(|v| v.as_str()) - .and_then(|uid| super::session::parse_session_from_user_id(uid)) + .and_then(super::session::parse_session_from_user_id) .or_else(|| { metadata .and_then(|m| m.get("session_id")) From c60f2048081ec346b05f07e2c2f54af976f9ebec Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 10:58:36 +0800 Subject: [PATCH 13/28] Update partner logos and sync across languages Synchronize Crazyrouter logo format and partner section updates across EN/JA/ZH README files. --- README.md | 20 ++++++++++---------- README_JA.md | 18 ++++++++++-------- README_ZH.md | 20 ++++++++++---------- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2c0687163..f6c890668 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e -SiliconFlow -Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free. +AIGoCode +Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up! @@ -47,8 +47,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e -AIGoCode -Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up! +SiliconFlow +Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free. @@ -72,21 +72,21 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via this link will receive a free 5 CNY platform trial credit! - -RightCode -Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and supports quota rollovers—unused quota from one day can be carried over and used the next day. Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via this link, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid. - - AICoding Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via this link get 10% off their first top-up! -Crazyrouter +Crazyrouter Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via this link to get $2 free credit instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra 30% bonus credit! + +RightCode +Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and supports quota rollovers—unused quota from one day can be carried over and used the next day. Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via this link, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid. + + SSSAiCode Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, offering high cost-effective official Claude service at just ¥0.5/$ equivalent, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via this link to enjoy $10 extra credit on every top-up! diff --git a/README_JA.md b/README_JA.md index 905194af2..7fc88cfae 100644 --- a/README_JA.md +++ b/README_JA.md @@ -37,8 +37,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 -SiliconFlow -SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 +AIGoCode +本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! @@ -47,8 +47,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 -AIGoCode -本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! +SiliconFlow +SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 @@ -73,9 +73,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / -RightCode -本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、こちらのリンクからご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。 - AICoding @@ -83,10 +80,15 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / -Crazyrouter +Crazyrouter Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:こちらのリンクから登録すると $2 の無料クレジット を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると 30% のボーナスクレジット が追加されます! + +RightCode +本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して出しています。特に高いコストパフォーマンスを誇る Codex の月刊プランを主力としており、未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)점이特长です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、こちらのリンクからご登録くと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。 + + SSSAiCode SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:こちらのリンクから登録すると、毎回のチャージで $10 の追加ボーナスを受けられます! diff --git a/README_ZH.md b/README_ZH.md index 8f5214dac..ac5cd1176 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -37,8 +37,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 -SiliconFlow -感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 +AIGoCode +感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! @@ -47,8 +47,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 -AIGoCode -感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! +SiliconFlow +感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 @@ -73,21 +73,21 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过此链接注册的用户,可得免费5元平台体验金! - -RightCode -感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打极高性价比的Codex包月套餐,提供额度转结,套餐当天用不完的额度,第二天还能接着用!充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过此链接注册,每次充值均可获得实付金额25%的按量额度! - - AICoding 感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过此链接 注册的 CC Switch 用户,首充可享受九折优惠! -Crazyrouter +Crazyrouter 感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过此链接注册即可获得 $2 免费额度,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 30% 奖励额度! + +RightCode +感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打极高性价比的Codex包月套餐,提供额度转结,套餐当天用不完的额度,第二天还能接着用!充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过此链接注册,每次充值均可获得实付金额25%的按量额度! + + SSSAiCode 感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,提供高性价比折合0.5¥/$的官方Claude服务,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用此链接注册每次充值均可享受10$的额外奖励! From 449a1712388aefaad39ec9729e7105652e3d00f5 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 11:15:25 +0800 Subject: [PATCH 14/28] Add LemonData sponsor and update partner logo formats Add new sponsor LemonData to partner section. Update Crazyrouter logo from JPG to PNG format. --- README.md | 5 +++++ README_JA.md | 5 +++++ README_ZH.md | 5 +++++ assets/partners/logos/crazyrouter.png | Bin 0 -> 54453 bytes assets/partners/logos/lemondata.png | Bin 0 -> 6254 bytes 5 files changed, 15 insertions(+) create mode 100644 assets/partners/logos/crazyrouter.png create mode 100644 assets/partners/logos/lemondata.png diff --git a/README.md b/README.md index f6c890668..19bcf0a76 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via this link and enter promo code "ccswitch" when topping up to enjoy a 10% discount! + +LemonData +Thanks to LemonData for sponsoring this project! LemonData is a high-performance AI API aggregation platform — one API key for 300+ models including GPT, Claude, Gemini, DeepSeek, and more. All models priced 30–70% below official rates with auto-failover, smart routing, and unlimited concurrency. New users get $1 free credit instantly upon registration — sign up via this linkto claim your bonus and start building right away! + + CTok Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register! diff --git a/README_JA.md b/README_JA.md index 7fc88cfae..3e10dd639 100644 --- a/README_JA.md +++ b/README_JA.md @@ -98,6 +98,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / Micu Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:こちらのリンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると 10% 割引 が適用されます! + +LemonData +LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — こちらのリンクから登録してボーナスを獲得し、すぐに開発を始めましょう! + + CTok diff --git a/README_ZH.md b/README_ZH.md index ac5cd1176..386d32d36 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -97,6 +97,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 Micu 感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"ccswitch"优惠码可享九折优惠! + +LemonData +感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过此链接注册即可领取奖励,立即开始开发! + + CTok diff --git a/assets/partners/logos/crazyrouter.png b/assets/partners/logos/crazyrouter.png new file mode 100644 index 0000000000000000000000000000000000000000..ffa5456dfa4d7f9456ba3b3d40b68c1ab38cf635 GIT binary patch literal 54453 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 z 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

FV!K_zc*?XiCGEo<3< zEca%NVGJ;y^wB>=7?X!RnSZ;D zoiWuomW9@JJLP)q%{A3n#$c<*zRYFMa$EdV?<2eu!FC1&MKJ!OB5arg;Jmd*dt6a_FmqK13g|~3Vv+;EO&;=*sS;zK<*uV{8n7h!-Jc`pXJaH zedcS;%gDy1xvZ~|@fotQ_2lmz`?C2A@OwETPSC?IGj|0&W9!9Mk9{lsmHZxhb8j`D zVVU(SW9#)t=oxzkc-zO`xC6mStc1}3D#-lc1s11c4B^@D1s)%H1eM%8&*BJVd?g1s zkB|IOPC=H}yXAA18$83~rET$1--|K8M?TNbaZBs@1eq4GD2MQ(VeMMGVF!W}Sp5i@ zwj~!J`c|L7^Nr2NsOG4SAI-aT?I!{~4 z5%~qoS8@ub)>;~gwbWv0UrU~_c9e)YuWcdYEx;qbF@B)eA0f-#Q@iRJlN;z6TZZ18 znIF8s;xqJkGgNbrsh@e6@6BKFYYqAmtN2^cGM+KD;fefXax$Xa438h{n-bNrdHrRK zG0O>FVDS}r{+67Xe@uSx6%EfV&O(-#(V80oFPbs_43+v;T*K2LF0l9v;;Z^w>Qr-k ze5Ka1atmJ>gN4X9Hm9I6_MRG%zb7xixwBjrU#Y*UsfZb^`kmMwh+ene*L3H5VtH8h zy4{Yi-4tWm>gD=N{hO#R_s;dR6H{U<*K2QW@o(4n##GC<6FUyF*KCcS;3De31WfTq zFy$Yj&NapOwh}w~_;;cWmDhXO?};q`&iCYUuyeic#AajXdv5!}du?{UX5+D}b+<8Z zvIFs#@*l=qW`9eLURl3%x8Z0Enn9UZ;vF7ksrwY)Yv-(x$mqv6!_ z_XnIR{|0xNKY@B95iKmgu-vxbaXKF5@E{({E&rID9->@hYJ(Szp6j+o!~x(lO@yl7 z(qz6$ZsxUinSP0TJP`RSV6V96MYbP~_`@HBDlkjP#I1Z<=f$m9q`^GXTYd|iljjktxjj^BjTuji)8huaZ4w$ zN{&iCfyG5NT5=0S)1qyKRRe9^m$vR}&O+q3`WhbJ8rP`ks<{NB*K-RNSMdShBfqqx zJf2_Yu-X=c_jEc}mQO?bnPXU7RrfrQ2 zG@tb?L>h2ej?CZE*Vsxv^Jaiwa(KC_^|cQDs@H<>nJz+>Ba4r%UGYU4=CWLxZ`EsL zJkXN^T%|^Ys((x!@mOAt_2iBaX~uFumG^X+pJ8cx@`@(Q(NjZ^X*HkK&QPfrX)1MB cG=DYxf6U!`N&Vz#*8l(j07*qoM6N<$f>Vu@xBvhE literal 0 HcmV?d00001 diff --git a/assets/partners/logos/lemondata.png b/assets/partners/logos/lemondata.png new file mode 100644 index 0000000000000000000000000000000000000000..e1222254066ce01377d74aa64637376ae8ec3e2d GIT binary patch literal 6254 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 Y0gKUS$l_q!0ssI207*qoM6N<$f@Uud^8f$< literal 0 HcmV?d00001 From 4a0b5c3dec54093233ce50520e65f7bdad59ed87 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 14:26:55 +0800 Subject: [PATCH 15/28] refactor: remove per-provider proxy config feature The per-provider proxy configuration (meta.proxyConfig) is removed because its scope is too narrow and covered by global proxy settings and proxy takeover mode. Users can achieve the same result via the global proxy panel. Changes: - Remove ProviderProxyConfig type (frontend TS + backend Rust) - Remove ProviderAdvancedConfig proxy UI block, keep testConfig/pricingConfig - Simplify http_client: delete build_proxy_url_from_config, build_client_for_provider, get_for_provider - Simplify forwarder/stream_check/model_fetch to use global client - Remove i18n keys (en/zh/ja) - Fix pre-existing test bug in transform.rs (extra None arg) --- src-tauri/src/provider.rs | 26 -- src-tauri/src/proxy/forwarder.rs | 10 +- src-tauri/src/proxy/http_client.rs | 98 ------- src-tauri/src/proxy/providers/transform.rs | 4 +- src-tauri/src/services/model_fetch.rs | 2 +- src-tauri/src/services/stream_check.rs | 10 +- .../providers/EditProviderDialog.tsx | 2 +- .../forms/ProviderAdvancedConfig.tsx | 247 +----------------- .../providers/forms/ProviderForm.tsx | 8 - src/i18n/locales/en.json | 5 - src/i18n/locales/ja.json | 5 - src/i18n/locales/zh.json | 5 - src/types.ts | 18 -- 13 files changed, 12 insertions(+), 428 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 4170dd3f9..af3af3f4b 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -172,29 +172,6 @@ pub struct ProviderTestConfig { pub max_retries: Option, } -/// 供应商单独的代理配置 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ProviderProxyConfig { - /// 是否启用单独配置(false 时使用全局/系统代理) - #[serde(default)] - pub enabled: bool, - /// 代理类型:http, https, socks5 - #[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")] - pub proxy_type: Option, - /// 代理主机 - #[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")] - pub proxy_host: Option, - /// 代理端口 - #[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")] - pub proxy_port: Option, - /// 代理用户名(可选) - #[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")] - pub proxy_username: Option, - /// 代理密码(可选) - #[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")] - pub proxy_password: Option, -} - /// 认证绑定来源 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -262,9 +239,6 @@ pub struct ProviderMeta { /// 供应商单独的模型测试配置 #[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")] pub test_config: Option, - /// 供应商单独的代理配置 - #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")] - pub proxy_config: Option, /// Claude API 格式(仅 Claude 供应商使用) /// - "anthropic": 原生 Anthropic Messages API,直接透传 /// - "openai_chat": OpenAI Chat Completions 格式,需要转换 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index de5c8542b..cf202118d 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1347,12 +1347,8 @@ impl RequestForwarder { self.non_streaming_timeout }; - // 解析上游代理 URL(供应商单独代理 > 全局代理 > 无) - let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); - let upstream_proxy_url: Option = proxy_config - .filter(|c| c.enabled) - .and_then(super::http_client::build_proxy_url_from_config) - .or_else(super::http_client::get_current_proxy_url); + // 获取全局代理 URL + let upstream_proxy_url: Option = super::http_client::get_current_proxy_url(); // SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest let is_socks_proxy = upstream_proxy_url @@ -1368,7 +1364,7 @@ impl RequestForwarder { let response = if is_socks_proxy { // SOCKS5 代理:只能走 reqwest(不支持 header case 保留) log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy"); - let client = super::http_client::get_for_provider(proxy_config); + let client = super::http_client::get(); let mut request = client.post(&url); if !self.non_streaming_timeout.is_zero() { request = request.timeout(self.non_streaming_timeout); diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs index 897da7fe8..1d3ddb2c9 100644 --- a/src-tauri/src/proxy/http_client.rs +++ b/src-tauri/src/proxy/http_client.rs @@ -3,7 +3,6 @@ //! 提供支持全局代理配置的 HTTP 客户端。 //! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。 -use crate::provider::ProviderProxyConfig; use once_cell::sync::OnceCell; use reqwest::Client; use std::env; @@ -334,103 +333,6 @@ pub fn mask_url(url: &str) -> String { } } -/// 根据供应商单独代理配置构建代理 URL -/// -/// 将 ProviderProxyConfig 转换为代理 URL 字符串 -pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option { - let proxy_type = config.proxy_type.as_deref().unwrap_or("http"); - let host = config.proxy_host.as_deref()?; - let port = config.proxy_port?; - - // 构建带认证的代理 URL - if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) { - if !username.is_empty() && !password.is_empty() { - return Some(format!( - "{proxy_type}://{username}:{password}@{host}:{port}" - )); - } - } - - Some(format!("{proxy_type}://{host}:{port}")) -} - -/// 根据供应商单独代理配置构建 HTTP 客户端 -/// -/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端; -/// 否则返回 None,调用方应使用全局客户端。 -/// -/// # Arguments -/// * `proxy_config` - 供应商的代理配置 -/// -/// # Returns -/// 如果配置有效则返回 Some(Client),否则返回 None -pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option { - let config = proxy_config.filter(|c| c.enabled)?; - - let proxy_url = build_proxy_url_from_config(config)?; - - log::debug!( - "[ProviderProxy] Building client with proxy: {}", - mask_url(&proxy_url) - ); - - // 构建带代理的客户端 - let proxy = match reqwest::Proxy::all(&proxy_url) { - Ok(p) => p, - Err(e) => { - log::error!( - "[ProviderProxy] Failed to create proxy from '{}': {}", - mask_url(&proxy_url), - e - ); - return None; - } - }; - - match Client::builder() - .timeout(Duration::from_secs(600)) - .connect_timeout(Duration::from_secs(30)) - .pool_max_idle_per_host(10) - .tcp_keepalive(Duration::from_secs(60)) - .no_gzip() - .no_brotli() - .no_deflate() - .proxy(proxy) - .build() - { - Ok(client) => { - log::info!( - "[ProviderProxy] Client built with proxy: {}", - mask_url(&proxy_url) - ); - Some(client) - } - Err(e) => { - log::error!("[ProviderProxy] Failed to build client: {e}"); - None - } - } -} - -/// 获取供应商专用的 HTTP 客户端 -/// -/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。 -/// -/// # Arguments -/// * `proxy_config` - 供应商的代理配置 -/// -/// # Returns -/// 返回适合该供应商的 HTTP 客户端 -pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client { - // 优先使用供应商单独代理 - if let Some(client) = build_client_for_provider(proxy_config) { - return client; - } - - // 回退到全局客户端 - get() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 5e7869cda..72e6b0f02 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -661,7 +661,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, None).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["messages"][0]["role"], "system"); assert_eq!( result["messages"][0]["content"], @@ -682,7 +682,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, None).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["messages"][0]["role"], "system"); assert_eq!( result["messages"][0]["content"], diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index 25c192867..2cc7f166b 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -41,7 +41,7 @@ pub async fn fetch_models( } let models_url = build_models_url(base_url, is_full_url)?; - let client = crate::proxy::http_client::get_for_provider(None); + let client = crate::proxy::http_client::get(); let response = client .get(&models_url) diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 7fd758efd..3d1ca197a 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -218,9 +218,8 @@ impl StreamCheckService { .or_else(|| adapter.extract_auth(provider)) .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; - // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 - let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); - let client = crate::proxy::http_client::get_for_provider(proxy_config); + // 获取 HTTP 客户端 + let client = crate::proxy::http_client::get(); let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); @@ -659,9 +658,8 @@ impl StreamCheckService { config: &StreamCheckConfig, start: Instant, ) -> Result { - // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 - let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); - let client = crate::proxy::http_client::get_for_provider(proxy_config); + // 获取 HTTP 客户端 + let client = crate::proxy::http_client::get(); let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 9b2d5bbbe..b1c7e593c 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -154,7 +154,7 @@ export function EditProviderDialog({ }, [ open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据 provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算 - provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig + provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig initialSettingsConfig, ]); diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 6023bd5f8..29a5f41f1 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -4,16 +4,11 @@ import { ChevronDown, ChevronRight, FlaskConical, - Globe, Coins, - Eye, - EyeOff, - X, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -22,7 +17,7 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import type { ProviderTestConfig, ProviderProxyConfig } from "@/types"; +import type { ProviderTestConfig } from "@/types"; export type PricingModelSourceOption = "inherit" | "request" | "response"; @@ -34,136 +29,31 @@ interface ProviderPricingConfig { interface ProviderAdvancedConfigProps { testConfig: ProviderTestConfig; - proxyConfig: ProviderProxyConfig; pricingConfig: ProviderPricingConfig; onTestConfigChange: (config: ProviderTestConfig) => void; - onProxyConfigChange: (config: ProviderProxyConfig) => void; onPricingConfigChange: (config: ProviderPricingConfig) => void; } -/** 从 ProviderProxyConfig 构建完整 URL */ -function buildProxyUrl(config: ProviderProxyConfig): string { - if (!config.proxyHost) return ""; - - const protocol = config.proxyType || "http"; - const host = config.proxyHost; - const port = config.proxyPort || (protocol === "socks5" ? 1080 : 7890); - - return `${protocol}://${host}:${port}`; -} - -/** 从完整 URL 解析为 ProviderProxyConfig */ -function parseProxyUrl(url: string): Partial { - if (!url.trim()) { - return { proxyHost: undefined, proxyPort: undefined, proxyType: undefined }; - } - - try { - const parsed = new URL(url); - const protocol = parsed.protocol.replace(":", "") as - | "http" - | "https" - | "socks5"; - const host = parsed.hostname; - const port = parsed.port ? parseInt(parsed.port, 10) : undefined; - - return { - proxyType: protocol, - proxyHost: host || undefined, - proxyPort: port, - }; - } catch { - // 尝试简单解析(不是标准 URL 格式) - const match = url.match(/^(?:(\w+):\/\/)?([^:]+)(?::(\d+))?$/); - if (match) { - return { - proxyType: (match[1] as "http" | "https" | "socks5") || "http", - proxyHost: match[2] || undefined, - proxyPort: match[3] ? parseInt(match[3], 10) : undefined, - }; - } - return {}; - } -} - export function ProviderAdvancedConfig({ testConfig, - proxyConfig, pricingConfig, onTestConfigChange, - onProxyConfigChange, onPricingConfigChange, }: ProviderAdvancedConfigProps) { const { t } = useTranslation(); const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled); - const [isProxyConfigOpen, setIsProxyConfigOpen] = useState( - proxyConfig.enabled, - ); const [isPricingConfigOpen, setIsPricingConfigOpen] = useState( pricingConfig.enabled, ); - const [showPassword, setShowPassword] = useState(false); - - // 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建) - const [proxyUrl, setProxyUrl] = useState(() => buildProxyUrl(proxyConfig)); - - // 标记是否为用户主动输入(用于区分外部更新和用户输入) - const [isUserTyping, setIsUserTyping] = useState(false); useEffect(() => { setIsTestConfigOpen(testConfig.enabled); }, [testConfig.enabled]); - // 同步外部 proxyConfig.enabled 变化到展开状态 - useEffect(() => { - setIsProxyConfigOpen(proxyConfig.enabled); - }, [proxyConfig.enabled]); - - // 同步外部 pricingConfig.enabled 变化到展开状态 useEffect(() => { setIsPricingConfigOpen(pricingConfig.enabled); }, [pricingConfig.enabled]); - // 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据) - useEffect(() => { - if (!isUserTyping) { - const newUrl = buildProxyUrl(proxyConfig); - if (newUrl !== proxyUrl) { - setProxyUrl(newUrl); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [proxyConfig.proxyType, proxyConfig.proxyHost, proxyConfig.proxyPort]); - - // 处理代理 URL 变化(用户输入时不触发 URL 重建) - const handleProxyUrlChange = (value: string) => { - setIsUserTyping(true); - setProxyUrl(value); - const parsed = parseProxyUrl(value); - onProxyConfigChange({ - ...proxyConfig, - ...parsed, - }); - }; - - // 输入框失焦时结束用户输入状态 - const handleProxyUrlBlur = () => { - setIsUserTyping(false); - }; - - // 清除代理配置 - const handleClearProxy = () => { - setProxyUrl(""); - onProxyConfigChange({ - ...proxyConfig, - proxyType: undefined, - proxyHost: undefined, - proxyPort: undefined, - proxyUsername: undefined, - proxyPassword: undefined, - }); - }; - return (

@@ -342,141 +232,6 @@ export function ProviderAdvancedConfig({
- {/* 代理配置 */} -
- -
-
-

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

- - {/* 代理地址输入框(仿照全局代理样式) */} -
- handleProxyUrlChange(e.target.value)} - onBlur={handleProxyUrlBlur} - className="font-mono text-sm flex-1" - disabled={!proxyConfig.enabled} - /> - -
- - {/* 认证信息:用户名 + 密码(可选) */} -
- - onProxyConfigChange({ - ...proxyConfig, - proxyUsername: e.target.value || undefined, - }) - } - className="font-mono text-sm flex-1" - disabled={!proxyConfig.enabled} - /> -
- - onProxyConfigChange({ - ...proxyConfig, - proxyPassword: e.target.value || undefined, - }) - } - className="font-mono text-sm pr-10" - disabled={!proxyConfig.enabled} - /> - -
-
-
-
-
- {/* 计费配置 */}
-
- - {(summaryData?.totalRequests ?? 0).toLocaleString()}{" "} - {t("usage.requestsLabel")} - - | - - {fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "} - {t("usage.costLabel")} - -
Date: Tue, 14 Apr 2026 15:56:31 +0800 Subject: [PATCH 21/28] fix: handle root-level skill repos during installation When a repo itself is a single skill (SKILL.md at repo root), the discovery phase sets directory to the repo name, but after ZIP extraction (which strips the root folder), no matching subdirectory exists. Add a fallback to check if SKILL.md exists directly in the extracted temp directory before reporting SKILL_DIR_NOT_FOUND. Fixes installation of repos like zlbigger/Google-SEOs.skill. --- src-tauri/src/services/skill.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f94dd8391..2acbdfae9 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -680,6 +680,13 @@ impl SkillService { found.display() ); source = found; + } else if temp_dir.join("SKILL.md").exists() { + // 根级 Skill:仓库本身就是 skill,SKILL.md 直接在解压根目录 + log::info!( + "Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir", + target_name, + ); + source = temp_dir.clone(); } else { let _ = fs::remove_dir_all(&temp_dir); return Err(anyhow!(format_skill_error( From 7ae89e9106ae2f038869c3fc88e1cf8a2a5ffb9f Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 16:12:48 +0800 Subject: [PATCH 22/28] perf: virtualize session message list for long conversations Replace full DOM rendering with @tanstack/react-virtual to only render visible messages (~25 DOM nodes instead of N). Wrap SessionMessageItem in React.memo to prevent unnecessary re-renders on state changes. --- package.json | 1 + pnpm-lock.yaml | 20 +++ .../sessions/SessionManagerPage.tsx | 140 +++++++++++------- .../sessions/SessionMessageItem.tsx | 11 +- 4 files changed, 108 insertions(+), 64 deletions(-) diff --git a/package.json b/package.json index 5301a971a..d07ecbf96 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.90.3", + "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-process": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c791d5e8..9fe22cfc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.3 version: 5.90.3(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.23 + version: 3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tauri-apps/api': specifier: ^2.8.0 version: 2.8.0 @@ -1468,6 +1471,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.23': + resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@tauri-apps/api@2.8.0': resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==} @@ -4275,6 +4287,14 @@ snapshots: '@tanstack/query-core': 5.90.3 react: 18.3.1 + '@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.13.23': {} + '@tauri-apps/api@2.8.0': {} '@tauri-apps/cli-darwin-arm64@2.8.1': diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index 3f16de0cc..0c4b219ad 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSessionSearch } from "@/hooks/useSessionSearch"; import { useTranslation } from "react-i18next"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -69,8 +70,7 @@ export function SessionManagerPage({ appId }: { appId: string }) { const { data, isLoading, refetch } = useSessionsQuery(); const sessions = data ?? []; const detailRef = useRef(null); - const messagesEndRef = useRef(null); - const messageRefs = useRef>(new Map()); + const scrollContainerRef = useRef(null); const [activeMessageIndex, setActiveMessageIndex] = useState( null, ); @@ -134,6 +134,20 @@ export function SessionManagerPage({ appId }: { appId: string }) { const deleteSessionMutation = useDeleteSessionMutation(); const isDeleting = deleteSessionMutation.isPending || isBatchDeleting; + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 120, + overscan: 5, + gap: 12, + }); + + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }, [selectedKey]); + useEffect(() => { const validKeys = new Set( sessions.map((session) => getSessionKey(session)), @@ -166,37 +180,36 @@ export function SessionManagerPage({ appId }: { appId: string }) { }, [messages]); const scrollToMessage = (index: number) => { - const el = messageRefs.current.get(index); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - setActiveMessageIndex(index); - setTocDialogOpen(false); // 关闭弹窗 - // 清除高亮状态 - setTimeout(() => setActiveMessageIndex(null), 2000); - } + virtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); + setActiveMessageIndex(index); + setTocDialogOpen(false); + setTimeout(() => setActiveMessageIndex(null), 2000); }; - // 清理定时器 - useEffect(() => { - return () => { - // 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。 - // 如果要严格清理,需要用 useRef 存 timer id。 - // 但对于 2秒的高亮清除,通常不清理也没大问题。 - // 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理) - }; - }, []); + const handleCopy = useCallback( + async (text: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success(successMessage); + } catch (error) { + toast.error( + extractErrorMessage(error) || + t("common.error", { defaultValue: "Copy failed" }), + ); + } + }, + [t], + ); - const handleCopy = async (text: string, successMessage: string) => { - try { - await navigator.clipboard.writeText(text); - toast.success(successMessage); - } catch (error) { - toast.error( - extractErrorMessage(error) || - t("common.error", { defaultValue: "Copy failed" }), + const handleMessageCopy = useCallback( + (content: string) => { + void handleCopy( + content, + t("sessionManager.messageCopied", { defaultValue: "已复制消息内容" }), ); - } - }; + }, + [handleCopy, t], + ); const handleResume = async () => { if (!selectedSession?.resumeCommand) return; @@ -973,9 +986,9 @@ export function SessionManagerPage({ appId }: { appId: string }) {
{/* 消息列表 */} - -
-
+
+
+
{t("sessionManager.conversationHistory", { @@ -986,7 +999,11 @@ export function SessionManagerPage({ appId }: { appId: string }) { {messages.length}
- +
+
{isLoadingMessages ? (
@@ -999,32 +1016,41 @@ export function SessionManagerPage({ appId }: { appId: string }) {

) : ( -
- {messages.map((message, index) => ( - { - if (el) messageRefs.current.set(index, el); - }} - onCopy={(content) => - handleCopy( - content, - t("sessionManager.messageCopied", { - defaultValue: "已复制消息内容", - }), - ) - } - /> - ))} -
+
+ {virtualizer + .getVirtualItems() + .map((virtualRow) => ( +
+ +
+ ))}
)}
- +
{/* 右侧目录 - 类似少数派 (大屏幕) */} void; onCopy: (content: string) => void; } -export function SessionMessageItem({ +export const SessionMessageItem = memo(function SessionMessageItem({ message, isActive, searchQuery, - setRef, onCopy, }: SessionMessageItemProps) { const { t } = useTranslation(); return (
); -} +}); From 0383c13e665ec0533ff09f62d67a01320b85cabf Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 16:16:57 +0800 Subject: [PATCH 23/28] perf: collapse long session messages to reduce text layout cost Messages over 3000 characters are now truncated to 1500 characters by default, with an expand/collapse toggle. This avoids expensive browser text layout for large AI responses containing code or tool output. --- .../sessions/SessionMessageItem.tsx | 44 +++++++++++++++++-- src/i18n/locales/en.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/zh.json | 4 +- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/components/sessions/SessionMessageItem.tsx b/src/components/sessions/SessionMessageItem.tsx index 7189a048b..88035b830 100644 --- a/src/components/sessions/SessionMessageItem.tsx +++ b/src/components/sessions/SessionMessageItem.tsx @@ -1,5 +1,5 @@ -import { memo } from "react"; -import { Copy } from "lucide-react"; +import { memo, useState } from "react"; +import { ChevronDown, ChevronUp, Copy } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -17,6 +17,9 @@ import { highlightText, } from "./utils"; +const COLLAPSE_THRESHOLD = 3000; +const COLLAPSED_LENGTH = 1500; + interface SessionMessageItemProps { message: SessionMessage; isActive: boolean; @@ -31,6 +34,13 @@ export const SessionMessageItem = memo(function SessionMessageItem({ onCopy, }: SessionMessageItemProps) { const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const isLong = message.content.length > COLLAPSE_THRESHOLD; + const displayContent = + isLong && !expanded + ? message.content.slice(0, COLLAPSED_LENGTH) + "…" + : message.content; return (
{searchQuery - ? highlightText(message.content, searchQuery) - : message.content} + ? highlightText(displayContent, searchQuery) + : displayContent}
+ {isLong && ( + + )}
); }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 714606148..f25b83bc3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -725,7 +725,9 @@ "copyCommand": "Copy Command", "copyMessage": "Copy Message", "messageCopied": "Message copied", - "conversationHistory": "Conversation History" + "conversationHistory": "Conversation History", + "expandContent": "Expand full content", + "collapseContent": "Collapse" }, "console": { "providerSwitchReceived": "Received provider switch event:", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cd3ffdada..9380eb2c0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -725,7 +725,9 @@ "copyCommand": "コマンドをコピー", "copyMessage": "メッセージをコピー", "messageCopied": "メッセージがコピーされました", - "conversationHistory": "会話履歴" + "conversationHistory": "会話履歴", + "expandContent": "全文を表示", + "collapseContent": "折りたたむ" }, "console": { "providerSwitchReceived": "プロバイダー切り替えイベントを受信:", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1e7afe320..cb32835e1 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -725,7 +725,9 @@ "copyCommand": "复制命令", "copyMessage": "复制消息", "messageCopied": "已复制消息内容", - "conversationHistory": "对话记录" + "conversationHistory": "对话记录", + "expandContent": "展开完整内容", + "collapseContent": "收起" }, "console": { "providerSwitchReceived": "收到供应商切换事件:", From 8b851dc60242958916864c70d2a579f0b91df598 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 16:50:22 +0800 Subject: [PATCH 24/28] fix: auto-expand collapsed messages when search matches hidden content When a search query matches text beyond the collapse point, the message automatically expands to show the highlighted match. Also adds aria-expanded for accessibility. --- src/components/sessions/SessionMessageItem.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/sessions/SessionMessageItem.tsx b/src/components/sessions/SessionMessageItem.tsx index 88035b830..2975b2760 100644 --- a/src/components/sessions/SessionMessageItem.tsx +++ b/src/components/sessions/SessionMessageItem.tsx @@ -37,10 +37,15 @@ export const SessionMessageItem = memo(function SessionMessageItem({ const [expanded, setExpanded] = useState(false); const isLong = message.content.length > COLLAPSE_THRESHOLD; - const displayContent = - isLong && !expanded - ? message.content.slice(0, COLLAPSED_LENGTH) + "…" - : message.content; + const hasSearchMatch = + isLong && + !expanded && + !!searchQuery && + message.content.toLowerCase().includes(searchQuery.toLowerCase()); + const collapsed = isLong && !expanded && !hasSearchMatch; + const displayContent = collapsed + ? message.content.slice(0, COLLAPSED_LENGTH) + "…" + : message.content; return (
- {isLong && ( + {isLong && !hasSearchMatch && (