From 24555275bbf78e5fdae50113edabaeea3eb9fba3 Mon Sep 17 00:00:00 2001 From: Satoru Date: Wed, 8 Apr 2026 21:17:19 +0800 Subject: [PATCH 01/77] fix: add padding to toolbar container to prevent add button shadow clipping (#1951) The orange add button's circular shadow appeared square because the parent overflow-x-hidden container was clipping it at the edges. --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 0665c1889..750c5ba2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1035,7 +1035,7 @@ function App() { )}
Date: Sun, 5 Apr 2026 16:54:51 +0800 Subject: [PATCH 02/77] feat: add official balance query for DeepSeek, StepFun, SiliconFlow, OpenRouter, Novita AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "Official" (官方) template type in the usage query panel that queries account balance via each provider's native API endpoint. Follows the same zero-script pattern as Token Plan — Rust handles the HTTP call, frontend auto-detects the provider from base URL. Supported providers and endpoints: - DeepSeek: GET /user/balance - StepFun: GET /v1/accounts - SiliconFlow: GET /v1/user/info (cn + com) - OpenRouter: GET /api/v1/credits - Novita AI: GET /v3/user/balance --- src-tauri/src/commands/balance.rs | 6 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/provider.rs | 25 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/services/balance.rs | 411 ++++++++++++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src/components/UsageScriptModal.tsx | 105 ++++++- src/config/constants.ts | 1 + src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh.json | 2 + src/lib/api/subscription.ts | 5 + 12 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/balance.rs create mode 100644 src-tauri/src/services/balance.rs diff --git a/src-tauri/src/commands/balance.rs b/src-tauri/src/commands/balance.rs new file mode 100644 index 000000000..ea7a3e109 --- /dev/null +++ b/src-tauri/src/commands/balance.rs @@ -0,0 +1,6 @@ +use crate::provider::UsageResult; + +#[tauri::command] +pub async fn get_balance(base_url: String, api_key: String) -> Result { + crate::services::balance::get_balance(&base_url, &api_key).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4261b3da2..d0be4717b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod auth; +mod balance; mod coding_plan; mod config; mod copilot; @@ -31,6 +32,7 @@ mod webdav_sync; mod workspace; pub use auth::*; +pub use balance::*; pub use coding_plan::*; pub use config::*; pub use copilot::*; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index d505efb92..565cc8bac 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -14,6 +14,7 @@ use std::str::FromStr; // 常量定义 const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot"; const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan"; +const TEMPLATE_TYPE_BALANCE: &str = "balance"; const COPILOT_UNIT_PREMIUM: &str = "requests"; /// 获取所有供应商 @@ -268,6 +269,30 @@ pub async fn queryProviderUsage( }); } + // ── 官方余额查询路径 ── + if template_type == TEMPLATE_TYPE_BALANCE { + let settings_config = provider + .map(|p| &p.settings_config) + .cloned() + .unwrap_or_default(); + let env = settings_config.get("env"); + let base_url = env + .and_then(|e| e.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let api_key = env + .and_then(|e| { + e.get("ANTHROPIC_AUTH_TOKEN") + .or_else(|| e.get("ANTHROPIC_API_KEY")) + }) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + return crate::services::balance::get_balance(base_url, api_key) + .await + .map_err(|e| format!("Failed to query balance: {e}")); + } + // ── 通用 JS 脚本路径 ── ProviderService::query_usage(state.inner(), app_type, &providerId) .await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 277713bc7..3b33f6f9e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -893,6 +893,7 @@ pub fn run() { // subscription quota commands::get_subscription_quota, commands::get_coding_plan_quota, + commands::get_balance, // New MCP via config.json (SSOT) commands::get_mcp_config, commands::upsert_mcp_server_in_config, diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs new file mode 100644 index 000000000..fa6b5d5a1 --- /dev/null +++ b/src-tauri/src/services/balance.rs @@ -0,0 +1,411 @@ +//! 供应商余额查询服务 +//! +//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。 +//! 返回 UsageResult 格式,与现有用量系统无缝对接。 + +use crate::provider::{UsageData, UsageResult}; +use std::time::Duration; + +// ── 供应商检测 ────────────────────────────────────────────── + +enum BalanceProvider { + DeepSeek, + StepFun, + SiliconFlow, + SiliconFlowEn, + OpenRouter, + NovitaAI, +} + +fn detect_provider(base_url: &str) -> Option { + let url = base_url.to_lowercase(); + if url.contains("api.deepseek.com") { + Some(BalanceProvider::DeepSeek) + } else if url.contains("api.stepfun.ai") || url.contains("api.stepfun.com") { + Some(BalanceProvider::StepFun) + } else if url.contains("api.siliconflow.cn") { + Some(BalanceProvider::SiliconFlow) + } else if url.contains("api.siliconflow.com") { + Some(BalanceProvider::SiliconFlowEn) + } else if url.contains("openrouter.ai") { + Some(BalanceProvider::OpenRouter) + } else if url.contains("api.novita.ai") { + Some(BalanceProvider::NovitaAI) + } else { + None + } +} + +fn make_error(msg: String) -> UsageResult { + UsageResult { + success: false, + data: None, + error: Some(msg), + } +} + +fn make_auth_error(status: reqwest::StatusCode) -> UsageResult { + UsageResult { + success: false, + data: Some(vec![UsageData { + plan_name: None, + remaining: None, + total: None, + used: None, + unit: None, + is_valid: Some(false), + invalid_message: Some(format!("Authentication failed (HTTP {status})")), + extra: None, + }]), + error: Some(format!("Authentication failed (HTTP {status})")), + } +} + +// ── DeepSeek ──────────────────────────────────────────────── +// GET https://api.deepseek.com/user/balance +// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available } + +async fn query_deepseek(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.deepseek.com/user/balance") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let is_available = body + .get("is_available") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let mut data = Vec::new(); + + if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) { + for info in infos { + let currency = info + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("CNY"); + let total = parse_f64_field(info, "total_balance"); + + data.push(UsageData { + plan_name: Some(currency.to_string()), + remaining: total, + total: None, + used: None, + unit: Some(currency.to_string()), + is_valid: Some(is_available), + invalid_message: if !is_available { + Some("Insufficient balance".to_string()) + } else { + None + }, + extra: None, + }); + } + } + + UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + } +} + +// ── StepFun ───────────────────────────────────────────────── +// GET https://api.stepfun.com/v1/accounts +// Response: { object, type, balance, total_cash_balance, total_voucher_balance } + +async fn query_stepfun(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.stepfun.com/v1/accounts") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let balance = parse_f64_field(&body, "balance").unwrap_or(0.0); + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("StepFun".to_string()), + remaining: Some(balance), + total: None, + used: None, + unit: Some("CNY".to_string()), + is_valid: Some(true), + invalid_message: None, + extra: None, + }]), + error: None, + } +} + +// ── SiliconFlow ───────────────────────────────────────────── +// GET https://api.siliconflow.cn/v1/user/info (or .com for EN) +// Response: { code, data: { balance, chargeBalance, totalBalance, status } } + +async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let domain = if is_cn { + "api.siliconflow.cn" + } else { + "api.siliconflow.com" + }; + let url = format!("https://{domain}/v1/user/info"); + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let data = match body.get("data") { + Some(d) => d, + None => return make_error("Missing 'data' field in response".to_string()), + }; + + let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0); + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("SiliconFlow".to_string()), + remaining: Some(total_balance), + total: None, + used: None, + unit: Some("CNY".to_string()), + is_valid: Some(true), + invalid_message: None, + extra: None, + }]), + error: None, + } +} + +// ── OpenRouter ────────────────────────────────────────────── +// GET https://openrouter.ai/api/v1/credits +// Response: { data: { total_credits, total_usage } } + +async fn query_openrouter(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://openrouter.ai/api/v1/credits") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let data = body.get("data").unwrap_or(&body); + let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0); + let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0); + let remaining = total_credits - total_usage; + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("OpenRouter".to_string()), + remaining: Some(remaining), + total: Some(total_credits), + used: Some(total_usage), + unit: Some("USD".to_string()), + is_valid: Some(remaining > 0.0), + invalid_message: if remaining <= 0.0 { + Some("No credits remaining".to_string()) + } else { + None + }, + extra: None, + }]), + error: None, + } +} + +// ── Novita AI ─────────────────────────────────────────────── +// GET https://api.novita.ai/v3/user/balance +// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices } +// 金额单位:0.0001 USD + +async fn query_novita(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.novita.ai/v3/user/balance") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + // Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD + let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0; + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("Novita AI".to_string()), + remaining: Some(available), + total: None, + used: None, + unit: Some("USD".to_string()), + is_valid: Some(available > 0.0), + invalid_message: if available <= 0.0 { + Some("No balance remaining".to_string()) + } else { + None + }, + extra: None, + }]), + error: None, + } +} + +// ── 工具函数 ──────────────────────────────────────────────── + +/// 解析 JSON 字段为 f64,兼容数字和字符串格式 +fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option { + obj.get(field).and_then(|v| { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) +} + +// ── 公开入口 ──────────────────────────────────────────────── + +pub async fn get_balance(base_url: &str, api_key: &str) -> Result { + if api_key.trim().is_empty() { + return Ok(UsageResult { + success: false, + data: None, + error: Some("API key is empty".to_string()), + }); + } + + let provider = match detect_provider(base_url) { + Some(p) => p, + None => { + return Ok(UsageResult { + success: false, + data: None, + error: Some("Unknown balance provider".to_string()), + }) + } + }; + + let result = match provider { + BalanceProvider::DeepSeek => query_deepseek(api_key).await, + BalanceProvider::StepFun => query_stepfun(api_key).await, + BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await, + BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await, + BalanceProvider::OpenRouter => query_openrouter(api_key).await, + BalanceProvider::NovitaAI => query_novita(api_key).await, + }; + + Ok(result) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ce5cc4dd0..64c1f641b 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod balance; pub mod coding_plan; pub mod config; pub mod env_checker; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 131deb267..19529f933 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -98,6 +98,9 @@ const generatePresetTemplates = ( // Coding Plan 模板不需要脚本,使用专用 Rust 查询 [TEMPLATE_TYPES.TOKEN_PLAN]: "", + + // 官方余额查询模板不需要脚本,使用专用 Rust 查询 + [TEMPLATE_TYPES.BALANCE]: "", }); // 模板名称国际化键映射 @@ -107,6 +110,7 @@ const TEMPLATE_NAME_KEYS: Record = { [TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI", [TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot", [TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan", + [TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance", }; /** Coding Plan 供应商选项 */ @@ -124,6 +128,25 @@ const TOKEN_PLAN_PROVIDERS = [ }, ] as const; +/** 官方余额查询供应商检测 */ +const BALANCE_PROVIDERS = [ + { id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i }, + { id: "stepfun", label: "StepFun", pattern: /api\.stepfun\.(ai|com)/i }, + { + id: "siliconflow", + label: "SiliconFlow", + pattern: /api\.siliconflow\.(cn|com)/i, + }, + { id: "openrouter", label: "OpenRouter", pattern: /openrouter\.ai/i }, + { id: "novita", label: "Novita AI", pattern: /api\.novita\.ai/i }, +] as const; + +/** 根据 Base URL 自动检测余额查询供应商 */ +function detectBalanceProvider(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl)); +} + /** 根据 Base URL 自动检测 Coding Plan 供应商 */ function detectTokenPlanProvider(baseUrl: string | undefined): string | null { if (!baseUrl) return null; @@ -219,6 +242,16 @@ const UsageScriptModal: React.FC = ({ }; } + // 新配置:如果 URL 匹配官方余额查询供应商,自动初始化 + if (detectBalanceProvider(providerCredentials.baseUrl)) { + return { + enabled: false, + language: "javascript" as const, + code: "", + timeout: 10, + }; + } + return { enabled: false, language: "javascript" as const, @@ -300,6 +333,10 @@ const UsageScriptModal: React.FC = ({ if (detectTokenPlanProvider(providerCredentials.baseUrl)) { return TEMPLATE_TYPES.TOKEN_PLAN; } + // 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板 + if (detectBalanceProvider(providerCredentials.baseUrl)) { + return TEMPLATE_TYPES.BALANCE; + } // 默认使用 GENERAL(与默认代码模板一致) return TEMPLATE_TYPES.GENERAL; }, @@ -331,10 +368,11 @@ const UsageScriptModal: React.FC = ({ }; const handleSave = () => { - // Copilot 和 Coding Plan 模板不需要脚本验证 + // Copilot、Coding Plan、Balance 模板不需要脚本验证 if ( selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && - selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN + selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && + selectedTemplate !== TEMPLATE_TYPES.BALANCE ) { if (script.enabled && !script.code.trim()) { toast.error(t("usageScript.scriptEmpty")); @@ -354,6 +392,7 @@ const UsageScriptModal: React.FC = ({ | "newapi" | "github_copilot" | "token_plan" + | "balance" | undefined, }; onSave(scriptWithTemplate); @@ -363,6 +402,37 @@ const UsageScriptModal: React.FC = ({ const handleTest = async () => { setTesting(true); try { + // 官方余额查询模板使用专用 API + if (selectedTemplate === TEMPLATE_TYPES.BALANCE) { + const config = provider.settingsConfig as Record; + const baseUrl: string = config?.env?.ANTHROPIC_BASE_URL ?? ""; + const apiKey: string = + config?.env?.ANTHROPIC_AUTH_TOKEN ?? + config?.env?.ANTHROPIC_API_KEY ?? + ""; + const { subscriptionApi } = await import("@/lib/api/subscription"); + const result = await subscriptionApi.getBalance(baseUrl, apiKey); + if (result.success && result.data && result.data.length > 0) { + const summary = result.data + .map((d) => { + const name = d.planName ? `[${d.planName}] ` : ""; + return `${name}${t("usage.remaining")} ${d.remaining?.toFixed(2)} ${d.unit || ""}`; + }) + .join(", "); + toast.success(`${t("usageScript.testSuccess")}${summary}`, { + duration: 3000, + closeButton: true, + }); + queryClient.setQueryData(["usage", provider.id, appId], result); + } else { + toast.error( + `${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`, + { duration: 5000 }, + ); + } + return; + } + // Coding Plan 模板使用专用 API if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) { const config = provider.settingsConfig as Record; @@ -558,6 +628,16 @@ const UsageScriptModal: React.FC = ({ codingPlanProvider: script.codingPlanProvider || autoDetected || "kimi", }); + } else if (presetName === TEMPLATE_TYPES.BALANCE) { + // 官方余额查询模板不需要脚本,使用 Rust 原生查询 + setScript({ + ...script, + code: "", + apiKey: undefined, + baseUrl: undefined, + accessToken: undefined, + userId: undefined, + }); } setSelectedTemplate(presetName); } @@ -746,6 +826,27 @@ const UsageScriptModal: React.FC = ({
)} + {/* 官方余额查询模式:自动提示 */} + {selectedTemplate === TEMPLATE_TYPES.BALANCE && ( +
+

+ {t("usageScript.balanceHint")} +

+
+ {BALANCE_PROVIDERS.filter((bp) => + bp.pattern.test(providerCredentials.baseUrl || ""), + ).map((bp) => ( + + {bp.label} + + ))} +
+
+ )} + {/* Coding Plan 模式:供应商选择 */} {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
diff --git a/src/config/constants.ts b/src/config/constants.ts index de9db8a68..158d983e7 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -10,6 +10,7 @@ export const TEMPLATE_TYPES = { NEW_API: "newapi", GITHUB_COPILOT: "github_copilot", TOKEN_PLAN: "token_plan", + BALANCE: "balance", } as const; export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 09f421fce..4f148e3e0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1091,8 +1091,10 @@ "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", + "templateBalance": "Official", "copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed", "tokenPlanHint": "Automatically uses the provider's API Key and Base URL to query Token Plan quota", + "balanceHint": "Automatically uses the provider's API Key to query account balance", "resetDate": "Reset date", "premiumRequests": "Premium Requests", "credentialsConfig": "Credentials", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 145355ef3..0e3bb23c0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1091,8 +1091,10 @@ "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", + "templateBalance": "公式", "copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要", "tokenPlanHint": "プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ", + "balanceHint": "プロバイダーのAPI Keyを使用してアカウント残高を自動クエリ", "resetDate": "リセット日", "premiumRequests": "Premium リクエスト", "credentialsConfig": "認証情報", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 75fc9232a..b70027bd9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1091,8 +1091,10 @@ "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", + "templateBalance": "官方", "copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证", "tokenPlanHint": "自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度", + "balanceHint": "自动使用供应商的 API Key 查询账户余额", "resetDate": "重置日期", "premiumRequests": "Premium 请求", "credentialsConfig": "凭证配置", diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index 9c572f0ec..79f29eb19 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -9,4 +9,9 @@ export const subscriptionApi = { apiKey: string, ): Promise => invoke("get_coding_plan_quota", { baseUrl, apiKey }), + getBalance: ( + baseUrl: string, + apiKey: string, + ): Promise => + invoke("get_balance", { baseUrl, apiKey }), }; From 9192b6f988ab19e302a3228bded4922f36dbd6fe Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 17:26:45 +0800 Subject: [PATCH 03/77] fix: align usage display across provider cards by always rendering action buttons Test and ConfigureUsage buttons are now always rendered instead of conditionally, with a disabled style for providers that don't support them (e.g. official subscriptions). This ensures consistent button container width so the usage display aligns uniformly across all cards. --- src/components/providers/ProviderActions.tsx | 57 ++++++++++---------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index d776ae21a..98db9307d 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -246,34 +246,37 @@ export function ProviderActions({ - {onTest && ( - - )} + - {onConfigureUsage && ( - - )} + {onOpenTerminal && ( +
{isLoading ? ( @@ -290,8 +364,14 @@ const UnifiedSkillsPanel = React.forwardRef< handleUninstall(skill)} + onUpdate={() => handleUpdateSkill(skill)} isLast={index === skills.length - 1} /> ))} @@ -339,15 +419,21 @@ UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel"; interface InstalledSkillListItemProps { skill: InstalledSkill; + hasUpdate?: boolean; + isUpdating?: boolean; onToggleApp: (id: string, app: AppId, enabled: boolean) => void; onUninstall: () => void; + onUpdate?: () => void; isLast?: boolean; } const InstalledSkillListItem: React.FC = ({ skill, + hasUpdate, + isUpdating, onToggleApp, onUninstall, + onUpdate, isLast, }) => { const { t } = useTranslation(); @@ -387,6 +473,14 @@ const InstalledSkillListItem: React.FC = ({ {sourceLabel} + {hasUpdate && ( + + {t("skills.updateAvailable")} + + )}
{skill.description && (

= ({ appIds={MCP_SKILLS_APP_IDS} /> -

+
+ {hasUpdate && onUpdate && ( + + )} + {skillUpdates && skillUpdates.length > 0 && ( + + )}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 16102e205..663634e50 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1582,6 +1582,9 @@ "checkingUpdates": "Checking...", "noUpdates": "All skills are up to date", "updatesFound": "{{count}} skill(s) have updates available", + "updateAll": "Update All ({{count}})", + "updatingAll": "Updating...", + "updateAllSuccess": "Successfully updated {{count}} skill(s)", "error": { "skillNotFound": "Skill not found: {{directory}}", "missingRepoInfo": "Missing repository info (owner or name)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0010b3483..0a432b889 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1582,6 +1582,9 @@ "checkingUpdates": "確認中...", "noUpdates": "すべてのスキルは最新です", "updatesFound": "{{count}} 個のスキルに更新があります", + "updateAll": "すべて更新 ({{count}})", + "updatingAll": "更新中...", + "updateAllSuccess": "{{count}} 個のスキルを更新しました", "error": { "skillNotFound": "スキルが見つかりません: {{directory}}", "missingRepoInfo": "リポジトリ情報(owner または name)が不足しています", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 04658c586..dd2715b3b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1582,6 +1582,9 @@ "checkingUpdates": "检查中...", "noUpdates": "所有技能已是最新版本", "updatesFound": "发现 {{count}} 个技能有可用更新", + "updateAll": "全部更新 ({{count}})", + "updatingAll": "更新中...", + "updateAllSuccess": "已成功更新 {{count}} 个技能", "error": { "skillNotFound": "技能不存在:{{directory}}", "missingRepoInfo": "缺少仓库信息(owner 或 name)", From 6d220b2528f0960f3be8ca466d490b22514e8f68 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 19:26:34 +0800 Subject: [PATCH 07/77] fix: animate "Update All" button sliding in from the left of "Check Updates" Use max-width + opacity CSS transition so the button smoothly expands into view instead of popping in abruptly. --- src/components/skills/UnifiedSkillsPanel.tsx | 61 +++++++++++--------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 4e11f2888..701af4929 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -344,42 +344,51 @@ const UnifiedSkillsPanel = React.forwardRef< counts={enabledCounts} appIds={MCP_SKILLS_APP_IDS} /> - - {skillUpdates && skillUpdates.length > 0 && ( +
+
0 ? "200px" : "0px", + opacity: skillUpdates && skillUpdates.length > 0 ? 1 : 0, + }} + > + +
- )} +
From 8cfce8abfc623f2c7417d8a812e156bd4c00f9ac Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 20:21:51 +0800 Subject: [PATCH 08/77] feat: add skill storage location toggle between CC Switch and ~/.agents/skills Allow users to choose between storing skills in CC Switch's managed directory (~/.cc-switch/skills/) or the Agent Skills open standard directory (~/.agents/skills/). Includes migration logic that safely moves files before updating settings, with confirmation dialog for non-empty installations. --- src-tauri/src/commands/skill.rs | 13 +- src-tauri/src/lib.rs | 1 + src-tauri/src/services/skill.rs | 116 +++++++++++- src-tauri/src/settings.rs | 26 ++- src/components/settings/SettingsPage.tsx | 11 ++ .../settings/SkillStorageLocationSettings.tsx | 165 ++++++++++++++++++ src/i18n/locales/en.json | 12 ++ src/i18n/locales/ja.json | 12 ++ src/i18n/locales/zh.json | 12 ++ src/lib/api/skills.ts | 14 ++ src/lib/schemas/settings.ts | 1 + src/types.ts | 5 + 12 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 src/components/settings/SkillStorageLocationSettings.tsx diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index a55b5e6f7..8c63ec589 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -7,8 +7,8 @@ use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; use crate::error::format_skill_error; use crate::services::skill::{ - DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService, - SkillUninstallResult, SkillUpdateInfo, + DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo, + SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, }; use crate::store::AppState; use std::sync::Arc; @@ -161,6 +161,15 @@ pub async fn update_skill( .map_err(|e| e.to_string()) } +/// 迁移 Skill 存储位置 +#[tauri::command] +pub async fn migrate_skill_storage( + target: SkillStorageLocation, + app_state: State<'_, AppState>, +) -> Result { + SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dca642610..e60c3ae23 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -965,6 +965,7 @@ pub fn run() { commands::discover_available_skills, commands::check_skill_updates, commands::update_skill, + commands::migrate_skill_storage, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f26803d34..adadf54ad 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -34,6 +34,17 @@ pub enum SyncMethod { Copy, } +/// Skill 存储位置(SSOT 目录选择) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SkillStorageLocation { + /// CC Switch 管理目录 (~/.cc-switch/skills/) + #[default] + CcSwitch, + /// Agent Skills 统一标准目录 (~/.agents/skills/) + Unified, +} + /// 可发现的技能(来自仓库) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoverableSkill { @@ -174,6 +185,15 @@ pub struct SkillUpdateInfo { pub remote_hash: String, } +/// Skill 存储位置迁移结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationResult { + pub migrated_count: usize, + pub skipped_count: usize, + pub errors: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -403,9 +423,20 @@ impl SkillService { // ========== 路径管理 ========== - /// 获取 SSOT 目录(~/.cc-switch/skills/) + /// 获取 SSOT 目录(根据设置返回 ~/.cc-switch/skills/ 或 ~/.agents/skills/) pub fn get_ssot_dir() -> Result { - let dir = get_app_config_dir().join("skills"); + let location = crate::settings::get_skill_storage_location(); + let dir = match location { + SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => { + let home = dirs::home_dir().context(format_skill_error( + "GET_HOME_DIR_FAILED", + &[], + Some("checkPermission"), + ))?; + home.join(".agents").join("skills") + } + }; fs::create_dir_all(&dir)?; Ok(dir) } @@ -1052,6 +1083,87 @@ impl SkillService { Ok(count) } + /// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件) + /// + /// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。 + pub fn migrate_storage( + db: &Arc, + target: SkillStorageLocation, + ) -> Result { + let current = crate::settings::get_skill_storage_location(); + if current == target { + return Ok(MigrationResult { + migrated_count: 0, + skipped_count: 0, + errors: vec![], + }); + } + + // 1. 解析旧目录和新目录(不改设置) + let old_dir = Self::get_ssot_dir()?; + let new_dir = match target { + SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => { + let home = dirs::home_dir().context("Cannot determine home directory")?; + home.join(".agents").join("skills") + } + }; + fs::create_dir_all(&new_dir)?; + + // 2. 逐个移动 skill 目录 + let skills = db.get_all_installed_skills()?; + let mut result = MigrationResult { + migrated_count: 0, + skipped_count: 0, + errors: vec![], + }; + + for skill in skills.values() { + let src = old_dir.join(&skill.directory); + let dst = new_dir.join(&skill.directory); + + if !src.exists() { + result.skipped_count += 1; + continue; + } + if dst.exists() { + result.skipped_count += 1; + continue; + } + + // 优先 rename(同文件系统原子操作),失败则 copy+delete + match fs::rename(&src, &dst) { + Ok(()) => result.migrated_count += 1, + Err(_) => match Self::copy_dir_recursive(&src, &dst) { + Ok(()) => { + let _ = fs::remove_dir_all(&src); + result.migrated_count += 1; + } + Err(e) => { + result.errors.push(format!("{}: {e}", skill.directory)); + } + }, + } + } + + // 3. 文件移动完成后才持久化设置 + crate::settings::set_skill_storage_location(target)?; + + // 4. 刷新所有应用目录的 symlink(指向新 SSOT) + for app in AppType::all() { + let _ = Self::sync_to_app(db, &app); + } + + log::info!( + "Skill 存储迁移完成: {} 迁移, {} 跳过, {} 错误", + result.migrated_count, + result.skipped_count, + result.errors.len() + ); + + Ok(result) + } + pub fn list_backups() -> Result> { let backup_dir = Self::get_backup_dir()?; let mut entries = Vec::new(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b44929dd1..1d1dd836e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,7 +6,7 @@ use std::sync::{OnceLock, RwLock}; use crate::app_config::AppType; use crate::error::AppError; -use crate::services::skill::SyncMethod; +use crate::services::skill::{SkillStorageLocation, SyncMethod}; /// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -245,6 +245,9 @@ pub struct AppSettings { /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy #[serde(default)] pub skill_sync_method: SyncMethod, + /// Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) + #[serde(default)] + pub skill_storage_location: SkillStorageLocation, // ===== WebDAV 同步设置 ===== #[serde(default, skip_serializing_if = "Option::is_none")] @@ -307,6 +310,7 @@ impl Default for AppSettings { current_provider_opencode: None, current_provider_openclaw: None, skill_sync_method: SyncMethod::default(), + skill_storage_location: SkillStorageLocation::default(), webdav_sync: None, webdav_backup: None, backup_interval_hours: None, @@ -642,6 +646,26 @@ pub fn get_skill_sync_method() -> SyncMethod { .skill_sync_method } +// ===== Skill 存储位置管理函数 ===== + +/// 获取 Skill 存储位置配置 +pub fn get_skill_storage_location() -> SkillStorageLocation { + settings_store() + .read() + .unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }) + .skill_storage_location +} + +/// 设置 Skill 存储位置 +pub fn set_skill_storage_location(location: SkillStorageLocation) -> Result<(), AppError> { + mutate_settings(|s| { + s.skill_storage_location = location; + }) +} + // ===== 备份策略管理函数 ===== /// Get the effective auto-backup interval in hours (default 24) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index cb5361598..b01cba7dd 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -32,6 +32,7 @@ import { LanguageSettings } from "@/components/settings/LanguageSettings"; import { ThemeSettings } from "@/components/settings/ThemeSettings"; import { WindowSettings } from "@/components/settings/WindowSettings"; import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings"; +import { SkillStorageLocationSettings } from "@/components/settings/SkillStorageLocationSettings"; import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings"; import { TerminalSettings } from "@/components/settings/TerminalSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; @@ -44,6 +45,7 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { LogConfigPanel } from "@/components/settings/LogConfigPanel"; import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; +import { useInstalledSkills } from "@/hooks/useSkills"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -96,6 +98,8 @@ export function SettingsPage({ resetStatus, } = useImportExport({ onImportSuccess }); + const { data: installedSkills } = useInstalledSkills(); + const [activeTab, setActiveTab] = useState("general"); const [showRestartPrompt, setShowRestartPrompt] = useState(false); @@ -229,6 +233,13 @@ export function SettingsPage({ settings={settings} onChange={handleAutoSave} /> + + updateSettings({ skillStorageLocation: location }) + } + /> diff --git a/src/components/settings/SkillStorageLocationSettings.tsx b/src/components/settings/SkillStorageLocationSettings.tsx new file mode 100644 index 000000000..1aae08f87 --- /dev/null +++ b/src/components/settings/SkillStorageLocationSettings.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { skillsApi, type MigrationResult } from "@/lib/api/skills"; +import type { SkillStorageLocation } from "@/types"; + +export interface SkillStorageLocationSettingsProps { + value: SkillStorageLocation; + installedCount: number; + onMigrated: (target: SkillStorageLocation) => void; +} + +export function SkillStorageLocationSettings({ + value, + installedCount, + onMigrated, +}: SkillStorageLocationSettingsProps) { + const { t } = useTranslation(); + const [pendingTarget, setPendingTarget] = + useState(null); + const [isMigrating, setIsMigrating] = useState(false); + + const handleSelect = (target: SkillStorageLocation) => { + if (target === value) return; + if (installedCount > 0) { + setPendingTarget(target); + } else { + doMigrate(target); + } + }; + + const doMigrate = async (target: SkillStorageLocation) => { + setIsMigrating(true); + setPendingTarget(null); + try { + const result: MigrationResult = await skillsApi.migrateStorage(target); + if (result.errors.length > 0) { + toast.warning( + t("settings.skillStorage.migrationPartial", { + migrated: result.migratedCount, + errors: result.errors.length, + }), + ); + } else { + toast.success( + t("settings.skillStorage.migrationSuccess", { + count: result.migratedCount, + }), + ); + } + onMigrated(target); + } catch (error) { + toast.error(String(error)); + } finally { + setIsMigrating(false); + } + }; + + return ( +
+
+

+ {t("settings.skillStorage.title")} +

+

+ {t("settings.skillStorage.description")} +

+
+
+ handleSelect("cc_switch")} + > + {t("settings.skillStorage.ccSwitch")} + + handleSelect("unified")} + > + {isMigrating && value !== "unified" ? ( + + ) : null} + {t("settings.skillStorage.unified")} + +
+

+ {value === "unified" + ? t("settings.skillStorage.unifiedHint") + : t("settings.skillStorage.ccSwitchHint")} +

+ + {/* 迁移确认对话框 */} + { + if (!open) setPendingTarget(null); + }} + > + + + {t("settings.skillStorage.confirmTitle")} + + {t("settings.skillStorage.confirmMessage", { + count: installedCount, + })} + + + + + + + + +
+ ); +} + +interface StorageButtonProps { + active: boolean; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function StorageButton({ + active, + disabled, + onClick, + children, +}: StorageButtonProps) { + return ( + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 663634e50..9b8440e7c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "Skill Storage Location", + "description": "Choose where CC Switch stores the master copies of your skills", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "Skills are stored in ~/.cc-switch/skills/ and synced to each app via symlink or copy.", + "unifiedHint": "Skills are stored in ~/.agents/skills/, the Agent Skills open standard. Compatible tools (Claude Code, Codex, Gemini CLI, etc.) discover skills here natively.", + "confirmTitle": "Migrate Skill Storage", + "confirmMessage": "{{count}} skill(s) will be moved to the new location. Continue?", + "migrationSuccess": "Successfully migrated {{count}} skill(s)", + "migrationPartial": "Migrated {{migrated}} skill(s), {{errors}} error(s). Check logs for details." + }, "skillSync": { "title": "Skill Sync Method", "description": "Choose how to sync Skills files", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0a432b889..f2ccfac41 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "スキル保存場所", + "description": "CC Switch がスキルのマスターコピーを保存するディレクトリを選択します", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "スキルは ~/.cc-switch/skills/ に保存され、シンボリックリンクまたはコピーで各アプリに同期されます。", + "unifiedHint": "スキルは ~/.agents/skills/ に保存されます(Agent Skills オープン標準)。対応ツール(Claude Code、Codex、Gemini CLI など)はこのディレクトリのスキルを直接検出します。", + "confirmTitle": "スキル保存場所の移行", + "confirmMessage": "{{count}} 個のスキルを新しい場所に移動します。続行しますか?", + "migrationSuccess": "{{count}} 個のスキルを移行しました", + "migrationPartial": "{{migrated}} 個のスキルを移行、{{errors}} 個のエラー。詳細はログを確認してください。" + }, "skillSync": { "title": "スキル同期方式", "description": "スキルファイルの同期方法を選択", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index dd2715b3b..6427611ea 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "技能存储位置", + "description": "选择 CC Switch 存放技能主副本的目录", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。", + "unifiedHint": "技能存储在 ~/.agents/skills/,遵循 Agent Skills 开放标准。兼容的工具(Claude Code、Codex、Gemini CLI 等)可直接发现此目录中的技能。", + "confirmTitle": "迁移技能存储", + "confirmMessage": "将移动 {{count}} 个技能到新位置,是否继续?", + "migrationSuccess": "已成功迁移 {{count}} 个技能", + "migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志" + }, "skillSync": { "title": "Skill 同步方式", "description": "选择 Skills 的文件同步策略", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 52463975d..3e92e88b0 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -88,6 +88,13 @@ export interface SkillUpdateInfo { remoteHash: string; } +/** 存储位置迁移结果 */ +export interface MigrationResult { + migratedCount: number; + skippedCount: number; + errors: string[]; +} + /** 仓库配置 */ export interface SkillRepo { owner: string; @@ -169,6 +176,13 @@ export const skillsApi = { return await invoke("update_skill", { id }); }, + /** 迁移 Skill 存储位置 */ + async migrateStorage( + target: "cc_switch" | "unified", + ): Promise { + return await invoke("migrate_skill_storage", { target }); + }, + // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */ diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index c73781ce8..752251369 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -29,6 +29,7 @@ export const settingsSchema = z.object({ // Skill 同步设置 skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(), + skillStorageLocation: z.enum(["cc_switch", "unified"]).optional(), // WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取) webdavSync: z diff --git a/src/types.ts b/src/types.ts index 868a05cc6..f3abcb0f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -177,6 +177,9 @@ export interface ProviderMeta { // Skill 同步方式 export type SkillSyncMethod = "auto" | "symlink" | "copy"; +// Skill 存储位置 +export type SkillStorageLocation = "cc_switch" | "unified"; + // Claude API 格式类型 // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 @@ -292,6 +295,8 @@ export interface Settings { // ===== Skill 同步设置 ===== // Skill 同步方式:auto(默认,优先 symlink)、symlink、copy skillSyncMethod?: SkillSyncMethod; + // Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) + skillStorageLocation?: SkillStorageLocation; // ===== WebDAV v2 同步设置 ===== webdavSync?: WebDavSyncSettings; From 33f5d56afdcd08de05e2ce815e708a24b4554691 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 20:26:04 +0800 Subject: [PATCH 09/77] fix: move skill settings above window settings in general tab --- src/components/settings/SettingsPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index b01cba7dd..5acd350dd 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -229,10 +229,6 @@ export function SettingsPage({ settings={settings} onChange={handleAutoSave} /> - + From d51e774b20e54e209be8ef766d61d4fc6a367692 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 22:16:10 +0800 Subject: [PATCH 10/77] feat: integrate skills.sh search for discovering skills from public registry Add skills.sh API integration allowing users to search and install from a catalog of 91K+ agent skills directly within CC Switch. The search results are converted to DiscoverableSkill objects and reuse the existing install pipeline. Includes fallback directory search for repos where skills are nested in subdirectories, and filters out non-GitHub sources. --- src-tauri/src/commands/skill.rs | 13 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/services/skill.rs | 176 +++++++++++++- src/components/skills/SkillCard.tsx | 17 +- src/components/skills/SkillsPage.tsx | 328 +++++++++++++++++++++++---- src/hooks/useDebouncedValue.ts | 16 ++ src/hooks/useSkills.ts | 22 ++ src/i18n/locales/en.json | 13 ++ src/i18n/locales/ja.json | 13 ++ src/i18n/locales/zh.json | 13 ++ src/lib/api/skills.ts | 28 +++ 11 files changed, 583 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useDebouncedValue.ts diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 8c63ec589..5bec2b17b 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -9,6 +9,7 @@ use crate::error::format_skill_error; use crate::services::skill::{ DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo, SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, + SkillsShSearchResult, }; use crate::store::AppState; use std::sync::Arc; @@ -170,6 +171,18 @@ pub async fn migrate_skill_storage( SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) } +/// 搜索 skills.sh 公共目录 +#[tauri::command] +pub async fn search_skills_sh( + query: String, + limit: usize, + offset: usize, +) -> Result { + SkillService::search_skills_sh(&query, limit, offset) + .await + .map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e60c3ae23..d7490e924 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -966,6 +966,7 @@ pub fn run() { commands::check_skill_updates, commands::update_skill, commands::migrate_skill_storage, + commands::search_skills_sh, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index adadf54ad..2ce39ea02 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -194,6 +194,58 @@ pub struct MigrationResult { pub errors: Vec, } +// ========== skills.sh API 类型 ========== + +/// skills.sh API 原始响应 +/// +/// 注意:API 命名不一致(searchType 是 camelCase,duration_ms 是 snake_case), +/// 因此不能用 rename_all,需要逐字段指定。 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiResponse { + pub query: String, + #[serde(rename = "searchType")] + #[allow(dead_code)] + pub search_type: String, + pub skills: Vec, + pub count: usize, + #[allow(dead_code)] + pub duration_ms: u64, +} + +/// skills.sh API 原始技能条目 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiSkill { + pub id: String, + #[serde(rename = "skillId")] + pub skill_id: String, + pub name: String, + pub installs: u64, + pub source: String, +} + +/// skills.sh 搜索结果(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShSearchResult { + pub skills: Vec, + pub total_count: usize, + pub query: String, +} + +/// skills.sh 可安装技能(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShDiscoverableSkill { + pub key: String, + pub name: String, + pub directory: String, + pub repo_owner: String, + pub repo_name: String, + pub repo_branch: String, + pub installs: u64, + pub readme_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -614,14 +666,28 @@ impl SkillService { repo_branch = used_branch; // 复制到 SSOT - let source = temp_dir.join(&source_rel); + let mut source = temp_dir.join(&source_rel); if !source.exists() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); + // 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md) + let target_name = source_rel + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) { + log::info!( + "Skill directory '{}' not found at direct path, using fallback: {}", + target_name, + found.display() + ); + source = found; + } else { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); + } } let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone()); @@ -1982,6 +2048,38 @@ impl SkillService { } } + /// 在目录树中查找名称匹配且包含 SKILL.md 的子目录 + /// + /// 用于 skills.sh 安装回退:API 只返回 skillId(如 "find-skills"), + /// 但实际文件可能在仓库子目录中(如 "skills/find-skills")。 + fn find_skill_dir_by_name(root: &Path, target_name: &str) -> Option { + fn walk(dir: &Path, target: &str, depth: usize) -> Option { + if depth > 3 { + return None; + } + let entries = fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') { + continue; + } + if name_str.eq_ignore_ascii_case(target) && path.join("SKILL.md").exists() { + return Some(path); + } + if let Some(found) = walk(&path, target, depth + 1) { + return Some(found); + } + } + None + } + walk(root, target_name, 0) + } + /// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示) fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); @@ -2589,6 +2687,70 @@ impl SkillService { Ok(()) } + + // ========== skills.sh 搜索 ========== + + /// 搜索 skills.sh 公共目录 + pub async fn search_skills_sh( + query: &str, + limit: usize, + offset: usize, + ) -> Result { + let client = crate::proxy::http_client::get(); + + let url = url::Url::parse_with_params( + "https://skills.sh/api/search", + &[ + ("q", query), + ("limit", &limit.to_string()), + ("offset", &offset.to_string()), + ], + )?; + + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let skills = resp + .skills + .into_iter() + .filter_map(|s| { + let parts: Vec<&str> = s.source.splitn(2, '/').collect(); + if parts.len() != 2 { + return None; + } + let (owner, repo) = (parts[0].to_string(), parts[1].to_string()); + // 过滤非 GitHub 来源(如 "skills.volces.com"、"mcp-hub.momenta.works") + if owner.contains('.') || repo.contains('.') { + return None; + } + Some(SkillsShDiscoverableSkill { + key: s.id, + name: s.name, + directory: s.skill_id.clone(), + repo_owner: owner.clone(), + repo_name: repo.clone(), + repo_branch: "main".to_string(), + installs: s.installs, + readme_url: Some(format!( + "https://github.com/{}/{}/tree/main/{}", + owner, repo, s.skill_id + )), + }) + }) + .collect(); + + Ok(SkillsShSearchResult { + skills, + total_count: resp.count, + query: resp.query, + }) + } } // ========== 迁移支持 ========== diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 5787f72ec..6c0d20b87 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -20,9 +20,15 @@ interface SkillCardProps { skill: SkillCardSkill; onInstall: (directory: string) => Promise; onUninstall: (directory: string) => Promise; + installs?: number; } -export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { +export function SkillCard({ + skill, + onInstall, + onUninstall, + installs, +}: SkillCardProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -81,6 +87,15 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { {skill.repoOwner}/{skill.repoName} )} + {typeof installs === "number" && ( + + + {installs.toLocaleString()} + + )}
{skill.installed && ( diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index ba09c835b..c24d68e94 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -1,4 +1,10 @@ -import { useState, useMemo, forwardRef, useImperativeHandle } from "react"; +import { + useState, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,7 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { RefreshCw, Search } from "lucide-react"; +import { RefreshCw, Search, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManagerPanel } from "./RepoManagerPanel"; @@ -20,9 +26,14 @@ import { useSkillRepos, useAddSkillRepo, useRemoveSkillRepo, + useSearchSkillsSh, } from "@/hooks/useSkills"; import type { AppId } from "@/lib/api/types"; -import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills"; +import type { + DiscoverableSkill, + SkillRepo, + SkillsShDiscoverableSkill, +} from "@/lib/api/skills"; import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { @@ -34,9 +45,13 @@ export interface SkillsPageHandle { openRepoManager: () => void; } +type SearchSource = "repos" | "skillssh"; + +const SKILLSSH_PAGE_SIZE = 20; + /** * Skills 发现面板 - * 用于浏览和安装来自仓库的 Skills + * 用于浏览和安装来自仓库或 skills.sh 的 Skills */ export const SkillsPage = forwardRef( ({ initialApp = "claude" }, ref) => { @@ -48,6 +63,15 @@ export const SkillsPage = forwardRef( "all" | "installed" | "uninstalled" >("all"); + // skills.sh 搜索状态 + const [searchSource, setSearchSource] = useState("repos"); + const [skillsShInput, setSkillsShInput] = useState(""); + const [skillsShQuery, setSkillsShQuery] = useState(""); + const [skillsShOffset, setSkillsShOffset] = useState(0); + const [accumulatedResults, setAccumulatedResults] = useState< + SkillsShDiscoverableSkill[] + >([]); + // currentApp 用于安装时的默认应用 const currentApp = initialApp; @@ -61,6 +85,33 @@ export const SkillsPage = forwardRef( const { data: installedSkills } = useInstalledSkills(); const { data: repos = [], refetch: refetchRepos } = useSkillRepos(); + // skills.sh 搜索 + const { + data: skillsShResult, + isLoading: loadingSkillsSh, + isFetching: fetchingSkillsSh, + } = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset); + + // 当搜索结果返回时累积 + useEffect(() => { + if (skillsShResult) { + if (skillsShOffset === 0) { + setAccumulatedResults(skillsShResult.skills); + } else { + setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]); + } + } + }, [skillsShResult, skillsShOffset]); + + // 手动提交搜索 + const handleSkillsShSearch = () => { + const trimmed = skillsShInput.trim(); + if (trimmed.length < 2) return; + setSkillsShOffset(0); + setAccumulatedResults([]); + setSkillsShQuery(trimmed); + }; + // Mutations const installMutation = useInstallSkill(); const addRepoMutation = useAddSkillRepo(); @@ -110,7 +161,16 @@ export const SkillsPage = forwardRef( }); }, [discoverableSkills, installedKeys]); - const loading = loadingDiscoverable || fetchingDiscoverable; + // 检查 skills.sh 结果的安装状态 + const isSkillsShInstalled = (skill: SkillsShDiscoverableSkill): boolean => { + const key = `${skill.directory.toLowerCase()}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`; + return installedKeys.has(key); + }; + + const loading = + searchSource === "repos" + ? loadingDiscoverable || fetchingDiscoverable + : false; useImperativeHandle(ref, () => ({ refresh: () => { @@ -120,13 +180,36 @@ export const SkillsPage = forwardRef( openRepoManager: () => setRepoManagerOpen(true), })); + // skills.sh 结果转为 DiscoverableSkill(复用现有安装流程) + const toDiscoverableSkill = ( + s: SkillsShDiscoverableSkill, + ): DiscoverableSkill => ({ + key: s.key, + name: s.name, + description: "", + directory: s.directory, + repoOwner: s.repoOwner, + repoName: s.repoName, + repoBranch: s.repoBranch, + readmeUrl: s.readmeUrl, + }); + const handleInstall = async (directory: string) => { - // 找到对应的 DiscoverableSkill - const skill = discoverableSkills?.find( - (s) => - s.directory === directory || - s.directory.split("/").pop() === directory, - ); + let skill: DiscoverableSkill | undefined; + + if (searchSource === "skillssh") { + const found = accumulatedResults.find((s) => s.directory === directory); + if (found) { + skill = toDiscoverableSkill(found); + } + } else { + skill = discoverableSkills?.find( + (s) => + s.directory === directory || + s.directory.split("/").pop() === directory, + ); + } + if (!skill) { toast.error(t("skills.notFound")); return; @@ -201,7 +284,7 @@ export const SkillsPage = forwardRef( } }; - // 过滤技能列表 + // 过滤技能列表(仓库模式) const filteredSkills = useMemo(() => { // 按仓库筛选 const byRepo = skills.filter((skill) => { @@ -232,35 +315,56 @@ export const SkillsPage = forwardRef( }); }, [skills, searchQuery, filterRepo, filterStatus]); + // 是否有更多 skills.sh 结果 + const hasMoreSkillsSh = + skillsShResult && accumulatedResults.length < skillsShResult.totalCount; + + // 无仓库时默认切换到 skills.sh + const effectiveSource = + searchSource === "repos" && skills.length === 0 && !loading + ? "skillssh" + : searchSource; + return (
{/* 技能网格(可滚动详情区域) */}
- {loading ? ( -
- -
- ) : skills.length === 0 ? ( -
-

- {t("skills.empty")} -

-

- {t("skills.emptyDescription")} -

+ {/* 搜索来源切换 + 搜索框 */} +
+ {/* 来源切换 */} +
+
- ) : ( - <> - {/* 搜索框和筛选器 */} -
+ + {effectiveSource === "repos" ? ( + <> + {/* 仓库模式搜索框 */}
( {t("skills.count", { count: filteredSkills.length })}

)} -
+ + ) : ( + <> + {/* skills.sh 搜索框 */} +
+ + setSkillsShInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSkillsShSearch(); + }} + className="pl-9 pr-3" + /> +
+ + + )} +
- {/* 技能列表或无结果提示 */} - {filteredSkills.length === 0 ? ( + {/* 内容区域 */} + {effectiveSource === "repos" ? ( + /* ===== 仓库模式 ===== */ + loading ? ( +
+ +
+ ) : skills.length === 0 ? ( +
+

+ {t("skills.empty")} +

+

+ {t("skills.emptyDescription")} +

+ +
+ ) : filteredSkills.length === 0 ? ( +
+

+ {t("skills.noResults")} +

+

+ {t("skills.emptyDescription")} +

+
+ ) : ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ ) + ) : ( + /* ===== skills.sh 模式 ===== */ + <> + {loadingSkillsSh && accumulatedResults.length === 0 ? ( +
+ + + {t("skills.skillssh.loading")} + +
+ ) : skillsShQuery.length < 2 ? ( +
+ +

+ {t("skills.skillssh.searchPlaceholder")} +

+
+ ) : accumulatedResults.length === 0 && !loadingSkillsSh ? (

- {t("skills.noResults")} -

-

- {t("skills.emptyDescription")} + {t("skills.skillssh.noResults", { + query: skillsShQuery, + })}

) : ( -
- {filteredSkills.map((skill) => ( - - ))} -
+ <> +
+ {accumulatedResults.map((skill) => { + const installed = isSkillsShInstalled(skill); + return ( + + ); + })} +
+ + {/* 加载更多 + 底部信息 */} +
+ {hasMoreSkillsSh && ( + + )} +

+ {t("skills.skillssh.poweredBy")} +

+
+ )} )} diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..4e9f9a04b --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +/** + * 返回一个延迟更新的值,在指定时间内无新变化后才更新。 + * 用于搜索输入等场景,避免每次按键都触发请求。 + */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index 65d67aa63..ea641f099 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -11,6 +11,7 @@ import { type ImportSkillSelection, type InstalledSkill, type SkillUpdateInfo, + type SkillsShSearchResult, } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; @@ -326,6 +327,26 @@ export function useUpdateSkill() { }); } +// ========== skills.sh 搜索 ========== + +/** + * 搜索 skills.sh 公共目录 + * 使用 300ms staleTime 和 keepPreviousData 实现平滑搜索体验 + */ +export function useSearchSkillsSh( + query: string, + limit: number, + offset: number, +) { + return useQuery({ + queryKey: ["skills", "skillssh", query, limit, offset], + queryFn: () => skillsApi.searchSkillsSh(query, limit, offset), + enabled: query.length >= 2, + staleTime: 5 * 60 * 1000, + placeholderData: keepPreviousData, + }); +} + // ========== 辅助类型 ========== export type { @@ -334,5 +355,6 @@ export type { ImportSkillSelection, SkillBackupEntry, SkillUpdateInfo, + SkillsShSearchResult, AppId, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9b8440e7c..da49b30a6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1650,6 +1650,19 @@ }, "search": "Search Skills", "searchPlaceholder": "Search skill name or repo...", + "searchSource": { + "repos": "Repos", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "Search skills.sh (min 2 chars)...", + "installs": "{{count}} installs", + "loadMore": "Load More", + "loading": "Searching skills.sh...", + "noResults": "No skills found for \"{{query}}\"", + "error": "Failed to search skills.sh", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "Filter by status", "all": "All", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f2ccfac41..bc13a3138 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1650,6 +1650,19 @@ }, "search": "スキルを検索", "searchPlaceholder": "スキル名またはリポジトリで検索...", + "searchSource": { + "repos": "リポジトリ", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "skills.sh を検索(2文字以上)...", + "installs": "{{count}} インストール", + "loadMore": "さらに読み込む", + "loading": "skills.sh を検索中...", + "noResults": "\"{{query}}\" に該当するスキルがありません", + "error": "skills.sh の検索に失敗しました", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "状態で絞り込み", "all": "すべて", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6427611ea..431e8354f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1650,6 +1650,19 @@ }, "search": "搜索技能", "searchPlaceholder": "搜索技能名称或仓库名称...", + "searchSource": { + "repos": "仓库", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "搜索 skills.sh(至少 2 个字符)...", + "installs": "{{count}} 次安装", + "loadMore": "加载更多", + "loading": "正在搜索 skills.sh...", + "noResults": "未找到 \"{{query}}\" 相关技能", + "error": "搜索 skills.sh 失败", + "poweredBy": "由 skills.sh 提供" + }, "filter": { "placeholder": "状态筛选", "all": "全部", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 3e92e88b0..456e57b67 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -95,6 +95,25 @@ export interface MigrationResult { errors: string[]; } +/** skills.sh 可发现的技能 */ +export interface SkillsShDiscoverableSkill { + key: string; + name: string; + directory: string; + repoOwner: string; + repoName: string; + repoBranch: string; + installs: number; + readmeUrl?: string; +} + +/** skills.sh 搜索结果 */ +export interface SkillsShSearchResult { + skills: SkillsShDiscoverableSkill[]; + totalCount: number; + query: string; +} + /** 仓库配置 */ export interface SkillRepo { owner: string; @@ -183,6 +202,15 @@ export const skillsApi = { return await invoke("migrate_skill_storage", { target }); }, + /** 搜索 skills.sh 公共目录 */ + async searchSkillsSh( + query: string, + limit: number, + offset: number, + ): Promise { + return await invoke("search_skills_sh", { query, limit, offset }); + }, + // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */ From 2d581bce91b4a21b2855cb0077802f4d424c4b3c Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 22:41:27 +0800 Subject: [PATCH 11/77] fix: hide empty description and fix broken skill link for skills.sh results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hide "暂无描述" text when skill has no description (skills.sh API doesn't return descriptions), show empty spacer instead - Change skills.sh result link from guessed subdirectory path to repo root URL, since skillId doesn't reflect the actual nested path --- src-tauri/src/services/skill.rs | 4 ++-- src/components/skills/SkillCard.tsx | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 2ce39ea02..461acb002 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -2738,8 +2738,8 @@ impl SkillService { repo_branch: "main".to_string(), installs: s.installs, readme_url: Some(format!( - "https://github.com/{}/{}/tree/main/{}", - owner, repo, s.skill_id + "https://github.com/{}/{}", + owner, repo )), }) }) diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 6c0d20b87..2b47c40ca 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -108,11 +108,15 @@ export function SkillCard({ )}
- -

- {skill.description || t("skills.noDescription")} -

-
+ {skill.description ? ( + +

+ {skill.description} +

+
+ ) : ( +
+ )} {skill.readmeUrl && ( +
+
+ ); +} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 691a8e075..5d6bd0c17 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -371,13 +371,16 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { {t("usage.status")} + + {t("usage.source", { defaultValue: "Source" })} + {logs.length === 0 ? ( {t("usage.noData")} @@ -506,6 +509,21 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { {log.statusCode} + + {log.dataSource && log.dataSource !== "proxy" ? ( + + {t(`usage.dataSource.${log.dataSource}`, { + defaultValue: log.dataSource, + })} + + ) : ( + + {t("usage.dataSource.proxy", { + defaultValue: "Proxy", + })} + + )} + )) )} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 81060e357..f11ba536d 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -6,6 +6,7 @@ import { UsageTrendChart } from "./UsageTrendChart"; import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; +import { DataSourceBar } from "./DataSourceBar"; import type { TimeRange } from "@/types/usage"; import { motion } from "framer-motion"; import { @@ -100,6 +101,8 @@ export function UsageDashboard() {
+ + diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index da49b30a6..8fc98c2ff 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1018,6 +1018,21 @@ "unknownProvider": "Unknown Provider", "stream": "Stream", "nonStream": "Non-stream", + "source": "Source", + "dataSources": "Data Sources", + "dataSource": { + "proxy": "Proxy", + "session_log": "Session Log", + "codex_db": "Codex DB" + }, + "sessionSync": { + "trigger": "Sync session logs", + "import": "Import Sessions", + "resync": "Sync", + "imported": "Imported {{count}} records from session logs", + "upToDate": "Session logs are up to date", + "failed": "Session sync failed" + }, "totalRecords": "{{total}} records total", "modelPricing": "Model Pricing", "loadPricingError": "Failed to load pricing data", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bc13a3138..e27cc8411 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1018,6 +1018,21 @@ "unknownProvider": "不明なプロバイダー", "stream": "ストリーム", "nonStream": "非ストリーム", + "source": "ソース", + "dataSources": "データソース", + "dataSource": { + "proxy": "プロキシ", + "session_log": "セッションログ", + "codex_db": "Codex DB" + }, + "sessionSync": { + "trigger": "セッションログを同期", + "import": "セッションをインポート", + "resync": "同期", + "imported": "セッションログから {{count}} 件のレコードをインポートしました", + "upToDate": "セッションログは最新です", + "failed": "セッション同期に失敗しました" + }, "totalRecords": "全 {{total}} 件", "modelPricing": "モデル料金", "loadPricingError": "料金データの読み込みに失敗しました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 431e8354f..84f783c00 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1018,6 +1018,21 @@ "unknownProvider": "未知供应商", "stream": "流", "nonStream": "非流", + "source": "来源", + "dataSources": "数据来源", + "dataSource": { + "proxy": "代理", + "session_log": "会话日志", + "codex_db": "Codex 数据库" + }, + "sessionSync": { + "trigger": "同步会话日志", + "import": "导入会话", + "resync": "同步", + "imported": "从会话日志导入了 {{count}} 条记录", + "upToDate": "会话日志已是最新", + "failed": "会话同步失败" + }, "totalRecords": "共 {{total}} 条记录", "modelPricing": "模型定价", "loadPricingError": "加载定价数据失败", diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index dbb38bc9b..981af57d2 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -9,6 +9,8 @@ import type { ModelPricing, ProviderLimitStatus, PaginatedLogs, + SessionSyncResult, + DataSourceSummary, } from "@/types/usage"; import type { UsageResult } from "@/types"; import type { AppId } from "./types"; @@ -115,4 +117,13 @@ export const usageApi = { ): Promise => { return invoke("check_provider_limits", { providerId, appType }); }, + + // Session usage sync + syncSessionUsage: async (): Promise => { + return invoke("sync_session_usage"); + }, + + getDataSourceBreakdown: async (): Promise => { + return invoke("get_usage_data_sources"); + }, }; diff --git a/src/types/usage.ts b/src/types/usage.ts index 61dc8774f..6b21e2b71 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -31,6 +31,20 @@ export interface RequestLog { statusCode: number; errorMessage?: string; createdAt: number; + dataSource?: string; +} + +export interface SessionSyncResult { + imported: number; + skipped: number; + filesScanned: number; + errors: string[]; +} + +export interface DataSourceSummary { + dataSource: string; + requestCount: number; + totalCostUsd: string; } export interface PaginatedLogs { From 46051317da488d2e47c484f04e4b69cf0a283953 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 11:44:39 +0800 Subject: [PATCH 13/77] =?UTF-8?q?fix:=20correct=20model=20pricing=20CNY?= =?UTF-8?q?=E2=86=92USD=20and=20add=20missing=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 13 Chinese model prices that were stored as CNY values in USD fields (DeepSeek, Kimi, MiniMax, GLM, Doubao, Mimo) - Add 12 new models: GPT-5.4/mini/nano, o3, o4-mini, GPT-4.1/mini/nano, Gemini 3.1 Pro/Flash Lite, Gemini 2.5 Flash Lite, Gemini 2.0 Flash, DeepSeek Chat/Reasoner, Kimi K2.5 - Merge pricing migration into existing v7→v8 to avoid extra version bump --- src-tauri/src/database/schema.rs | 143 +++++++++++++++++++------------ 1 file changed, 90 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index b3e9d26e2..4f8257cc2 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -414,7 +414,7 @@ impl Database { Self::set_user_version(conn, 7)?; } 7 => { - log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪)"); + log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)"); Self::migrate_v7_to_v8(conn)?; Self::set_user_version(conn, 8)?; } @@ -1102,7 +1102,38 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?; - log::info!("v7 -> v8 迁移完成:已添加 data_source 列和 session_log_sync 表"); + // 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD + let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[ + ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"), + ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"), + ("deepseek-v3", "0.28", "1.11", "0.028", "0"), + ("doubao-seed-code", "0.17", "1.11", "0.02", "0"), + ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"), + ("minimax-m2.1", "0.27", "0.95", "0.03", "0"), + ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"), + ("minimax-m2", "0.27", "0.95", "0.03", "0"), + ("glm-4.7", "0.39", "1.75", "0.04", "0"), + ("glm-4.6", "0.28", "1.11", "0.03", "0"), + ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"), + ]; + for (model_id, input, output, cache_read, cache_creation) in pricing_fixes { + conn.execute( + "UPDATE model_pricing SET + input_cost_per_million = ?2, + output_cost_per_million = ?3, + cache_read_cost_per_million = ?4, + cache_creation_cost_per_million = ?5 + WHERE model_id = ?1", + rusqlite::params![model_id, input, output, cache_read, cache_creation], + ) + .map_err(|e| { + AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")) + })?; + } + + log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价"); Ok(()) } @@ -1195,6 +1226,10 @@ impl Database { "0.30", "3.75", ), + // GPT-5.4 系列 + ("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"), + ("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"), + ("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"), // GPT-5.2 系列 ("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"), ("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"), @@ -1355,6 +1390,30 @@ impl Database { "0.125", "0", ), + // OpenAI Reasoning 系列 + ("o3", "OpenAI o3", "2", "8", "0.50", "0"), + ("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"), + // GPT-4.1 系列 + ("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"), + ("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"), + ("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"), + // Gemini 3.1 系列 + ( + "gemini-3.1-pro-preview", + "Gemini 3.1 Pro Preview", + "2", + "12", + "0.20", + "0", + ), + ( + "gemini-3.1-flash-lite-preview", + "Gemini 3.1 Flash Lite Preview", + "0.25", + "1.50", + "0.025", + "0", + ), // Gemini 3 系列 ( "gemini-3-pro-preview", @@ -1389,6 +1448,16 @@ impl Database { "0.03", "0", ), + ( + "gemini-2.5-flash-lite", + "Gemini 2.5 Flash Lite", + "0.10", + "0.40", + "0.01", + "0", + ), + // Gemini 2.0 系列 + ("gemini-2.0-flash", "Gemini 2.0 Flash", "0.10", "0.40", "0.025", "0"), // StepFun 系列 ( "step-3.5-flash", @@ -1398,68 +1467,36 @@ impl Database { "0.02", "0", ), - // ====== 国产模型 (CNY/1M tokens) ====== + // ====== 国产模型 (USD/1M tokens) ====== // Doubao (字节跳动) - ( - "doubao-seed-code", - "Doubao Seed Code", - "1.20", - "8.00", - "0.24", - "0", - ), + ("doubao-seed-code", "Doubao Seed Code", "0.17", "1.11", "0.02", "0"), // DeepSeek 系列 - ( - "deepseek-v3.2", - "DeepSeek V3.2", - "2.00", - "3.00", - "0.40", - "0", - ), - ( - "deepseek-v3.1", - "DeepSeek V3.1", - "4.00", - "12.00", - "0.80", - "0", - ), - ("deepseek-v3", "DeepSeek V3", "2.00", "8.00", "0.40", "0"), + ("deepseek-v3.2", "DeepSeek V3.2", "0.28", "0.42", "0.028", "0"), + ("deepseek-v3.1", "DeepSeek V3.1", "0.55", "1.67", "0.055", "0"), + ("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"), + ("deepseek-chat", "DeepSeek Chat", "0.28", "0.42", "0.028", "0"), + ("deepseek-reasoner", "DeepSeek Reasoner", "0.28", "0.42", "0.028", "0"), // Kimi (月之暗面) - ( - "kimi-k2-thinking", - "Kimi K2 Thinking", - "4.00", - "16.00", - "1.00", - "0", - ), - ("kimi-k2-0905", "Kimi K2", "4.00", "16.00", "1.00", "0"), - ( - "kimi-k2-turbo", - "Kimi K2 Turbo", - "8.00", - "58.00", - "1.00", - "0", - ), + ("kimi-k2-thinking", "Kimi K2 Thinking", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-0905", "Kimi K2", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-turbo", "Kimi K2 Turbo", "1.11", "8.06", "0.14", "0"), + ("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"), // MiniMax 系列 - ("minimax-m2.1", "MiniMax M2.1", "2.10", "8.40", "0.21", "0"), + ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( "minimax-m2.1-lightning", "MiniMax M2.1 Lightning", - "2.10", - "16.80", - "0.21", + "0.27", + "2.33", + "0.03", "0", ), - ("minimax-m2", "MiniMax M2", "2.10", "8.40", "0.21", "0"), + ("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"), // GLM (智谱) - ("glm-4.7", "GLM-4.7", "2.00", "8.00", "0.40", "0"), - ("glm-4.6", "GLM-4.6", "2.00", "8.00", "0.40", "0"), + ("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 (小米) - ("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "0"), + ("mimo-v2-flash", "Mimo V2 Flash", "0.09", "0.29", "0.009", "0"), ]; for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { From 2ea6a307fc2e9fcab26337b42e9ea3982448baef Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 18:31:38 +0800 Subject: [PATCH 14/77] feat: replace Codex estimated usage with precise JSONL session log parsing Replace the 70/30 input/output token estimation from state_5.sqlite with precise parsing of Codex CLI JSONL session logs (~/.codex/sessions/). - Parse event_msg (token_count), turn_context, and session_meta events - Compute exact input/output/cached token deltas from cumulative totals - Reuse session_log_sync table for incremental file scanning - Pre-filter lines with string contains() before JSON deserialization - Add codex_session data source to DataSourceBar with i18n (zh/en/ja) --- src-tauri/src/services/session_usage_codex.rs | 775 ++++++++++++++---- src/components/usage/DataSourceBar.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 5 files changed, 608 insertions(+), 177 deletions(-) diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 4e2a3041b..2a6ab36ba 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -1,11 +1,17 @@ -//! Codex 使用数据导入 +//! Codex 会话日志使用追踪 //! -//! 从 ~/.codex/state_5.sqlite 的 threads 表读取 token 使用数据, -//! 导入到 proxy_request_logs 表。 +//! 从 ~/.codex/sessions/ 下的 JSONL 会话文件中提取精确 token 使用数据, +//! 替代原有的 state_5.sqlite 估算方案。 //! -//! ## 限制 -//! - Thread 级粒度(非请求级) -//! - 只有总 token 数,使用估算比例分 input/output +//! ## 数据流 +//! ```text +//! ~/.codex/sessions/YYYY/MM/DD/*.jsonl → 增量解析 → delta 计算 → 费用计算 → proxy_request_logs 表 +//! ``` +//! +//! ## 解析的事件类型 +//! - `session_meta` → 提取 session_id +//! - `turn_context` → 提取当前 model +//! - `event_msg` (type=token_count) → 提取累计 token 用量,计算 delta use crate::codex_config::get_codex_config_dir; use crate::database::{lock_conn, Database}; @@ -14,203 +20,510 @@ use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; use crate::services::session_usage::SessionSyncResult; use rust_decimal::Decimal; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; -/// 默认 input/output 比例(70% input, 30% output) -const DEFAULT_INPUT_RATIO: f64 = 0.7; +/// 累计 token 用量(跟踪 total_token_usage 字段) +#[derive(Debug, Clone, Default)] +struct CumulativeTokens { + input: u64, + cached_input: u64, + output: u64, +} -/// 同步 Codex 使用数据 +/// 单次 API 调用的 token 增量 +#[derive(Debug)] +struct DeltaTokens { + input: u32, + cached_input: u32, + output: u32, +} + +impl DeltaTokens { + fn is_zero(&self) -> bool { + self.input == 0 && self.cached_input == 0 && self.output == 0 + } +} + +/// 单文件解析时的运行状态 +struct FileParseState { + session_id: Option, + current_model: String, + prev_total: Option, + event_index: u32, +} + +/// 计算两次累计值之间的 delta +fn compute_delta(prev: &Option, current: &CumulativeTokens) -> DeltaTokens { + match prev { + None => DeltaTokens { + input: current.input as u32, + cached_input: current.cached_input as u32, + output: current.output as u32, + }, + Some(p) => DeltaTokens { + input: current.input.saturating_sub(p.input) as u32, + cached_input: current.cached_input.saturating_sub(p.cached_input) as u32, + output: current.output.saturating_sub(p.output) as u32, + }, + } +} + +/// 从 JSON Value 中提取累计 token 用量 +fn parse_cumulative_tokens(total_usage: &serde_json::Value) -> Option { + if total_usage.is_null() || !total_usage.is_object() { + return None; + } + Some(CumulativeTokens { + input: total_usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + cached_input: total_usage + .get("cached_input_tokens") + .or_else(|| total_usage.get("cache_read_input_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0), + output: total_usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + }) +} + +/// 同步 Codex 使用数据(从 JSONL 会话日志) pub fn sync_codex_usage(db: &Database) -> Result { let codex_dir = get_codex_config_dir(); - let state_db_path = codex_dir.join("state_5.sqlite"); - if !state_db_path.exists() { - return Ok(SessionSyncResult { - imported: 0, - skipped: 0, - files_scanned: 0, - errors: vec![], - }); - } + let files = collect_codex_session_files(&codex_dir); let mut result = SessionSyncResult { imported: 0, skipped: 0, - files_scanned: 1, + files_scanned: files.len() as u32, errors: vec![], }; - // 只读打开 Codex SQLite 数据库 - let codex_conn = match rusqlite::Connection::open_with_flags( - &state_db_path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) { - Ok(c) => c, - Err(e) => { - result.errors.push(format!("无法打开 Codex 数据库: {e}")); - return Ok(result); - } - }; + if files.is_empty() { + return Ok(result); + } - // 查询所有 thread - let mut stmt = match codex_conn.prepare( - "SELECT id, model, model_provider, tokens_used, created_at, title - FROM threads - WHERE tokens_used > 0 - ORDER BY created_at DESC", - ) { - Ok(s) => s, - Err(e) => { - result - .errors - .push(format!("查询 Codex threads 失败: {e}")); - return Ok(result); - } - }; - - let rows = match stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, // id - row.get::<_, Option>(1)?, // model (nullable) - row.get::<_, String>(2)?, // model_provider - row.get::<_, i64>(3)?, // tokens_used - row.get::<_, i64>(4)?, // created_at (unix seconds) - row.get::<_, String>(5)?, // title - )) - }) { - Ok(r) => r, - Err(e) => { - result.errors.push(format!("遍历 threads 失败: {e}")); - return Ok(result); - } - }; - - let conn = lock_conn!(db.conn); - - for row_result in rows { - let (thread_id, model, _provider, tokens_used, created_at, _title) = match row_result { - Ok(r) => r, - Err(e) => { - result.errors.push(format!("读取行失败: {e}")); - continue; + for file_path in &files { + match sync_single_codex_file(db, file_path) { + Ok((imported, skipped)) => { + result.imported += imported; + result.skipped += skipped; } - }; - - let request_id = format!("codex_thread:{thread_id}"); - let model = model.unwrap_or_else(|| "unknown".to_string()); - - // 检查是否已存在 - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", - rusqlite::params![request_id], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .unwrap_or(false); - - if exists { - result.skipped += 1; - continue; - } - - // 使用估算比例分配 input/output - let input_tokens = (tokens_used as f64 * DEFAULT_INPUT_RATIO) as u32; - let output_tokens = tokens_used as u32 - input_tokens; - - let usage = TokenUsage { - input_tokens, - output_tokens, - cache_read_tokens: 0, - cache_creation_tokens: 0, - model: Some(model.clone()), - }; - - // 查找定价 - let pricing = find_codex_pricing(&conn, &model); - let multiplier = Decimal::from(1); - let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = - match pricing { - Some(p) => { - let cost = CostCalculator::calculate(&usage, &p, multiplier); - ( - cost.input_cost.to_string(), - cost.output_cost.to_string(), - cost.cache_read_cost.to_string(), - cost.cache_creation_cost.to_string(), - cost.total_cost.to_string(), - ) - } - None => ( - "0".to_string(), - "0".to_string(), - "0".to_string(), - "0".to_string(), - "0".to_string(), - ), - }; - - let insert_result = conn.execute( - "INSERT OR IGNORE 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, - latency_ms, first_token_ms, status_code, error_message, session_id, - provider_type, is_streaming, cost_multiplier, created_at, data_source - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", - rusqlite::params![ - request_id, - "_codex_session", - "codex", - model, - model, - input_tokens, - output_tokens, - 0i64, // cache_read_tokens - 0i64, // cache_creation_tokens - input_cost, - output_cost, - cache_read_cost, - cache_creation_cost, - total_cost, - 0i64, // latency_ms - Option::::None, - 200i64, // status_code - Option::::None, - Some(thread_id), // session_id = thread_id - Some("codex_db"), - 0i64, // is_streaming: unknown - "1.0", - created_at, - "codex_db", - ], - ); - - match insert_result { - Ok(changed) if changed > 0 => result.imported += 1, - Ok(_) => result.skipped += 1, Err(e) => { - result.errors.push(format!("插入失败: {e}")); - result.skipped += 1; + let msg = format!("Codex 会话文件解析失败 {}: {e}", file_path.display()); + log::warn!("[CODEX-SYNC] {msg}"); + result.errors.push(msg); } } } if result.imported > 0 { log::info!( - "[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条", + "[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, ��描 {} 个文件", result.imported, - result.skipped + result.skipped, + result.files_scanned ); } Ok(result) } +/// 收集所有 Codex 会话 JSONL 文件 +fn collect_codex_session_files(codex_dir: &Path) -> Vec { + let mut files = Vec::new(); + + // 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录�� + let sessions_dir = codex_dir.join("sessions"); + if sessions_dir.is_dir() { + collect_jsonl_recursive(&sessions_dir, &mut files, 0, 3); + } + + // 2. 扫描 archived_sessions/*.jsonl(扁平归档目录) + let archived_dir = codex_dir.join("archived_sessions"); + if archived_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&archived_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + files.push(path); + } + } + } + } + + files +} + +/// 递归扫描目录下的 .jsonl 文件(限制最大深度) +fn collect_jsonl_recursive(dir: &Path, files: &mut Vec, depth: u32, max_depth: u32) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && depth < max_depth { + collect_jsonl_recursive(&path, files, depth + 1, max_depth); + } else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + files.push(path); + } + } +} + +/// 同步单�� Codex JSONL 文件,返回 (imported, skipped) +fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> { + let file_path_str = file_path.to_string_lossy().to_string(); + + // 获取文件元数据 + let metadata = fs::metadata(file_path) + .map_err(|e| AppError::Config(format!("无法读取文���元数据: {e}")))?; + let file_modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + // 检查同步状态 + let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?; + + // 文件未变化则跳过 + if file_modified <= last_modified { + return Ok((0, 0)); + } + + // 打开文件逐行解析 + let file = + fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?; + let reader = BufReader::new(file); + + let mut state = FileParseState { + session_id: None, + current_model: "unknown".to_string(), + prev_total: None, + event_index: 0, + }; + + let mut line_offset: i64 = 0; + let mut imported: u32 = 0; + let mut skipped: u32 = 0; + + for line_result in reader.lines() { + line_offset += 1; + + let line = match line_result { + Ok(l) => l, + Err(_) => continue, // 容忍不完整的最后一行 + }; + + if line.trim().is_empty() { + continue; + } + + // 快速过滤:在 JSON 反序列化前跳过无关行 + let is_event_msg = line.contains("\"event_msg\""); + let is_turn_context = line.contains("\"turn_context\""); + let is_session_meta = line.contains("\"session_meta\""); + + if !is_event_msg && !is_turn_context && !is_session_meta { + continue; + } + if is_event_msg && !line.contains("\"token_count\"") { + continue; + } + + let value: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let event_type = match value.get("type").and_then(|t| t.as_str()) { + Some(t) => t, + None => continue, + }; + + match event_type { + "session_meta" => { + if state.session_id.is_none() { + let payload = value.get("payload"); + state.session_id = payload + .and_then(|p| { + p.get("session_id") + .or_else(|| p.get("sessionId")) + .or_else(|| p.get("id")) + }) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + "turn_context" => { + if let Some(payload) = value.get("payload") { + // model 可能在 payload.model 或 payload.info.model + if let Some(model) = payload + .get("model") + .or_else(|| payload.get("info").and_then(|info| info.get("model"))) + .and_then(|v| v.as_str()) + { + state.current_model = model.to_string(); + } + } + } + "event_msg" => { + let payload = match value.get("payload") { + Some(p) => p, + None => continue, + }; + + // 只处理 token_count 类型 + if payload.get("type").and_then(|t| t.as_str()) != Some("token_count") { + continue; + } + + let info = match payload.get("info") { + Some(i) if !i.is_null() => i, + _ => continue, // info 为 null 的首个事件跳�� + }; + + // 提取模型(token_count 事件也可能携带 model) + if let Some(model) = info + .get("model") + .or_else(|| info.get("model_name")) + .or_else(|| payload.get("model")) + .and_then(|v| v.as_str()) + { + state.current_model = model.to_string(); + } + + // 优先用 total_token_usage(累计值),fallback 到 last_token_usage(增量值) + let (cumulative, is_total) = if let Some(total) = info.get("total_token_usage") { + (parse_cumulative_tokens(total), true) + } else if let Some(last) = info.get("last_token_usage") { + (parse_cumulative_tokens(last), false) + } else { + continue; + }; + + let cumulative = match cumulative { + Some(c) => c, + None => continue, + }; + + let delta = if is_total { + // 累计值模式:计算与上次的 delta + let d = compute_delta(&state.prev_total, &cumulative); + state.prev_total = Some(cumulative); + d + } else { + // 增量值模式:直接使用 last_token_usage 的值 + DeltaTokens { + input: cumulative.input as u32, + cached_input: cumulative.cached_input as u32, + output: cumulative.output as u32, + } + }; + + if delta.is_zero() { + continue; // 跳过 task 边界的零 delta 事件 + } + + state.event_index += 1; + + // 跳过已处理的行(但仍需解析以恢复状态) + if line_offset <= last_offset { + continue; + } + + // 生成唯一 request_id + let session_id_str = state.session_id.as_deref().unwrap_or("unknown"); + let request_id = format!("codex_session:{}:{}", session_id_str, state.event_index); + + // 提取时间戳 + let timestamp = value + .get("timestamp") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + match insert_codex_session_entry( + db, + &request_id, + &delta, + &state.current_model, + state.session_id.as_deref(), + timestamp.as_deref(), + ) { + Ok(true) => imported += 1, + Ok(false) => skipped += 1, + Err(e) => { + log::warn!("[CODEX-SYNC] 插入失败 ({}): {e}", request_id); + skipped += 1; + } + } + } + _ => {} + } + } + + // 更新同步状态 + update_sync_state(db, &file_path_str, file_modified, line_offset)?; + + Ok((imported, skipped)) +} + +/// 插入单条 Codex 会话记录到 proxy_request_logs +fn insert_codex_session_entry( + db: &Database, + request_id: &str, + delta: &DeltaTokens, + model: &str, + session_id: Option<&str>, + timestamp: Option<&str>, +) -> Result { + let conn = lock_conn!(db.conn); + + // 检查是否已存在 + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", + rusqlite::params![request_id], + |row| row.get::<_, i64>(0).map(|c| c > 0), + ) + .unwrap_or(false); + + if exists { + return Ok(false); + } + + // 解析时间戳 + let created_at = timestamp + .and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.timestamp()) + }) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }); + + // 计算费用 + let usage = TokenUsage { + input_tokens: delta.input, + output_tokens: delta.output, + cache_read_tokens: delta.cached_input, + cache_creation_tokens: 0, + model: Some(model.to_string()), + }; + + let pricing = find_codex_pricing(&conn, model); + let multiplier = Decimal::from(1); + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing + { + Some(p) => { + let cost = CostCalculator::calculate(&usage, &p, multiplier); + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } + None => ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ), + }; + + conn.execute( + "INSERT OR IGNORE 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, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + rusqlite::params![ + request_id, + "_codex_session", // provider_id + "codex", // app_type + model, + model, // request_model = model + delta.input, + delta.output, + delta.cached_input, + 0i64, // cache_creation_tokens: Codex 日志无此数据 + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, // latency_ms + Option::::None, // first_token_ms + 200i64, // status_code + Option::::None, // error_message + session_id.map(|s| s.to_string()), + Some("codex_session"), // provider_type + 1i64, // is_streaming + "1.0", // cost_multiplier + created_at, + "codex_session", // data_source + ], + ) + .map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?; + + Ok(true) +} + +/// 获取文件的同步状态 +fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { + let conn = lock_conn!(db.conn); + let result = conn.query_row( + "SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1", + rusqlite::params![file_path], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ); + Ok(result.unwrap_or((0, 0))) +} + +/// 更新文件的同步状态 +fn update_sync_state( + db: &Database, + file_path: &str, + last_modified: i64, + last_offset: i64, +) -> Result<(), AppError> { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![file_path, last_modified, last_offset, now], + ) + .map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?; + Ok(()) +} + /// 查找 Codex 模型定价 -fn find_codex_pricing( - conn: &rusqlite::Connection, - model_id: &str, -) -> Option { - // 精确匹配 +fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + // 精确匹�� let result = conn.query_row( "SELECT input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million @@ -252,3 +565,117 @@ fn find_codex_pricing( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_delta_first_event() { + let prev = None; + let current = CumulativeTokens { + input: 17934, + cached_input: 9600, + output: 454, + }; + let delta = compute_delta(&prev, ¤t); + assert_eq!(delta.input, 17934); + assert_eq!(delta.cached_input, 9600); + assert_eq!(delta.output, 454); + assert!(!delta.is_zero()); + } + + #[test] + fn test_delta_subsequent_event() { + let prev = Some(CumulativeTokens { + input: 17934, + cached_input: 9600, + output: 454, + }); + let current = CumulativeTokens { + input: 36722, + cached_input: 27904, + output: 804, + }; + let delta = compute_delta(&prev, ¤t); + assert_eq!(delta.input, 36722 - 17934); + assert_eq!(delta.cached_input, 27904 - 9600); + assert_eq!(delta.output, 804 - 454); + } + + #[test] + fn test_delta_zero_at_task_boundary() { + let prev = Some(CumulativeTokens { + input: 58346, + cached_input: 46976, + output: 1045, + }); + // task 边界:相同的累计值 + let current = CumulativeTokens { + input: 58346, + cached_input: 46976, + output: 1045, + }; + let delta = compute_delta(&prev, ¤t); + assert!(delta.is_zero()); + } + + #[test] + fn test_delta_saturating_sub() { + // 异常情况:当前值小于前值(不应发生,但需防护) + let prev = Some(CumulativeTokens { + input: 100, + cached_input: 50, + output: 30, + }); + let current = CumulativeTokens { + input: 80, + cached_input: 40, + output: 20, + }; + let delta = compute_delta(&prev, ¤t); + assert_eq!(delta.input, 0); + assert_eq!(delta.cached_input, 0); + assert_eq!(delta.output, 0); + assert!(delta.is_zero()); + } + + #[test] + fn test_parse_cumulative_tokens_valid() { + let json: serde_json::Value = serde_json::json!({ + "input_tokens": 17934, + "cached_input_tokens": 9600, + "output_tokens": 454, + "reasoning_output_tokens": 233, + "total_tokens": 18388 + }); + let tokens = parse_cumulative_tokens(&json).unwrap(); + assert_eq!(tokens.input, 17934); + assert_eq!(tokens.cached_input, 9600); + assert_eq!(tokens.output, 454); + } + + #[test] + fn test_parse_cumulative_tokens_null() { + let json = serde_json::Value::Null; + assert!(parse_cumulative_tokens(&json).is_none()); + } + + #[test] + fn test_parse_cumulative_tokens_alt_field_names() { + // 某些版本可能使用 cache_read_input_tokens 而非 cached_input_tokens + let json: serde_json::Value = serde_json::json!({ + "input_tokens": 1000, + "cache_read_input_tokens": 500, + "output_tokens": 200 + }); + let tokens = parse_cumulative_tokens(&json).unwrap(); + assert_eq!(tokens.cached_input, 500); + } + + #[test] + fn test_collect_codex_session_files_nonexistent() { + let files = collect_codex_session_files(Path::new("/nonexistent/path")); + assert!(files.is_empty()); + } +} diff --git a/src/components/usage/DataSourceBar.tsx b/src/components/usage/DataSourceBar.tsx index e068151e0..bc40d6f6f 100644 --- a/src/components/usage/DataSourceBar.tsx +++ b/src/components/usage/DataSourceBar.tsx @@ -15,6 +15,7 @@ const DATA_SOURCE_ICONS: Record = { proxy: , session_log: , codex_db: , + codex_session: , }; export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8fc98c2ff..ff0202aeb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1023,7 +1023,8 @@ "dataSource": { "proxy": "Proxy", "session_log": "Session Log", - "codex_db": "Codex DB" + "codex_db": "Codex DB", + "codex_session": "Codex Session" }, "sessionSync": { "trigger": "Sync session logs", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e27cc8411..c4b2b1f39 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1023,7 +1023,8 @@ "dataSource": { "proxy": "プロキシ", "session_log": "セッションログ", - "codex_db": "Codex DB" + "codex_db": "Codex DB", + "codex_session": "Codex セッション" }, "sessionSync": { "trigger": "セッションログを同期", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 84f783c00..fd39b3e0e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1023,7 +1023,8 @@ "dataSource": { "proxy": "代理", "session_log": "会话日志", - "codex_db": "Codex 数据库" + "codex_db": "Codex 数据库", + "codex_session": "Codex 会话日志" }, "sessionSync": { "trigger": "同步会话日志", From 8ad1bb7924c8b1395d70cf228e5784720765010f Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 19:02:32 +0800 Subject: [PATCH 15/77] feat: add Codex model name normalization for consistent pricing lookup Normalize model names from JSONL session logs before storage and pricing lookup: lowercase, strip provider prefix (openai/), strip date suffixes (-YYYY-MM-DD, -YYYYMMDD). Also clamp cached tokens to not exceed input. --- src-tauri/src/services/session_usage_codex.rs | 197 +++++++++++++++--- 1 file changed, 165 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 2a6ab36ba..6d211d30c 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -55,6 +55,51 @@ struct FileParseState { event_index: u32, } +/// 归一化 Codex 模型名 +/// +/// 处理规则(按顺序): +/// 1. 转小写:`GLM-4.6` → `glm-4.6` +/// 2. 剥离 provider 前缀:`openai/gpt-5.4` → `gpt-5.4` +/// 3. 剥离 ISO 日期后缀:`gpt-5.4-2026-03-05` → `gpt-5.4` +/// 4. 剥离紧凑日期后缀:`gpt-5.4-20260305` → `gpt-5.4` +fn normalize_codex_model(raw: &str) -> String { + // Step 1: 小写 + let mut name = raw.to_lowercase(); + + // Step 2: 剥离 "provider/" 前缀(如 openai/, azure/) + if let Some(pos) = name.rfind('/') { + name = name[pos + 1..].to_string(); + } + + // Step 3: 剥离 ISO 日期后缀 -YYYY-MM-DD(正好 11 字符) + if name.len() > 11 { + let suffix = &name[name.len() - 11..]; + if suffix.as_bytes()[0] == b'-' + && suffix[1..5].chars().all(|c| c.is_ascii_digit()) + && suffix.as_bytes()[5] == b'-' + && suffix[6..8].chars().all(|c| c.is_ascii_digit()) + && suffix.as_bytes()[8] == b'-' + && suffix[9..11].chars().all(|c| c.is_ascii_digit()) + { + name.truncate(name.len() - 11); + } + } + + // Step 4: 剥离紧凑日期后缀 -YYYYMMDD(正好 9 字符) + if name.len() > 9 { + let parts: Vec<&str> = name.rsplitn(2, '-').collect(); + if parts.len() == 2 { + if let Some(suffix) = parts.first() { + if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) { + name = parts[1].to_string(); + } + } + } + } + + name +} + /// 计算两次累计值之间的 delta fn compute_delta(prev: &Option, current: &CumulativeTokens) -> DeltaTokens { match prev { @@ -273,7 +318,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), .or_else(|| payload.get("info").and_then(|info| info.get("model"))) .and_then(|v| v.as_str()) { - state.current_model = model.to_string(); + state.current_model = normalize_codex_model(model); } } } @@ -300,7 +345,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), .or_else(|| payload.get("model")) .and_then(|v| v.as_str()) { - state.current_model = model.to_string(); + state.current_model = normalize_codex_model(model); } // 优先用 total_token_usage(累计值),fallback 到 last_token_usage(增量值) @@ -331,6 +376,12 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), } }; + // 钳制:cached 不应超过 input(防护异常数据) + let delta = DeltaTokens { + cached_input: delta.cached_input.min(delta.input), + ..delta + }; + if delta.is_zero() { continue; // 跳过 task 边界的零 delta 事件 } @@ -521,10 +572,38 @@ fn update_sync_state( Ok(()) } -/// 查找 Codex 模型定价 +/// ��找 Codex 模型定价(带归一化) fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { - // 精确匹�� - let result = conn.query_row( + let normalized = normalize_codex_model(model_id); + + // 1. 精确匹配(归一化后的名称) + if let Some(pricing) = try_find_pricing(conn, &normalized) { + return Some(pricing); + } + + // 2. LIKE 模糊匹配(兜底) + let pattern = format!("{normalized}%"); + conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1", + rusqlite::params![pattern], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) +} + +/// 精确匹配定价查询 +fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + conn.query_row( "SELECT input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million FROM model_pricing WHERE model_id = ?1", @@ -537,33 +616,9 @@ fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option(3)?, )) }, - ); - - match result { - Ok((input, output, cache_read, cache_creation)) => { - ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok() - } - Err(_) => { - // 尝试 LIKE 匹配 - let pattern = format!("{model_id}%"); - conn.query_row( - "SELECT input_cost_per_million, output_cost_per_million, - cache_read_cost_per_million, cache_creation_cost_per_million - FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1", - rusqlite::params![pattern], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .ok() - .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) - } - } + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) } #[cfg(test)] @@ -678,4 +733,82 @@ mod tests { let files = collect_codex_session_files(Path::new("/nonexistent/path")); assert!(files.is_empty()); } + + // ── 模型名归一化测试 ── + + #[test] + fn test_normalize_codex_model_lowercase() { + assert_eq!(normalize_codex_model("GLM-4.6"), "glm-4.6"); + assert_eq!(normalize_codex_model("DeepSeek-Chat"), "deepseek-chat"); + assert_eq!(normalize_codex_model("GPT-5.4"), "gpt-5.4"); + } + + #[test] + fn test_normalize_codex_model_strip_prefix() { + assert_eq!(normalize_codex_model("openai/gpt-5.4"), "gpt-5.4"); + assert_eq!(normalize_codex_model("azure/gpt-5.2-codex"), "gpt-5.2-codex"); + assert_eq!(normalize_codex_model("OPENAI/GPT-5.4"), "gpt-5.4"); + } + + #[test] + fn test_normalize_codex_model_strip_iso_date() { + assert_eq!(normalize_codex_model("gpt-5.4-2026-03-05"), "gpt-5.4"); + assert_eq!( + normalize_codex_model("gpt-5.4-pro-2026-03-05"), + "gpt-5.4-pro" + ); + } + + #[test] + fn test_normalize_codex_model_strip_compact_date() { + assert_eq!(normalize_codex_model("gpt-5.4-20260305"), "gpt-5.4"); + assert_eq!( + normalize_codex_model("claude-opus-4-6-20260206"), + "claude-opus-4-6" + ); + } + + #[test] + fn test_normalize_codex_model_no_change() { + assert_eq!(normalize_codex_model("gpt-5.4"), "gpt-5.4"); + assert_eq!(normalize_codex_model("gpt-5.2-codex"), "gpt-5.2-codex"); + assert_eq!(normalize_codex_model("o3"), "o3"); + assert_eq!(normalize_codex_model("deepseek-chat"), "deepseek-chat"); + } + + #[test] + fn test_normalize_codex_model_combined() { + // prefix + uppercase + ISO date + assert_eq!( + normalize_codex_model("openai/GPT-5.4-2026-03-05"), + "gpt-5.4" + ); + // prefix + compact date + assert_eq!( + normalize_codex_model("openai/gpt-5.4-20260305"), + "gpt-5.4" + ); + } + + #[test] + fn test_cached_clamped_to_input() { + // cached > input 的异常场景应被 min() 钳制 + let prev = Some(CumulativeTokens { + input: 100, + cached_input: 0, + output: 50, + }); + let current = CumulativeTokens { + input: 110, // delta = 10 + cached_input: 80, // delta = 80(异常:大于 input delta) + output: 60, + }; + let delta = compute_delta(&prev, ¤t); + // 钳制前:cached_input = 80, input = 10 + assert_eq!(delta.cached_input, 80); + assert_eq!(delta.input, 10); + // 实际钳制在调用侧:delta.cached_input.min(delta.input) + let clamped = delta.cached_input.min(delta.input); + assert_eq!(clamped, 10); + } } From f5d7064d5765bc48923873e7576649eed07210c8 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 19:48:06 +0800 Subject: [PATCH 16/77] feat: add Gemini CLI session log usage tracking Parse ~/.gemini/tmp/*/chats/session-*.json for precise per-message token data (input/output/cached/thoughts). Integrates with existing background sync and manual sync button alongside Claude and Codex. --- src-tauri/src/commands/usage.rs | 13 + src-tauri/src/lib.rs | 14 + src-tauri/src/services/mod.rs | 1 + .../src/services/session_usage_gemini.rs | 490 ++++++++++++++++++ src/components/usage/DataSourceBar.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 8 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/services/session_usage_gemini.rs diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 1f92456ef..9bf0bf08e 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -187,6 +187,19 @@ pub fn sync_session_usage( } } + // 同步 Gemini 使用数据 + match crate::services::session_usage_gemini::sync_gemini_usage(&state.db) { + Ok(gemini_result) => { + result.imported += gemini_result.imported; + result.skipped += gemini_result.skipped; + result.files_scanned += gemini_result.files_scanned; + result.errors.extend(gemini_result.errors); + } + Err(e) => { + result.errors.push(format!("Gemini 同步失败: {e}")); + } + } + Ok(result) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 186215b89..6e2648ff9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -817,6 +817,13 @@ pub fn run() { { log::warn!("Codex usage initial sync failed: {e}"); } + if let Err(e) = + crate::services::session_usage_gemini::sync_gemini_usage( + &db_for_session_sync, + ) + { + log::warn!("Gemini usage initial sync failed: {e}"); + } // 定期同步 let mut interval = tokio::time::interval(std::time::Duration::from_secs( @@ -839,6 +846,13 @@ pub fn run() { { log::warn!("Codex usage periodic sync failed: {e}"); } + if let Err(e) = + crate::services::session_usage_gemini::sync_gemini_usage( + &db_for_session_sync, + ) + { + log::warn!("Gemini usage periodic sync failed: {e}"); + } } }); }); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d64711245..5a6bb798f 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,6 +11,7 @@ pub mod provider; pub mod proxy; pub mod session_usage; pub mod session_usage_codex; +pub mod session_usage_gemini; pub mod skill; pub mod speedtest; pub mod stream_check; diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs new file mode 100644 index 000000000..770994547 --- /dev/null +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -0,0 +1,490 @@ +//! Gemini CLI 会话日志使用追踪 +//! +//! 从 ~/.gemini/tmp//chats/session-*.json 中提取精确 token 使用数据。 +//! +//! ## 数据流 +//! ```text +//! ~/.gemini/tmp/*/chats/session-*.json → 全量解析 → 费用计算 → proxy_request_logs 表 +//! ``` +//! +//! ## 与 Claude/Codex 解析器的差异 +//! - JSON 格式(非 JSONL):每个文件是单个 JSON 对象,包含 messages 数组 +//! - 无需 delta 计算:tokens 字段是 per-message 独立值 +//! - 无需状态恢复:不依赖前一条消息的累计值 +//! - 天然去重:每条消息有唯一 id 字段 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::gemini_config::get_gemini_dir; +use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; +use crate::proxy::usage::parser::TokenUsage; +use crate::services::session_usage::SessionSyncResult; +use rust_decimal::Decimal; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// 从 Gemini message 中提取的 token 数据 +#[derive(Debug)] +struct GeminiTokens { + input: u32, + output: u32, + cached: u32, + thoughts: u32, +} + +/// 同步 Gemini 使用数据(从 JSON 会话日志) +pub fn sync_gemini_usage(db: &Database) -> Result { + let gemini_dir = get_gemini_dir(); + + let files = collect_gemini_session_files(&gemini_dir); + + let mut result = SessionSyncResult { + imported: 0, + skipped: 0, + files_scanned: files.len() as u32, + errors: vec![], + }; + + if files.is_empty() { + return Ok(result); + } + + for file_path in &files { + match sync_single_gemini_file(db, file_path) { + Ok((imported, skipped)) => { + result.imported += imported; + result.skipped += skipped; + } + Err(e) => { + let msg = format!("Gemini 会话文件解析失败 {}: {e}", file_path.display()); + log::warn!("[GEMINI-SYNC] {msg}"); + result.errors.push(msg); + } + } + } + + if result.imported > 0 { + log::info!( + "[GEMINI-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件", + result.imported, + result.skipped, + result.files_scanned + ); + } + + Ok(result) +} + +/// 收集所有 Gemini 会话 JSON 文件 +fn collect_gemini_session_files(gemini_dir: &Path) -> Vec { + let mut files = Vec::new(); + + let tmp_dir = gemini_dir.join("tmp"); + if !tmp_dir.is_dir() { + return files; + } + + // 遍历 tmp//chats/session-*.json + let project_dirs = match fs::read_dir(&tmp_dir) { + Ok(entries) => entries, + Err(_) => return files, + }; + + for entry in project_dirs.flatten() { + let chats_dir = entry.path().join("chats"); + if !chats_dir.is_dir() { + continue; + } + + let chat_files = match fs::read_dir(&chats_dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for file_entry in chat_files.flatten() { + let path = file_entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + files.push(path); + } + } + } + + files +} + +/// 同步单个 Gemini 会话 JSON 文件,返回 (imported, skipped) +fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> { + let file_path_str = file_path.to_string_lossy().to_string(); + + // 获取文件元数据 + let metadata = fs::metadata(file_path) + .map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?; + let file_modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + // 检查同步状态 + let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?; + + // 文件未变化则跳过 + if file_modified <= last_modified { + return Ok((0, 0)); + } + + // 读取并解析整个 JSON 文件 + let content = fs::read_to_string(file_path) + .map_err(|e| AppError::Config(format!("无法读取文件: {e}")))?; + let value: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| AppError::Config(format!("JSON 解析失败: {e}")))?; + + // 提取顶层 sessionId + let session_id = value + .get("sessionId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 遍历 messages 数组 + let messages = match value.get("messages").and_then(|v| v.as_array()) { + Some(msgs) => msgs, + None => return Ok((0, 0)), + }; + + let mut imported: u32 = 0; + let mut skipped: u32 = 0; + let mut gemini_msg_count: i64 = 0; + + for msg in messages { + // 只处理 type == "gemini" 的消息 + if msg.get("type").and_then(|t| t.as_str()) != Some("gemini") { + continue; + } + + // 提取 tokens 对象 + let tokens_obj = match msg.get("tokens") { + Some(t) if t.is_object() => t, + _ => continue, + }; + + let tokens = parse_gemini_tokens(tokens_obj); + if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 { + continue; // 跳过全零的空 token 消息 + } + + gemini_msg_count += 1; + + // 提取消息 ID 和模型 + let message_id = msg + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let model = msg + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let timestamp = msg.get("timestamp").and_then(|v| v.as_str()); + + // 生成唯一 request_id + let session_id_str = session_id.as_deref().unwrap_or("unknown"); + let request_id = format!("gemini_session:{session_id_str}:{message_id}"); + + match insert_gemini_session_entry( + db, + &request_id, + &tokens, + model, + session_id.as_deref(), + timestamp, + ) { + Ok(true) => imported += 1, + Ok(false) => skipped += 1, + Err(e) => { + log::warn!("[GEMINI-SYNC] 插入失败 ({}): {e}", request_id); + skipped += 1; + } + } + } + + // 更新同步状态 + update_sync_state(db, &file_path_str, file_modified, gemini_msg_count)?; + + Ok((imported, skipped)) +} + +/// 从 tokens JSON 对象中提取 token 数据 +fn parse_gemini_tokens(tokens: &serde_json::Value) -> GeminiTokens { + GeminiTokens { + input: tokens + .get("input") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + output: tokens + .get("output") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cached: tokens + .get("cached") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + thoughts: tokens + .get("thoughts") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + } +} + +/// 插入单条 Gemini 会话记录到 proxy_request_logs +fn insert_gemini_session_entry( + db: &Database, + request_id: &str, + tokens: &GeminiTokens, + model: &str, + session_id: Option<&str>, + timestamp: Option<&str>, +) -> Result { + let conn = lock_conn!(db.conn); + + // 检查是否已存在 + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", + rusqlite::params![request_id], + |row| row.get::<_, i64>(0).map(|c| c > 0), + ) + .unwrap_or(false); + + if exists { + return Ok(false); + } + + // 解析时间戳 + let created_at = timestamp + .and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.timestamp()) + }) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }); + + // 合并 thoughts 到 output(思考 token 按输出计费) + let output_tokens = tokens.output + tokens.thoughts; + + // 计算费用 + let usage = TokenUsage { + input_tokens: tokens.input, + output_tokens, + cache_read_tokens: tokens.cached, + cache_creation_tokens: 0, + model: Some(model.to_string()), + }; + + let pricing = find_gemini_pricing(&conn, model); + let multiplier = Decimal::from(1); + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing + { + Some(p) => { + let cost = CostCalculator::calculate(&usage, &p, multiplier); + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } + None => ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ), + }; + + conn.execute( + "INSERT OR IGNORE 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, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + rusqlite::params![ + request_id, + "_gemini_session", // provider_id + "gemini", // app_type + model, + model, // request_model = model + tokens.input, + output_tokens, + tokens.cached, + 0i64, // cache_creation_tokens + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, // latency_ms + Option::::None, // first_token_ms + 200i64, // status_code + Option::::None, // error_message + session_id.map(|s| s.to_string()), + Some("gemini_session"), // provider_type + 1i64, // is_streaming + "1.0", // cost_multiplier + created_at, + "gemini_session", // data_source + ], + ) + .map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?; + + Ok(true) +} + +/// 获取文件的同步状态 +fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { + let conn = lock_conn!(db.conn); + let result = conn.query_row( + "SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1", + rusqlite::params![file_path], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ); + Ok(result.unwrap_or((0, 0))) +} + +/// 更新文件的同步状态 +fn update_sync_state( + db: &Database, + file_path: &str, + last_modified: i64, + last_offset: i64, +) -> Result<(), AppError> { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![file_path, last_modified, last_offset, now], + ) + .map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?; + Ok(()) +} + +/// 查找 Gemini 模型定价 +fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + // 精确匹配 + if let Some(pricing) = try_find_pricing(conn, model_id) { + return Some(pricing); + } + + // LIKE 模糊匹配(兜底) + let pattern = format!("{model_id}%"); + conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1", + rusqlite::params![pattern], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) +} + +/// 精确匹配定价查询 +fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id = ?1", + rusqlite::params![model_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_collect_gemini_session_files_nonexistent() { + let files = collect_gemini_session_files(Path::new("/nonexistent/path")); + assert!(files.is_empty()); + } + + #[test] + fn test_parse_gemini_tokens() { + let json: serde_json::Value = serde_json::json!({ + "input": 8522, + "output": 29, + "cached": 3138, + "thoughts": 405, + "tool": 0, + "total": 8956 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.input, 8522); + assert_eq!(tokens.output, 29); + assert_eq!(tokens.cached, 3138); + assert_eq!(tokens.thoughts, 405); + // output + thoughts = 29 + 405 = 434(用于计费) + assert_eq!(tokens.output + tokens.thoughts, 434); + } + + #[test] + fn test_parse_gemini_tokens_missing_fields() { + // 缺少某些字段时应返回 0 + let json: serde_json::Value = serde_json::json!({ + "input": 100, + "output": 50 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.input, 100); + assert_eq!(tokens.output, 50); + assert_eq!(tokens.cached, 0); + assert_eq!(tokens.thoughts, 0); + } + + #[test] + fn test_parse_gemini_tokens_all_zero() { + let json: serde_json::Value = serde_json::json!({ + "input": 0, + "output": 0, + "cached": 0, + "thoughts": 0, + "tool": 0, + "total": 0 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.input, 0); + assert_eq!(tokens.output, 0); + // 全零会被 sync 逻辑跳过 + } +} diff --git a/src/components/usage/DataSourceBar.tsx b/src/components/usage/DataSourceBar.tsx index bc40d6f6f..7dabc51a8 100644 --- a/src/components/usage/DataSourceBar.tsx +++ b/src/components/usage/DataSourceBar.tsx @@ -16,6 +16,7 @@ const DATA_SOURCE_ICONS: Record = { session_log: , codex_db: , codex_session: , + gemini_session: , }; export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ff0202aeb..1236959d5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1024,7 +1024,8 @@ "proxy": "Proxy", "session_log": "Session Log", "codex_db": "Codex DB", - "codex_session": "Codex Session" + "codex_session": "Codex Session", + "gemini_session": "Gemini Session" }, "sessionSync": { "trigger": "Sync session logs", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c4b2b1f39..5a3545c95 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1024,7 +1024,8 @@ "proxy": "プロキシ", "session_log": "セッションログ", "codex_db": "Codex DB", - "codex_session": "Codex セッション" + "codex_session": "Codex セッション", + "gemini_session": "Gemini セッション" }, "sessionSync": { "trigger": "セッションログを同期", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index fd39b3e0e..f6cd70a42 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1024,7 +1024,8 @@ "proxy": "代理", "session_log": "会话日志", "codex_db": "Codex 数据库", - "codex_session": "Codex 会话日志" + "codex_session": "Codex 会话日志", + "gemini_session": "Gemini 会话日志" }, "sessionSync": { "trigger": "同步会话日志", From c0bcd19d443b8c342eb62328ae0b87203d6b59ea Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 21:33:41 +0800 Subject: [PATCH 17/77] fix: correct Gemini session sync accuracy issues - Use UPSERT with WHERE guard instead of INSERT OR IGNORE, so updated token values on existing messages are properly synced without unnecessary rewrites of unchanged rows - Include cached tokens in the skip-zero filter to stop silently discarding pure cache-hit records - Restrict file collection to session-*.json to match documented scope and prevent ingesting non-session JSON files --- .../src/services/session_usage_gemini.rs | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index 770994547..1b20c69fa 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -104,7 +104,12 @@ fn collect_gemini_session_files(gemini_dir: &Path) -> Vec { for file_entry in chat_files.flatten() { let path = file_entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("json") { + let is_session = path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("session-") && n.ends_with(".json")) + .unwrap_or(false); + if is_session { files.push(path); } } @@ -170,7 +175,7 @@ fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32) }; let tokens = parse_gemini_tokens(tokens_obj); - if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 { + if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0 { continue; // 跳过全零的空 token 消息 } @@ -247,19 +252,6 @@ fn insert_gemini_session_entry( ) -> Result { let conn = lock_conn!(db.conn); - // 检查是否已存在 - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", - rusqlite::params![request_id], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .unwrap_or(false); - - if exists { - return Ok(false); - } - // 解析时间戳 let created_at = timestamp .and_then(|ts| { @@ -309,14 +301,29 @@ fn insert_gemini_session_entry( ), }; + // 使用 UPSERT:新记录插入,已存在记录更新 token 和费用(Gemini 全量重读可能携带更新值) conn.execute( - "INSERT OR IGNORE INTO proxy_request_logs ( + "INSERT 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, latency_ms, first_token_ms, status_code, error_message, session_id, provider_type, is_streaming, cost_multiplier, created_at, data_source - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24) + ON CONFLICT(request_id) DO UPDATE SET + model = excluded.model, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cache_read_tokens = excluded.cache_read_tokens, + input_cost_usd = excluded.input_cost_usd, + output_cost_usd = excluded.output_cost_usd, + cache_read_cost_usd = excluded.cache_read_cost_usd, + cache_creation_cost_usd = excluded.cache_creation_cost_usd, + total_cost_usd = excluded.total_cost_usd + WHERE input_tokens != excluded.input_tokens + OR output_tokens != excluded.output_tokens + OR cache_read_tokens != excluded.cache_read_tokens + OR model != excluded.model", rusqlite::params![ request_id, "_gemini_session", // provider_id @@ -346,7 +353,8 @@ fn insert_gemini_session_entry( ) .map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?; - Ok(true) + // changes() > 0 表示新插入或已更新,== 0 表示值完全相同(无实际变更) + Ok(conn.changes() > 0) } /// 获取文件的同步状态 @@ -485,6 +493,23 @@ mod tests { let tokens = parse_gemini_tokens(&json); assert_eq!(tokens.input, 0); assert_eq!(tokens.output, 0); - // 全零会被 sync 逻辑跳过 + // 全零(包括 cached=0)会被 sync 逻辑跳过 + assert!(tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0); + } + + #[test] + fn test_parse_gemini_tokens_cache_only_not_skipped() { + // 纯缓存命中消息(input/output/thoughts=0 但 cached>0)不应被跳过 + let json: serde_json::Value = serde_json::json!({ + "input": 0, + "output": 0, + "cached": 5000, + "thoughts": 0 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.cached, 5000); + // 跳过条件:所有四个字段都为 0 才跳过 + let should_skip = tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0; + assert!(!should_skip, "纯缓存命中记录不应被跳过"); } } From 687ffc237d8ec38fd9d76797bb2b8fd26e138fd2 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 22:41:35 +0800 Subject: [PATCH 18/77] feat: add per-app usage filtering (Claude/Codex/Gemini) Add dashboard-level app type filter to usage statistics, replacing the DataSourceBar with a more useful segmented control. All components (summary cards, trend chart, provider stats, model stats, request logs) now respond to the selected app filter. Backend: add optional app_type parameter to get_usage_summary, get_daily_trends, get_provider_stats, and get_model_stats queries. Frontend: new AppTypeFilter type, updated query keys with appType dimension for proper cache separation, and RequestLogTable local filter auto-locks when dashboard filter is active. --- src-tauri/src/commands/usage.rs | 24 ++- src-tauri/src/services/usage_stats.rs | 200 +++++++++++++------- src/components/usage/ModelStatsTable.tsx | 8 +- src/components/usage/ProviderStatsTable.tsx | 4 +- src/components/usage/RequestLogTable.tsx | 21 +- src/components/usage/UsageDashboard.tsx | 78 +++++++- src/components/usage/UsageSummaryCards.tsx | 4 +- src/components/usage/UsageTrendChart.tsx | 4 +- src/i18n/locales/en.json | 8 + src/i18n/locales/ja.json | 8 + src/i18n/locales/zh.json | 8 + src/lib/api/usage.ts | 14 +- src/lib/query/usage.ts | 75 +++++--- src/types/usage.ts | 2 + 14 files changed, 337 insertions(+), 121 deletions(-) diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 9bf0bf08e..79a3280fa 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -11,8 +11,11 @@ pub fn get_usage_summary( state: State<'_, AppState>, start_date: Option, end_date: Option, + app_type: Option, ) -> Result { - state.db.get_usage_summary(start_date, end_date) + state + .db + .get_usage_summary(start_date, end_date, app_type.as_deref()) } /// 获取每日趋势 @@ -21,20 +24,29 @@ pub fn get_usage_trends( state: State<'_, AppState>, start_date: Option, end_date: Option, + app_type: Option, ) -> Result, AppError> { - state.db.get_daily_trends(start_date, end_date) + state + .db + .get_daily_trends(start_date, end_date, app_type.as_deref()) } /// 获取 Provider 统计 #[tauri::command] -pub fn get_provider_stats(state: State<'_, AppState>) -> Result, AppError> { - state.db.get_provider_stats() +pub fn get_provider_stats( + state: State<'_, AppState>, + app_type: Option, +) -> Result, AppError> { + state.db.get_provider_stats(app_type.as_deref()) } /// 获取模型统计 #[tauri::command] -pub fn get_model_stats(state: State<'_, AppState>) -> Result, AppError> { - state.db.get_model_stats() +pub fn get_model_stats( + state: State<'_, AppState>, + app_type: Option, +) -> Result, AppError> { + state.db.get_model_stats(app_type.as_deref()) } /// 获取请求日志列表 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index d03eea482..6aaf3da42 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -123,44 +123,54 @@ impl Database { &self, start_date: Option, end_date: Option, + app_type: Option<&str>, ) -> Result { let conn = lock_conn!(self.conn); - let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() { - let mut conditions = Vec::new(); - let mut params = Vec::new(); + // Build detail WHERE clause + let mut conditions = Vec::new(); + let mut params_vec: Vec> = Vec::new(); - if let Some(start) = start_date { - conditions.push("created_at >= ?"); - params.push(start); - } - if let Some(end) = end_date { - conditions.push("created_at <= ?"); - params.push(end); - } + if let Some(start) = start_date { + conditions.push("created_at >= ?"); + params_vec.push(Box::new(start)); + } + if let Some(end) = end_date { + conditions.push("created_at <= ?"); + params_vec.push(Box::new(end)); + } + if let Some(at) = app_type { + conditions.push("app_type = ?"); + params_vec.push(Box::new(at.to_string())); + } - (format!("WHERE {}", conditions.join(" AND ")), params) + let where_clause = if conditions.is_empty() { + String::new() } else { - (String::new(), Vec::new()) + format!("WHERE {}", conditions.join(" AND ")) }; - // Build rollup WHERE clause using date strings (use ? for sequential binding) - let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() { - let mut conditions: Vec = Vec::new(); - let mut params = Vec::new(); + // Build rollup WHERE clause using date strings + let mut rollup_conditions: Vec = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); - if let Some(start) = start_date { - conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); - params.push(start); - } - if let Some(end) = end_date { - conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); - params.push(end); - } + if let Some(start) = start_date { + rollup_conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(start)); + } + if let Some(end) = end_date { + rollup_conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(end)); + } + if let Some(at) = app_type { + rollup_conditions.push("app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } - (format!("WHERE {}", conditions.join(" AND ")), params) + let rollup_where = if rollup_conditions.is_empty() { + String::new() } else { - (String::new(), Vec::new()) + format!("WHERE {}", rollup_conditions.join(" AND ")) }; let sql = format!( @@ -194,10 +204,11 @@ impl Database { ); // Combine params: detail params first, then rollup params - let mut all_params: Vec = params_vec; + let mut all_params: Vec> = params_vec; all_params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect(); - let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| { + let result = conn.query_row(&sql, param_refs.as_slice(), |row| { let total_requests: i64 = row.get(0)?; let total_cost: f64 = row.get(1)?; let total_input_tokens: i64 = row.get(2)?; @@ -231,6 +242,7 @@ impl Database { &self, start_date: Option, end_date: Option, + app_type: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -262,9 +274,15 @@ impl Database { bucket_count = 1; } + let app_type_filter = if app_type.is_some() { + "AND app_type = ?4" + } else { + "" + }; + // Query detail logs - let sql = " - SELECT + let sql = format!( + "SELECT CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, COUNT(*) as request_count, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, @@ -274,12 +292,13 @@ impl Database { COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens FROM proxy_request_logs - WHERE created_at >= ?1 AND created_at <= ?2 + WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} GROUP BY bucket_idx - ORDER BY bucket_idx ASC"; + ORDER BY bucket_idx ASC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { Ok(( row.get::<_, i64>(0)?, DailyStats { @@ -293,24 +312,33 @@ impl Database { total_cache_read_tokens: row.get::<_, i64>(7)? as u64, }, )) - })?; + }; let mut map: HashMap = HashMap::new(); - for row in rows { - let (mut bucket_idx, stat) = row?; - if bucket_idx < 0 { - continue; + + // Collect rows into map (need to handle both param variants) + { + let rows = if let Some(at) = app_type { + stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)? + } else { + stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)? + }; + for row in rows { + let (mut bucket_idx, stat) = row?; + if bucket_idx < 0 { + continue; + } + if bucket_idx >= bucket_count { + bucket_idx = bucket_count - 1; + } + map.insert(bucket_idx, stat); } - if bucket_idx >= bucket_count { - bucket_idx = bucket_count - 1; - } - map.insert(bucket_idx, stat); } // Also query rollup data (daily granularity, only useful for daily buckets) if bucket_seconds >= 86400 { - let rollup_sql = " - SELECT + let rollup_sql = format!( + "SELECT CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx, COALESCE(SUM(request_count), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), @@ -320,12 +348,12 @@ impl Database { COALESCE(SUM(cache_creation_tokens), 0), COALESCE(SUM(cache_read_tokens), 0) FROM usage_daily_rollups - WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') + WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter} GROUP BY bucket_idx - ORDER BY bucket_idx ASC"; + ORDER BY bucket_idx ASC" + ); - let mut rstmt = conn.prepare(rollup_sql)?; - let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + let rollup_row_mapper = |row: &rusqlite::Row| { Ok(( row.get::<_, i64>(0)?, ( @@ -338,7 +366,14 @@ impl Database { row.get::<_, i64>(7)? as u64, ), )) - })?; + }; + + let mut rstmt = conn.prepare(&rollup_sql)?; + let rrows = if let Some(at) = app_type { + rstmt.query_map(params![start_ts, end_ts, bucket_seconds, at], rollup_row_mapper)? + } else { + rstmt.query_map(params![start_ts, end_ts, bucket_seconds], rollup_row_mapper)? + }; for row in rrows { let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?; @@ -400,11 +435,21 @@ impl Database { } /// 获取 Provider 统计 - pub fn get_provider_stats(&self) -> Result, AppError> { + pub fn get_provider_stats( + &self, + app_type: Option<&str>, + ) -> Result, AppError> { let conn = lock_conn!(self.conn); + let (detail_where, rollup_where) = if app_type.is_some() { + ("WHERE l.app_type = ?1", "WHERE r.app_type = ?2") + } else { + ("", "") + }; + // UNION detail logs + rollup data, then aggregate - let sql = "SELECT + let sql = format!( + "SELECT provider_id, app_type, provider_name, SUM(request_count) as request_count, SUM(total_tokens) as total_tokens, @@ -423,6 +468,7 @@ impl Database { COALESCE(SUM(l.latency_ms), 0) as latency_sum FROM proxy_request_logs l LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + {detail_where} GROUP BY l.provider_id, l.app_type UNION ALL SELECT r.provider_id, r.app_type, @@ -434,13 +480,15 @@ impl Database { COALESCE(SUM(r.avg_latency_ms * r.request_count), 0) FROM usage_daily_rollups r LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type + {rollup_where} GROUP BY r.provider_id, r.app_type ) GROUP BY provider_id, app_type - ORDER BY total_cost DESC"; + ORDER BY total_cost DESC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(3)?; let success_count: i64 = row.get(6)?; let success_rate = if request_count > 0 { @@ -460,7 +508,13 @@ impl Database { success_rate, avg_latency_ms: row.get::<_, f64>(7)? as u64, }) - })?; + }; + + let rows = if let Some(at) = app_type { + stmt.query_map(params![at, at], row_mapper)? + } else { + stmt.query_map([], row_mapper)? + }; let mut stats = Vec::new(); for row in rows { @@ -471,11 +525,18 @@ impl Database { } /// 获取模型统计 - pub fn get_model_stats(&self) -> Result, AppError> { + pub fn get_model_stats(&self, app_type: Option<&str>) -> Result, AppError> { let conn = lock_conn!(self.conn); + let (detail_where, rollup_where) = if app_type.is_some() { + ("WHERE app_type = ?1", "WHERE app_type = ?2") + } else { + ("", "") + }; + // UNION detail logs + rollup data - let sql = "SELECT + let sql = format!( + "SELECT model, SUM(request_count) as request_count, SUM(total_tokens) as total_tokens, @@ -486,6 +547,7 @@ impl Database { COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost FROM proxy_request_logs + {detail_where} GROUP BY model UNION ALL SELECT model, @@ -493,13 +555,15 @@ impl Database { COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) FROM usage_daily_rollups + {rollup_where} GROUP BY model ) GROUP BY model - ORDER BY total_cost DESC"; + ORDER BY total_cost DESC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(1)?; let total_cost: f64 = row.get(3)?; let avg_cost = if request_count > 0 { @@ -515,7 +579,13 @@ impl Database { total_cost: format!("{total_cost:.6}"), avg_cost_per_request: format!("{avg_cost:.6}"), }) - })?; + }; + + let rows = if let Some(at) = app_type { + stmt.query_map(params![at, at], row_mapper)? + } else { + stmt.query_map([], row_mapper)? + }; let mut stats = Vec::new(); for row in rows { @@ -1044,7 +1114,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None)?; + let summary = db.get_usage_summary(None, None, None)?; assert_eq!(summary.total_requests, 2); assert_eq!(summary.success_rate, 100.0); @@ -1079,7 +1149,7 @@ mod tests { )?; } - let stats = db.get_model_stats()?; + let stats = db.get_model_stats(None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 20f83e628..9f629d70b 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -11,12 +11,16 @@ import { useModelStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; interface ModelStatsTableProps { + appType?: string; refreshIntervalMs: number; } -export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) { +export function ModelStatsTable({ + appType, + refreshIntervalMs, +}: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats({ + const { data: stats, isLoading } = useModelStats(appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index e21e1f6c0..993a436ba 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -11,14 +11,16 @@ import { useProviderStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; interface ProviderStatsTableProps { + appType?: string; refreshIntervalMs: number; } export function ProviderStatsTable({ + appType, refreshIntervalMs, }: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats({ + const { data: stats, isLoading } = useProviderStats(appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 5d6bd0c17..fe485ec20 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -29,6 +29,7 @@ import { } from "./format"; interface RequestLogTableProps { + appType?: string; refreshIntervalMs: number; } @@ -37,7 +38,10 @@ const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; type TimeMode = "rolling" | "fixed"; -export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { +export function RequestLogTable({ + appType: dashboardAppType, + refreshIntervalMs, +}: RequestLogTableProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); @@ -56,8 +60,14 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { const pageSize = 20; const [validationError, setValidationError] = useState(null); + // When dashboard-level app filter is active (not "all"), override the local appType filter + const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; + const effectiveFilters: LogFilters = dashboardAppTypeActive + ? { ...appliedFilters, appType: dashboardAppType } + : appliedFilters; + const { data: result, isLoading } = useRequestLogs({ - filters: appliedFilters, + filters: effectiveFilters, timeMode: appliedTimeMode, rollingWindowSeconds: ONE_DAY_SECONDS, page, @@ -174,13 +184,18 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
+ + + + + + + {t("codexOauth.useDefaultAccount", "使用默认账号")} + + + {accounts.map((account) => ( + +
+ + {account.login} +
+
+ ))} +
+ +
+ )} + + {/* 已登录账号列表 */} + {hasAnyAccount && ( +
+ +
+ {accounts.map((account) => ( +
+
+ + {account.login} + {defaultAccountId === account.id && ( + + {t("codexOauth.defaultAccount", "默认")} + + )} + {selectedAccountId === account.id && ( + + {t("codexOauth.selected", "已选中")} + + )} +
+
+ {defaultAccountId !== account.id && ( + + )} + +
+
+ ))} +
+
+ )} + + {/* 未认证 - 登录按钮 */} + {!hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 已有账号 - 添加更多按钮 */} + {hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 轮询中状态 */} + {isPolling && deviceCode && ( +
+
+ + {t("codexOauth.waitingForAuth", "等待授权中...")} +
+ +
+

+ {t("codexOauth.enterCode", "在浏览器中输入以下代码:")} +

+
+ + {deviceCode.user_code} + + +
+
+ + + +
+ +
+
+ )} + + {/* 错误状态 */} + {pollingState === "error" && error && ( +
+

{error}

+
+ + +
+
+ )} + + {/* 注销所有账号 */} + {hasAnyAccount && accounts.length > 1 && ( + + )} +
+ ); +}; + +export default CodexOAuthSection; diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index eac36a651..f5f5f6dbb 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -82,6 +82,7 @@ import { useOmoDraftState, useOpenclawFormState, useCopilotAuth, + useCodexOauth, } from "./hooks"; import { CLAUDE_DEFAULT_CONFIG, @@ -346,11 +347,19 @@ export function ProviderForm({ // Copilot OAuth 认证状态(仅 Claude 应用需要) const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth(); + // Codex OAuth 认证状态(ChatGPT Plus/Pro 反代) + const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth(); + // 选中的 GitHub 账号 ID(多账号支持) const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState< string | null >(() => resolveManagedAccountId(initialData?.meta, "github_copilot")); + // 选中的 ChatGPT 账号 ID(Codex OAuth 多账号支持) + const [selectedCodexAccountId, setSelectedCodexAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "codex_oauth")); + const { codexAuth, codexConfig, @@ -782,6 +791,9 @@ export function ProviderForm({ templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com"); + const isCodexOauthProvider = + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth"; // GitHub Copilot 必须先登录才能添加 if (isCopilotProvider && !isCopilotAuthenticated) { toast.error( @@ -791,10 +803,19 @@ export function ProviderForm({ ); return; } + // Codex OAuth 必须先登录才能添加 + if (isCodexOauthProvider && !isCodexOauthAuthenticated) { + toast.error( + t("codexOauth.loginRequired", { + defaultValue: "请先登录 ChatGPT 账号", + }), + ); + return; + } if (category !== "official" && category !== "cloud_provider") { if (appId === "claude") { - if (!baseUrl.trim()) { + if (!isCodexOauthProvider && !baseUrl.trim()) { toast.error( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", @@ -802,7 +823,7 @@ export function ProviderForm({ ); return; } - if (!isCopilotProvider && !apiKey.trim()) { + if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) { toast.error( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", @@ -1015,7 +1036,7 @@ export function ProviderForm({ ? useGeminiCommonConfigFlag : undefined, endpointAutoSelect, - // 保存 providerType(用于识别 Copilot 等特殊供应商) + // 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商) providerType, authBinding: isCopilotProvider ? { @@ -1023,7 +1044,13 @@ export function ProviderForm({ authProvider: "github_copilot", accountId: selectedGitHubAccountId ?? undefined, } - : undefined, + : isCodexOauthProvider + ? { + source: "managed_account", + authProvider: "codex_oauth", + accountId: selectedCodexAccountId ?? undefined, + } + : undefined, // GitHub Copilot 多账号:保存关联的账号 ID githubAccountId: isCopilotProvider && selectedGitHubAccountId @@ -1493,15 +1520,24 @@ export function ProviderForm({ initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com") } + isCodexOauthPreset={ + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth" + } usesOAuth={ templatePreset?.requiresOAuth === true || templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || - baseUrl.includes("githubcopilot.com") + baseUrl.includes("githubcopilot.com") || + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth" } isCopilotAuthenticated={isCopilotAuthenticated} selectedGitHubAccountId={selectedGitHubAccountId} onGitHubAccountSelect={setSelectedGitHubAccountId} + isCodexOauthAuthenticated={isCodexOauthAuthenticated} + selectedCodexAccountId={selectedCodexAccountId} + onCodexAccountSelect={setSelectedCodexAccountId} templateValueEntries={templateValueEntries} templateValues={templateValues} templatePresetName={templatePreset?.name || ""} diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index f39983aeb..ddee534c7 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -18,3 +18,4 @@ export { useOpencodeFormState } from "./useOpencodeFormState"; export { useOmoDraftState } from "./useOmoDraftState"; export { useOpenclawFormState } from "./useOpenclawFormState"; export { useCopilotAuth } from "./useCopilotAuth"; +export { useCodexOauth } from "./useCodexOauth"; diff --git a/src/components/providers/forms/hooks/useCodexOauth.ts b/src/components/providers/forms/hooks/useCodexOauth.ts new file mode 100644 index 000000000..84530edf6 --- /dev/null +++ b/src/components/providers/forms/hooks/useCodexOauth.ts @@ -0,0 +1,10 @@ +import { useManagedAuth } from "./useManagedAuth"; + +/** + * Codex OAuth (ChatGPT Plus/Pro) 认证 hook + * + * 复用通用 useManagedAuth,仅指定 provider 为 "codex_oauth" + */ +export function useCodexOauth() { + return useManagedAuth("codex_oauth"); +} diff --git a/src/components/settings/AuthCenterPanel.tsx b/src/components/settings/AuthCenterPanel.tsx index ee06b0bc7..e98bed41a 100644 --- a/src/components/settings/AuthCenterPanel.tsx +++ b/src/components/settings/AuthCenterPanel.tsx @@ -1,7 +1,8 @@ -import { Github, ShieldCheck } from "lucide-react"; +import { Github, ShieldCheck, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection"; +import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection"; export function AuthCenterPanel() { const { t } = useTranslation(); @@ -50,6 +51,25 @@ export function AuthCenterPanel() { + +
+
+
+ +
+
+

ChatGPT (Codex OAuth)

+

+ {t("settings.authCenter.codexOauthDescription", { + defaultValue: + "管理 ChatGPT Plus/Pro 账号,用于将 Claude Code 请求反代到 Codex 后端。仅限个人开发使用。", + })} +

+
+
+ + +
); } diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 2ed5dcd88..a29d00635 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -53,7 +53,8 @@ export interface ProviderPreset { // 供应商类型标识(用于特殊供应商检测) // - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证) - providerType?: "github_copilot"; + // - "codex_oauth": OpenAI Codex via ChatGPT Plus/Pro 反代(需要 OAuth 认证) + providerType?: "github_copilot" | "codex_oauth"; // 是否需要 OAuth 认证(而非 API Key) requiresOAuth?: boolean; @@ -708,6 +709,27 @@ export const providerPresets: ProviderPreset[] = [ icon: "github", iconColor: "#000000", }, + { + name: "Codex (ChatGPT Plus/Pro)", + websiteUrl: "https://openai.com/chatgpt/pricing", + settingsConfig: { + env: { + // base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex + // 用户无需配置 + ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex", + ANTHROPIC_MODEL: "gpt-5.1-codex", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.1-codex-mini", + ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.1-codex", + ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.1-codex-max", + }, + }, + category: "third_party", + apiFormat: "openai_responses", + providerType: "codex_oauth", + requiresOAuth: true, + icon: "openai", + iconColor: "#000000", + }, { name: "Nvidia", websiteUrl: "https://build.nvidia.com", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dc6eb02c5..6d9f1e5a9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -845,6 +845,27 @@ "migrationFailed": "Legacy auth migration failed: {{error}}", "loadModelsFailed": "Failed to load Copilot models" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro Auth", + "notAuthenticated": "Not authenticated", + "loginWithChatGPT": "Sign in with ChatGPT", + "loginRequired": "Please sign in to ChatGPT first", + "waitingForAuth": "Waiting for authorization...", + "enterCode": "Enter the code in your browser:", + "accountCount": "{{count}} account(s)", + "selectAccount": "Select account", + "selectAccountPlaceholder": "Choose a ChatGPT account", + "useDefaultAccount": "Use default account", + "loggedInAccounts": "Logged in accounts", + "defaultAccount": "Default", + "selected": "Selected", + "removeAccount": "Remove account", + "setAsDefault": "Set as default", + "addAnotherAccount": "Add another account", + "logoutAll": "Logout all accounts", + "retry": "Retry", + "copyCode": "Copy code" + }, "endpointTest": { "title": "API Endpoint Management", "endpoints": "endpoints", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 47b08db43..853b466d0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -845,6 +845,27 @@ "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro 認証", + "notAuthenticated": "未認証", + "loginWithChatGPT": "ChatGPT でログイン", + "loginRequired": "先に ChatGPT にログインしてください", + "waitingForAuth": "認証を待機中...", + "enterCode": "ブラウザで以下のコードを入力してください:", + "accountCount": "{{count}} アカウント", + "selectAccount": "アカウントを選択", + "selectAccountPlaceholder": "ChatGPT アカウントを選択", + "useDefaultAccount": "デフォルトアカウントを使用", + "loggedInAccounts": "ログイン済みアカウント", + "defaultAccount": "デフォルト", + "selected": "選択中", + "removeAccount": "アカウントを削除", + "setAsDefault": "デフォルトに設定", + "addAnotherAccount": "別のアカウントを追加", + "logoutAll": "すべてのアカウントをログアウト", + "retry": "再試行", + "copyCode": "コードをコピー" + }, "endpointTest": { "title": "API エンドポイント管理", "endpoints": "エンドポイント", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index aaa944ab9..7e02c19d6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -845,6 +845,27 @@ "migrationFailed": "旧认证数据迁移失败:{{error}}", "loadModelsFailed": "加载 Copilot 模型列表失败" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro 认证", + "notAuthenticated": "未认证", + "loginWithChatGPT": "使用 ChatGPT 登录", + "loginRequired": "请先登录 ChatGPT 账号", + "waitingForAuth": "等待授权中...", + "enterCode": "请在浏览器中输入以下验证码:", + "accountCount": "{{count}} 个账号", + "selectAccount": "选择账号", + "selectAccountPlaceholder": "选择一个 ChatGPT 账号", + "useDefaultAccount": "使用默认账号", + "loggedInAccounts": "已登录账号", + "defaultAccount": "默认", + "selected": "已选中", + "removeAccount": "移除账号", + "setAsDefault": "设为默认", + "addAnotherAccount": "添加其他账号", + "logoutAll": "注销所有账号", + "retry": "重试", + "copyCode": "复制代码" + }, "endpointTest": { "title": "请求地址管理", "endpoints": "个端点", diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 5e6ecde00..a4d840ffa 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; -export type ManagedAuthProvider = "github_copilot"; +export type ManagedAuthProvider = "github_copilot" | "codex_oauth"; export interface ManagedAuthAccount { id: string; From d164191bd1e13448454f7d7f08675119a6649810 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 12:11:05 +0800 Subject: [PATCH 23/77] feat: display subscription quota for Codex OAuth provider cards Codex OAuth (ChatGPT Plus/Pro) providers previously fell through to the default UsageFooter branch and showed no quota at all, while Copilot and official Codex providers already had a wham/usage-backed quota footer. This wires up the same five-hour / seven-day tier badges for codex_oauth provider cards by reusing the existing query_codex_quota function and SubscriptionQuotaFooter rendering, parameterized to keep both the CLI credential path ("codex") and the cc-switch managed OAuth path ("codex_oauth") working from a single source of truth. - Parameterize services::subscription::query_codex_quota with tool_label and expired_message; promote SubscriptionQuota constructors to pub(crate). The CLI path keeps its existing "codex" label and the "re-login with Codex CLI" message; the new path passes "codex_oauth" and a cc-switch-specific re-login hint. - Add a new get_codex_oauth_quota Tauri command in commands/codex_oauth.rs that resolves the ChatGPT account (explicit binding > default account > not_found), pulls a valid access_token from CodexOAuthManager (auto-refresh handled), and delegates to query_codex_quota. - Extract SubscriptionQuotaFooter's render body into a pure SubscriptionQuotaView component (props: quota / loading / refetch / appIdForExpiredHint / inline). The existing SubscriptionQuotaFooter becomes a thin wrapper with identical props and behavior, so CopilotQuotaFooter and the official Claude/Codex/Gemini paths are untouched. This avoids duplicating ~280 lines of five-state rendering. - Add CodexOauthQuotaFooter, a 38-line wrapper that calls the new useCodexOauthQuota hook and forwards to SubscriptionQuotaView. - ProviderCard inserts an isCodexOauth branch between isCopilot and isOfficial, keyed off PROVIDER_TYPES.CODEX_OAUTH (newly added to config/constants.ts to centralize the previously scattered string). - Frontend hook caches per (codex_oauth, accountId) so multiple cards bound to the same ChatGPT account share one fetch via react-query dedup; cards bound to different accounts get independent fetches. - No new i18n keys: existing subscription.fiveHour / sevenDay / expired / refresh / queryFailed / expiredHint are reused. --- src-tauri/src/commands/codex_oauth.rs | 49 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/services/subscription.rs | 45 ++++++++++++----- src/components/CodexOauthQuotaFooter.tsx | 38 ++++++++++++++ src/components/SubscriptionQuotaFooter.tsx | 58 ++++++++++++++++++---- src/components/providers/ProviderCard.tsx | 5 ++ src/config/constants.ts | 1 + src/lib/api/subscription.ts | 2 + src/lib/query/subscription.ts | 28 +++++++++++ 9 files changed, 204 insertions(+), 23 deletions(-) create mode 100644 src/components/CodexOauthQuotaFooter.tsx diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs index 040de9140..7e83e0dc7 100644 --- a/src-tauri/src/commands/codex_oauth.rs +++ b/src-tauri/src/commands/codex_oauth.rs @@ -2,12 +2,57 @@ //! //! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。 //! -//! 注意:实际的命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端, -//! 此处仅定义 State wrapper。 +//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端, +//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。 use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; +use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota}; use std::sync::Arc; +use tauri::State; use tokio::sync::RwLock; /// Codex OAuth 认证状态 pub struct CodexOAuthState(pub Arc>); + +/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度 +/// +/// - `account_id` 未指定时回退到 `CodexOAuthManager` 的默认账号 +/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染 +/// - 复用 `services::subscription::query_codex_quota`,因此 wham/usage 端点协议 +/// 与 Codex CLI 路径完全一致 +#[tauri::command(rename_all = "camelCase")] +pub async fn get_codex_oauth_quota( + account_id: Option, + state: State<'_, CodexOAuthState>, +) -> Result { + let manager = state.0.read().await; + + // 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found) + let resolved = match account_id { + Some(id) => Some(id), + None => manager.default_account_id().await, + }; + let Some(id) = resolved else { + return Ok(SubscriptionQuota::not_found("codex_oauth")); + }; + + // 获取(必要时自动刷新)access_token + let token = match manager.get_valid_token_for_account(&id).await { + Ok(t) => t, + Err(e) => { + return Ok(SubscriptionQuota::error( + "codex_oauth", + CredentialStatus::Expired, + format!("Codex OAuth token unavailable: {e}"), + )); + } + }; + + Ok(query_codex_quota( + &token, + Some(&id), + "codex_oauth", + "Codex OAuth access token expired or rejected. Please re-login via cc-switch.", + ) + .await) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62444ba62..34673168c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -963,6 +963,7 @@ pub fn run() { commands::testUsageScript, // subscription quota commands::get_subscription_quota, + commands::get_codex_oauth_quota, commands::get_coding_plan_quota, commands::get_balance, // New MCP via config.json (SSOT) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 5c2d4b4da..ee4a0b115 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -60,7 +60,7 @@ pub struct SubscriptionQuota { } impl SubscriptionQuota { - fn not_found(tool: &str) -> Self { + pub(crate) fn not_found(tool: &str) -> Self { Self { tool: tool.to_string(), credential_status: CredentialStatus::NotFound, @@ -73,7 +73,7 @@ impl SubscriptionQuota { } } - fn error(tool: &str, status: CredentialStatus, message: String) -> Self { + pub(crate) fn error(tool: &str, status: CredentialStatus, message: String) -> Self { Self { tool: tool.to_string(), credential_status: status, @@ -621,8 +621,17 @@ fn unix_ts_to_iso(ts: i64) -> Option { chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.to_rfc3339()) } -/// 查询 Codex 官方订阅额度 -async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> SubscriptionQuota { +/// 查询 Codex / ChatGPT 反代订阅额度 +/// +/// 参数化 `tool_label` 和 `expired_message` 让该函数可被两个调用点共用: +/// - `"codex"` + "Please re-login with Codex CLI."(CLI 凭据路径) +/// - `"codex_oauth"` + "Please re-login via cc-switch."(cc-switch 自管 OAuth 路径) +pub(crate) async fn query_codex_quota( + access_token: &str, + account_id: Option<&str>, + tool_label: &str, + expired_message: &str, +) -> SubscriptionQuota { let client = crate::proxy::http_client::get(); let mut req = client @@ -639,7 +648,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs Ok(r) => r, Err(e) => { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("Network error: {e}"), ); @@ -650,16 +659,16 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Expired, - format!("Authentication failed (HTTP {status}). Please re-login with Codex CLI."), + format!("{expired_message} (HTTP {status})"), ); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("API error (HTTP {status}): {body}"), ); @@ -669,7 +678,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs Ok(v) => v, Err(e) => { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("Failed to parse API response: {e}"), ); @@ -697,7 +706,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs } SubscriptionQuota { - tool: "codex".to_string(), + tool: tool_label.to_string(), credential_status: CredentialStatus::Valid, credential_message: None, success: true, @@ -1221,7 +1230,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result { // 即使可能过期也尝试调用 API if let Some(token) = token { - let result = query_codex_quota(&token, account_id.as_deref()).await; + let result = query_codex_quota( + &token, + account_id.as_deref(), + "codex", + "Authentication failed. Please re-login with Codex CLI.", + ) + .await; if result.success { return Ok(result); } @@ -1234,7 +1249,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_codex_quota(&token, account_id.as_deref()).await) + Ok(query_codex_quota( + &token, + account_id.as_deref(), + "codex", + "Authentication failed. Please re-login with Codex CLI.", + ) + .await) } } } diff --git a/src/components/CodexOauthQuotaFooter.tsx b/src/components/CodexOauthQuotaFooter.tsx new file mode 100644 index 000000000..be9121b58 --- /dev/null +++ b/src/components/CodexOauthQuotaFooter.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import type { ProviderMeta } from "@/types"; +import { useCodexOauthQuota } from "@/lib/query/subscription"; +import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter"; + +interface CodexOauthQuotaFooterProps { + meta?: ProviderMeta; + inline?: boolean; +} + +/** + * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度 footer + * + * 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。 + * 数据源切换为 cc-switch 自管的 OAuth token 而非 Codex CLI 凭据。 + */ +const CodexOauthQuotaFooter: React.FC = ({ + meta, + inline = false, +}) => { + const { + data: quota, + isFetching: loading, + refetch, + } = useCodexOauthQuota(meta, true); + + return ( + + ); +}; + +export default CodexOauthQuotaFooter; diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 4226544e3..e345b58f9 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -3,13 +3,22 @@ import { RefreshCw, AlertCircle, Clock } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AppId } from "@/lib/api"; import { useSubscriptionQuota } from "@/lib/query/subscription"; -import type { QuotaTier } from "@/types/subscription"; +import type { QuotaTier, SubscriptionQuota } from "@/types/subscription"; interface SubscriptionQuotaFooterProps { appId: AppId; inline?: boolean; } +interface SubscriptionQuotaViewProps { + quota: SubscriptionQuota | undefined; + loading: boolean; + refetch: () => void; + /** 用于 `subscription.expiredHint` 的 {tool} 插值;解耦了 hook 的 appId */ + appIdForExpiredHint: string; + inline?: boolean; +} + /** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */ export const TIER_I18N_KEYS: Record = { five_hour: "subscription.fiveHour", @@ -78,16 +87,22 @@ function formatRelativeTime( return t("usage.daysAgo", { count: Math.floor(diff / 86400) }); } -const SubscriptionQuotaFooter: React.FC = ({ - appId, +/** + * 纯展示组件:渲染 SubscriptionQuota 的 5 种状态(not_found / parse_error / + * expired / API 失败 / 成功),支持 inline / expanded 两种布局。 + * + * 数据源由调用方 hook 注入,方便不同的额度后端复用同一套渲染逻辑: + * - `SubscriptionQuotaFooter`(CLI 凭据路径,by appId) + * - `CodexOauthQuotaFooter`(cc-switch 自管 OAuth 路径,by ChatGPT account) + */ +export const SubscriptionQuotaView: React.FC = ({ + quota, + loading, + refetch, + appIdForExpiredHint, inline = false, }) => { const { t } = useTranslation(); - const { - data: quota, - isFetching: loading, - refetch, - } = useSubscriptionQuota(appId, true); // 定期更新相对时间显示 const [now, setNow] = React.useState(Date.now()); @@ -131,7 +146,7 @@ const SubscriptionQuotaFooter: React.FC = ({
{t("subscription.expired")} - {t("subscription.expiredHint", { tool: appId })} + {t("subscription.expiredHint", { tool: appIdForExpiredHint })}
@@ -364,4 +379,29 @@ const TierBar: React.FC<{ ); }; +/** + * CLI 凭据路径下的薄 wrapper:通过 useSubscriptionQuota(appId) 自取数据 + * 后转发到 SubscriptionQuotaView。对外 props/行为与重构前完全一致。 + */ +const SubscriptionQuotaFooter: React.FC = ({ + appId, + inline = false, +}) => { + const { + data: quota, + isFetching: loading, + refetch, + } = useSubscriptionQuota(appId, true); + + return ( + + ); +}; + export default SubscriptionQuotaFooter; diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 52f662a92..24166b00f 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -13,6 +13,7 @@ import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; +import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter"; import { PROVIDER_TYPES } from "@/config/constants"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; @@ -177,6 +178,8 @@ export function ProviderCard({ const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; + const isCodexOauth = + provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent @@ -355,6 +358,8 @@ export function ProviderCard({
{isCopilot ? ( + ) : isCodexOauth ? ( + ) : isOfficial ? ( ) : hasMultiplePlans ? ( diff --git a/src/config/constants.ts b/src/config/constants.ts index 158d983e7..d6a3aab8d 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,6 +1,7 @@ // Provider 类型常量 export const PROVIDER_TYPES = { GITHUB_COPILOT: "github_copilot", + CODEX_OAUTH: "codex_oauth", } as const; // 用量脚本模板类型常量 diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index 79f29eb19..d11000ffa 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -4,6 +4,8 @@ import type { SubscriptionQuota } from "@/types/subscription"; export const subscriptionApi = { getQuota: (tool: string): Promise => invoke("get_subscription_quota", { tool }), + getCodexOauthQuota: (accountId: string | null): Promise => + invoke("get_codex_oauth_quota", { accountId }), getCodingPlanQuota: ( baseUrl: string, apiKey: string, diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index c70545b77..8353fcca0 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -1,6 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { subscriptionApi } from "@/lib/api/subscription"; import type { AppId } from "@/lib/api/types"; +import type { ProviderMeta } from "@/types"; +import { resolveManagedAccountId } from "@/lib/authBinding"; +import { PROVIDER_TYPES } from "@/config/constants"; const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes @@ -15,3 +18,28 @@ export function useSubscriptionQuota(appId: AppId, enabled: boolean) { retry: 1, }); } + +/** + * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook + * + * 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token, + * 而不是 Codex CLI 的 ~/.codex/auth.json。 + * + * Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。 + * accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。 + */ +export function useCodexOauthQuota( + meta: ProviderMeta | undefined, + enabled: boolean, +) { + const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH); + return useQuery({ + queryKey: ["codex_oauth", "quota", accountId ?? "default"], + queryFn: () => subscriptionApi.getCodexOauthQuota(accountId), + enabled, + refetchInterval: REFETCH_INTERVAL, + refetchOnWindowFocus: true, + staleTime: REFETCH_INTERVAL, + retry: 1, + }); +} From 5288238694cfe944962ef2b9d3c292bbc86f84dc Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 16:38:49 +0800 Subject: [PATCH 24/77] refactor: tighten OAuth Auth Center copy, layout, and icon - Trim Auth Center section descriptions to focus on user intent - Remove duplicate outer heading on the auth settings tab - Swap Sparkles glyph for CodexIcon on the ChatGPT card - Generalize codexOauth.authStatus to a neutral "Auth status" - Register settings.authCenter.* keys across zh/en/ja locales --- .../providers/forms/CodexOAuthSection.tsx | 2 +- src/components/settings/AuthCenterPanel.tsx | 13 ++++++------- src/components/settings/SettingsPage.tsx | 18 ------------------ src/i18n/locales/en.json | 9 ++++++++- src/i18n/locales/ja.json | 9 ++++++++- src/i18n/locales/zh.json | 9 ++++++++- 6 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/components/providers/forms/CodexOAuthSection.tsx b/src/components/providers/forms/CodexOAuthSection.tsx index a45bd2186..8f66b4dca 100644 --- a/src/components/providers/forms/CodexOAuthSection.tsx +++ b/src/components/providers/forms/CodexOAuthSection.tsx @@ -89,7 +89,7 @@ export const CodexOAuthSection: React.FC = ({
{/* 认证状态标题 */}
- + {t("settings.authCenter.description", { defaultValue: - "集中管理跨应用复用的 OAuth 账号。Provider 只绑定这些认证源,不再重复登录。", + "在 Claude Code 中使用您的其他订阅,请注意合规风险。", })}

@@ -42,8 +43,7 @@ export function AuthCenterPanel() {

GitHub Copilot

{t("settings.authCenter.copilotDescription", { - defaultValue: - "管理 GitHub Copilot 账号、默认账号以及供 Claude / Codex / Gemini 绑定的托管凭据。", + defaultValue: "管理 GitHub Copilot 账号", })}

@@ -55,14 +55,13 @@ export function AuthCenterPanel() {
- +

ChatGPT (Codex OAuth)

{t("settings.authCenter.codexOauthDescription", { - defaultValue: - "管理 ChatGPT Plus/Pro 账号,用于将 Claude Code 请求反代到 Codex 后端。仅限个人开发使用。", + defaultValue: "管理 ChatGPT 账号", })}

diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 5acd350dd..d10e57f30 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -9,7 +9,6 @@ import { ScrollText, HardDriveDownload, FlaskConical, - KeyRound, } from "lucide-react"; import { toast } from "sonner"; import { @@ -272,23 +271,6 @@ export function SettingsPage({ transition={{ duration: 0.3 }} className="space-y-6" > -
- -
-

- {t("settings.authCenter.heading", { - defaultValue: "认证中心", - })} -

-

- {t("settings.authCenter.headingDescription", { - defaultValue: - "统一管理可跨应用复用的 OAuth 账号和默认认证来源。", - })} -

-
-
- diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6d9f1e5a9..f2475be26 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -225,6 +225,13 @@ "tabGeneral": "General", "tabAdvanced": "Advanced", "tabProxy": "Proxy", + "authCenter": { + "title": "OAuth Authentication Center", + "description": "Use your other subscriptions in Claude Code — please be mindful of compliance risks.", + "beta": "Beta", + "copilotDescription": "Manage GitHub Copilot accounts", + "codexOauthDescription": "Manage ChatGPT accounts" + }, "advanced": { "configDir": { "title": "Configuration Directory", @@ -846,7 +853,7 @@ "loadModelsFailed": "Failed to load Copilot models" }, "codexOauth": { - "authStatus": "ChatGPT Plus/Pro Auth", + "authStatus": "Auth status", "notAuthenticated": "Not authenticated", "loginWithChatGPT": "Sign in with ChatGPT", "loginRequired": "Please sign in to ChatGPT first", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 853b466d0..3aa2f7006 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -225,6 +225,13 @@ "tabGeneral": "一般", "tabAdvanced": "詳細", "tabProxy": "プロキシ", + "authCenter": { + "title": "OAuth 認証センター", + "description": "Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。", + "beta": "Beta", + "copilotDescription": "GitHub Copilot アカウントを管理します", + "codexOauthDescription": "ChatGPT アカウントを管理します" + }, "advanced": { "configDir": { "title": "設定ディレクトリ", @@ -846,7 +853,7 @@ "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" }, "codexOauth": { - "authStatus": "ChatGPT Plus/Pro 認証", + "authStatus": "認証状態", "notAuthenticated": "未認証", "loginWithChatGPT": "ChatGPT でログイン", "loginRequired": "先に ChatGPT にログインしてください", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7e02c19d6..7148b5bde 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -225,6 +225,13 @@ "tabGeneral": "通用", "tabAdvanced": "高级", "tabProxy": "代理", + "authCenter": { + "title": "OAuth 认证中心", + "description": "在 Claude Code 中使用您的其他订阅,请注意合规风险。", + "beta": "Beta", + "copilotDescription": "管理 GitHub Copilot 账号", + "codexOauthDescription": "管理 ChatGPT 账号" + }, "advanced": { "configDir": { "title": "配置文件目录", @@ -846,7 +853,7 @@ "loadModelsFailed": "加载 Copilot 模型列表失败" }, "codexOauth": { - "authStatus": "ChatGPT Plus/Pro 认证", + "authStatus": "认证状态", "notAuthenticated": "未认证", "loginWithChatGPT": "使用 ChatGPT 登录", "loginRequired": "请先登录 ChatGPT 账号", From 3a164622616533e02f98c86ff4bcbfa2c1cafe74 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 16:43:45 +0800 Subject: [PATCH 25/77] i18n(zh): unify Skills terminology in settings labels Use "Skills" consistently in skillStorage title/description and skillSync title to match the upstream Agent Skills wording and the existing English label style used elsewhere on the settings page. --- src/i18n/locales/zh.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7148b5bde..be42ae4e7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -486,8 +486,8 @@ "opencodeDesc": "OpenCode CLI" }, "skillStorage": { - "title": "技能存储位置", - "description": "选择 CC Switch 存放技能主副本的目录", + "title": "Skills 存储位置", + "description": "选择 CC Switch 存放 Skills 主副本的目录", "ccSwitch": "CC Switch", "unified": "~/.agents/skills", "ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。", @@ -498,7 +498,7 @@ "migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志" }, "skillSync": { - "title": "Skill 同步方式", + "title": "Skills 同步方式", "description": "选择 Skills 的文件同步策略", "symlink": "软连接", "copy": "文件复制", From 64c068415e9e4a2be6aac6979d9f026f0ed089ae Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 17:53:20 +0800 Subject: [PATCH 26/77] fix(linux): repair unresponsive UI on startup and full-screen panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux users reported the window UI (including native title bar buttons) couldn't receive clicks until manually maximizing and restoring the window. Root causes: (1) Tauri webview did not acquire focus on startup so first clicks were consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface input region failed to renegotiate on the visible:false + show() path under some WebKitGTK/compositor combinations. - Add linux_fix::nudge_main_window helper that performs set_focus plus a ±1px no-op resize after window show, with a 500ms reconciliation readback to compensate for dropped resize requests on slow compositors. - Wire the helper into every window re-show path: normal startup, deeplink, single_instance, tray show_main, and lightweight exit. - Set WEBKIT_DISABLE_COMPOSITING_MODE=1 at startup to avoid resize crashes and Wayland surface negotiation issues. - Remove data-tauri-drag-region on Linux from App.tsx header and the shared FullScreenPanel (used by all provider/MCP/workspace forms) to avoid Tauri #13440 in Wayland sessions. Extract drag-region constants to src/lib/platform.ts for reuse. All Rust changes are gated by #[cfg(target_os = "linux")]; frontend changes preserve macOS/Windows behavior via runtime isLinux() checks. Known limitation: tiling Wayland compositors ignore set_size, so GDK_BACKEND=x11 remains the user-side workaround. --- CHANGELOG.md | 5 + src-tauri/src/lib.rs | 18 ++++ src-tauri/src/lightweight.rs | 8 ++ src-tauri/src/linux_fix.rs | 121 ++++++++++++++++++++++ src-tauri/src/main.rs | 6 ++ src-tauri/src/tray.rs | 4 + src/App.tsx | 27 +++-- src/components/common/FullScreenPanel.tsx | 38 ++++--- src/lib/platform.ts | 17 +++ 9 files changed, 219 insertions(+), 25 deletions(-) create mode 100644 src-tauri/src/linux_fix.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ac0c336..62c98b0a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Linux UI Unresponsive on Startup**: Fixed a bug where the window UI (including native title bar buttons) couldn't receive clicks on Linux until the user manually maximized and restored the window. Root causes: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive. Mitigations: set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible "maximize-and-restore". Wired into all window-re-show paths (normal startup, deeplink, single_instance, tray `show_main`, lightweight exit). +- **Linux Drag Region on Header**: Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland. macOS drag behavior is preserved. + --- ## [3.12.3] - 2026-03-24 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 34673168c..393c88346 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod gemini_config; mod gemini_mcp; mod init_status; mod lightweight; +#[cfg(target_os = "linux")] +mod linux_fix; mod mcp; mod openclaw_config; mod opencode_config; @@ -132,6 +134,10 @@ fn handle_deeplink_url( let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } log::info!("✓ Window shown and focused"); } } @@ -229,6 +235,10 @@ pub fn run() { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } } })); } @@ -899,6 +909,14 @@ pub fn run() { // 正常启动模式:显示窗口 let _ = window.show(); log::info!("正常启动模式:主窗口已显示"); + + // Linux: 解决首次启动 UI 无响应问题(Tauri #10746 + wry #637)。 + // 启动时 webview 未获取焦点 + surface 尺寸协商失败,导致点击无效。 + // 这里做 set_focus + 伪 resize,等价于无视觉版本的"最大化-还原"。 + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } } } diff --git a/src-tauri/src/lightweight.rs b/src-tauri/src/lightweight.rs index c16af79ef..78e5c9174 100644 --- a/src-tauri/src/lightweight.rs +++ b/src-tauri/src/lightweight.rs @@ -36,6 +36,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } #[cfg(target_os = "windows")] { let _ = window.set_skip_taskbar(false); @@ -66,6 +70,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } } #[cfg(target_os = "windows")] diff --git a/src-tauri/src/linux_fix.rs b/src-tauri/src/linux_fix.rs new file mode 100644 index 000000000..35bace1a3 --- /dev/null +++ b/src-tauri/src/linux_fix.rs @@ -0,0 +1,121 @@ +//! Linux 专用的主窗口恢复补丁。 +//! +//! 解决 Tauri 2.x 在部分 Linux 发行版(尤其是 Wayland / 某些 WebKitGTK +//! 版本)上启动后 UI 无法响应点击的问题: +//! +//! - **失效模式 A**(Tauri #10746 / wry #637):webview 在 `show()` 后 +//! 没有获得 keyboard focus,导致首次点击被 X11/Wayland 用作 +//! click-to-activate 而非传给 webview。 +//! - **失效模式 B**:GTK surface 与 WebKitWebView 的 input region 尺寸 +//! 协商在 `visible:false` → `show()` 的路径上失败,整窗永远不响应 +//! 点击,只有重新 `size_allocate`(例如最大化-还原)才能恢复。 +//! +//! 本模块导出 [`nudge_main_window`],它通过「显式 set_focus + 无视觉 +//! 版本的 ±1px 伪 resize」精确模拟用户手动最大化再还原的 workaround, +//! 但肉眼无法察觉。所有"让主窗口出现在用户面前"的路径(正常启动、 +//! deeplink 唤起、single_instance 回调、托盘 show_main、lightweight +//! 退出)都应在现有 `set_focus()` 之后追加一次调用。 + +use std::time::Duration; + +use tauri::{PhysicalSize, WebviewWindow}; + +/// 在 webview realize 之后的延迟,等 GTK 主循环把 realize 事件处理完。 +/// 200ms 是社区经验值;太短 set_focus 仍会无效,太长会让首屏可交互 +/// 时间被肉眼感知到。 +const REALIZE_WAIT: Duration = Duration::from_millis(200); + +/// ±1px 伪 resize 两步之间的间隔,确保 GTK 先处理了第一次 +/// `size_allocate` 再收到第二次 resize。放宽到 100ms 是因为 Tao 在 Linux +/// 上的尺寸 API 是异步的(底层走 `gtk_window_resize` → 合成器 configure), +/// 太短会让合成器把两次连续 resize coalesce 成一次。 +const RESIZE_GAP: Duration = Duration::from_millis(100); + +/// 尺寸对账回读前的额外等待。200ms + 100ms + 500ms = 总共 ~800ms 后 +/// 校验窗口尺寸是否回到 original。这个时间足够所有合成器处理完 +/// resize 消息队列。 +const RECONCILE_WAIT: Duration = Duration::from_millis(500); + +/// 对主窗口执行 Linux 专用的「focus + surface 重激活」序列。 +/// +/// 调用是 fire-and-forget:内部 spawn 一个异步任务在 ~250ms 后完成。 +/// 调用线程立即返回,不阻塞 UI。 +pub(crate) fn nudge_main_window(window: WebviewWindow) { + // 第一次 set_focus:webview 可能还没 realize,这一次通常是无效的, + // 但成本极低(线程安全,内部 run_on_main_thread),顺手做掉。 + let _ = window.set_focus(); + + tauri::async_runtime::spawn(async move { + tokio::time::sleep(REALIZE_WAIT).await; + + // 第二次 set_focus:此时 webview realize 已完成,在绝大多数 + // 发行版上这一次会真的生效,消除失效模式 A。 + let _ = window.set_focus(); + + // 伪 resize:读取当前 inner_size,先加 1px 再还原。这会触发 + // GTK 的 size-allocate → WebKitWebViewBase::size_allocate → + // 重新 attach input surface,消除失效模式 B。 + // + // 使用 PhysicalSize 避免跨 DPI 的逻辑坐标漂移;saturating_add + // 防止极端尺寸溢出。 + match window.inner_size() { + Ok(original) => { + let bumped = PhysicalSize::new(original.width.saturating_add(1), original.height); + let _ = window.set_size(bumped); + tokio::time::sleep(RESIZE_GAP).await; + let _ = window.set_size(original); + log::info!("Linux: 已对主窗口执行 focus + surface 重激活"); + + // 尺寸对账回读:Tao Linux 的尺寸 API 是异步的,`set_size` 只是把 + // resize 请求送进 GTK 主循环队列,合成器可能会 coalesce 两次连续 + // 请求(尤其是第二次 `set_size(original)`),导致窗口永久停留在 + // width+1。这里等合成器处理完队列后读一次实际尺寸,发现 drift 就 + // 再补一次 `set_size(original)` 兜底。 + // + // 已知限制:tiling Wayland 合成器(sway/river/hyprland)会完全忽略 + // `set_size`,此时对账永远 drift=0(因为两次 set_size 都是 no-op), + // 看起来"没问题"但失效模式 B 其实没被修复;这是已知限制,需要用户 + // 侧用 GDK_BACKEND=x11 绕过,README 应该有说明。 + tokio::time::sleep(RECONCILE_WAIT).await; + match window.inner_size() { + Ok(after) => { + if after.width != original.width || after.height != original.height { + log::info!( + "Linux nudge 尺寸 drift: expected={}x{}, got={}x{},已补偿", + original.width, + original.height, + after.width, + after.height + ); + let _ = window.set_size(original); + // 最终校验:如果补偿后仍然不一致,记 warn 让用户/开发者 + // 知道对账失败。这时窗口会停在非预期尺寸(通常是 +1px), + // 属于极端兜底场景。 + if let Ok(final_size) = window.inner_size() { + if final_size.width != original.width + || final_size.height != original.height + { + log::warn!( + "Linux nudge 尺寸 drift 补偿后仍不一致: expected={}x{}, got={}x{}", + original.width, + original.height, + final_size.width, + final_size.height + ); + } + } + } + } + Err(e) => { + log::warn!("Linux nudge: 对账回读 inner_size 失败: {e}"); + } + } + } + Err(e) => { + // 极罕见的失败路径;只做了 set_focus 也比什么都不做强, + // 不要让 resize 失败把整个补丁吞掉。 + log::warn!("Linux nudge: 读取 inner_size 失败,跳过伪 resize: {e}"); + } + } + }); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d1f85285..9d81b848f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,12 @@ fn main() { if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } + // 禁用 WebKitGTK 合成模式,规避 resize 时 webview 崩溃以及部分 Wayland + // 合成器下的 surface 协商问题(整窗 UI 点击无响应、必须最大化-还原才能恢复)。 + // 参考: https://github.com/tauri-apps/tauri/issues/9394 + if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } } cc_switch_lib::run(); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 9f9b06c58..31af81b42 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -424,6 +424,10 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } #[cfg(target_os = "macos")] { apply_tray_policy(app, true); diff --git a/src/App.tsx b/src/App.tsx index 750c5ba2f..63d6d05cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,12 @@ import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; import { isTextEditableTarget } from "@/utils/domUtils"; import { cn } from "@/lib/utils"; -import { isWindows, isLinux } from "@/lib/platform"; +import { + isWindows, + isLinux, + DRAG_REGION_ATTR, + DRAG_REGION_STYLE, +} from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; @@ -876,11 +881,13 @@ function App() { className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} > -
+ {DRAG_BAR_HEIGHT > 0 && ( +
+ )} {showEnvBanner && envConflicts.length > 0 && (
= ({ className="fixed inset-0 z-[60] flex flex-col" style={{ backgroundColor: "hsl(var(--background))" }} > - {/* Drag region - match App.tsx */} -
+ {/* Drag region - match App.tsx. Linux 上 DRAG_BAR_HEIGHT=0, + 直接跳过整个元素;macOS 保留 28px 拖拽占位。 */} + {DRAG_BAR_HEIGHT > 0 && ( +
+ )} {/* Header - match App.tsx */}
= ({ >
+ + + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f2475be26..e41632edf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -42,6 +42,12 @@ "enabled": "Enabled", "notSet": "Not Set" }, + "firstRunNotice": { + "title": "Welcome to CC Switch", + "bodyDefault": "CC Switch lets you switch between multiple Claude Code / Codex / Gemini CLI providers with a single click. If you already had these tools configured, CC Switch has saved your existing setup as a provider named “default” so none of your previous configuration is lost.", + "bodyOfficial": "An “Official” preset is also included in the list — click it any time you want to switch back to the official default. CC Switch automatically backs up your current config to “default” before switching, so you can move back and forth freely. That's how CC Switch works 😊", + "confirm": "Got it" + }, "apiKeyInput": { "placeholder": "Enter API Key", "show": "Show API Key", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3aa2f7006..200d14acd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -42,6 +42,12 @@ "enabled": "有効", "notSet": "未設定" }, + "firstRunNotice": { + "title": "CC Switch へようこそ", + "bodyDefault": "CC Switch は Claude Code / Codex / Gemini CLI の複数プロバイダーをワンクリックで切り替えられるツールです。すでにこれらのツールを設定済みの場合、CC Switch は現在の設定を「default」という名前のプロバイダーとして自動的に保存するので、既存の設定が失われることはありません。", + "bodyOfficial": "リストには「Official(公式)」プリセットも用意されており、公式バージョンに戻したくなったらクリックするだけで切り替えられます。切り替える前に現在の設定は自動で default にバックアップされるので、自由に行き来できます。これが CC Switch の仕組みです 😊", + "confirm": "了解しました" + }, "apiKeyInput": { "placeholder": "API Key を入力", "show": "API Key を表示", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index be42ae4e7..52a318141 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -42,6 +42,12 @@ "enabled": "已开启", "notSet": "未设置" }, + "firstRunNotice": { + "title": "欢迎使用 CC Switch", + "bodyDefault": "CC Switch 可以帮你在 Claude Code / Codex / Gemini CLI 的多个供应商之间一键切换。如果你之前已经配置过这些工具,CC Switch 会自动把现有设置保存为名为 “default” 的供应商,保证你的配置不会丢失。", + "bodyOfficial": "列表里还预置了 “官方(Official)” 供应商,随时点一下即可切回官方默认配置。切换前 CC Switch 会自动把当前配置备份回 default,可以放心来回切。CC Switch 就是这样工作的 😊", + "confirm": "我知道了" + }, "apiKeyInput": { "placeholder": "请输入API Key", "show": "显示API Key", diff --git a/src/types.ts b/src/types.ts index f3abcb0f3..d7229ec98 100644 --- a/src/types.ts +++ b/src/types.ts @@ -264,6 +264,8 @@ export interface Settings { enableFailoverToggle?: boolean; // User has confirmed the failover toggle first-run notice failoverConfirmed?: boolean; + // User has confirmed the first-run welcome notice + firstRunNoticeConfirmed?: boolean; // User has confirmed the auto-sync traffic warning autoSyncConfirmed?: boolean; // 首选语言(可选,默认中文) From bafe9e820dce9e1200b65e30ff03f5ccd2208502 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 15:16:35 +0800 Subject: [PATCH 42/77] fix(notifications): remove duplicate toast when switching to proxy providers When switching to Copilot/ChatGPT/OpenAI-format providers with the proxy not running, two toasts appeared: a "proxy required" warning followed by a "switch success" toast. Unify the post-switch toast logic so that all provider types show a single success toast, and skip it entirely when a proxy-required warning was already shown. --- src/hooks/useProviderActions.ts | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 346de6e5b..85d125c34 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -200,27 +200,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { ); } - // 根据供应商类型显示不同的成功提示 - if ( - !proxyRequiredReason && - activeApp === "claude" && - provider.category !== "official" && - (isCopilotProvider || - provider.meta?.apiFormat === "openai_chat" || - provider.meta?.apiFormat === "openai_responses") - ) { - // OpenAI format provider: show proxy hint (skip if warning already shown) - toast.info( - isCopilotProvider - ? t("notifications.copilotProxyHint") - : t("notifications.openAIFormatHint"), - { - duration: 5000, - closeButton: true, - }, - ); - } else { - // 普通供应商:显示切换成功 + // 若已弹过 proxyRequired 警告则不再弹 success + if (!proxyRequiredReason) { // OpenCode/OpenClaw: show "added to config" message instead of "switched" const isMultiProviderApp = activeApp === "opencode" || activeApp === "openclaw"; From 521571c4ca31d4346731f9703a0e53dcd9c104da Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 15:19:51 +0800 Subject: [PATCH 43/77] fix(usage): only show CLI subscription quota for active provider CLI-credential-based subscriptions (Claude/Codex/Gemini) read from a single global credential file, so the quota always reflects the last CLI login rather than a specific provider. Showing it on non-current cards is misleading when multiple official subscriptions exist. Apply the same isCurrent + autoQuery pattern already used by Copilot and Codex OAuth: only query and render the quota footer when the provider is the currently active one. --- src/components/SubscriptionQuotaFooter.tsx | 6 +++++- src/components/providers/ProviderCard.tsx | 2 +- src/lib/query/subscription.ts | 11 ++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index e345b58f9..fce6ea09e 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -8,6 +8,7 @@ import type { QuotaTier, SubscriptionQuota } from "@/types/subscription"; interface SubscriptionQuotaFooterProps { appId: AppId; inline?: boolean; + isCurrent?: boolean; } interface SubscriptionQuotaViewProps { @@ -386,12 +387,15 @@ const TierBar: React.FC<{ const SubscriptionQuotaFooter: React.FC = ({ appId, inline = false, + isCurrent = false, }) => { const { data: quota, isFetching: loading, refetch, - } = useSubscriptionQuota(appId, true); + } = useSubscriptionQuota(appId, isCurrent, isCurrent); + + if (!isCurrent) return null; return ( ) : isOfficial ? ( - + ) : hasMultiplePlans ? (
diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index 71248e7f6..f79dd1d6b 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -7,13 +7,18 @@ import { PROVIDER_TYPES } from "@/config/constants"; const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes -export function useSubscriptionQuota(appId: AppId, enabled: boolean) { +export function useSubscriptionQuota( + appId: AppId, + enabled: boolean, + autoQuery = false, +) { return useQuery({ queryKey: ["subscription", "quota", appId], queryFn: () => subscriptionApi.getQuota(appId), enabled: enabled && ["claude", "codex", "gemini"].includes(appId), - refetchInterval: REFETCH_INTERVAL, - refetchOnWindowFocus: true, + refetchInterval: autoQuery ? REFETCH_INTERVAL : false, + refetchIntervalInBackground: autoQuery, + refetchOnWindowFocus: autoQuery, staleTime: REFETCH_INTERVAL, retry: 1, }); From ecb1fed5db6a54b357a279e9e7b5c691848c065d Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 16:20:22 +0800 Subject: [PATCH 44/77] feat(common-config): add guide info and empty state to common config editor Add an informational alert block at the top of the common config snippet editor modal (Claude/Codex/Gemini) explaining what the feature is, why it exists, and how to use it. Also add an empty state prompt when no snippet has been extracted yet, guiding users to click "Extract from Editor". Includes i18n support for zh/en/ja. --- .../forms/CodexCommonConfigModal.tsx | 30 ++++++++++++++-- .../providers/forms/CommonConfigEditor.tsx | 35 +++++++++++++++---- .../forms/GeminiCommonConfigModal.tsx | 32 +++++++++++++++-- src/i18n/locales/en.json | 9 +++++ src/i18n/locales/ja.json | 9 +++++ src/i18n/locales/zh.json | 9 +++++ 6 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx index 67204ff59..0c0207d91 100644 --- a/src/components/providers/forms/CodexCommonConfigModal.tsx +++ b/src/components/providers/forms/CodexCommonConfigModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Save, Download, Loader2 } from "lucide-react"; +import { Save, Download, Loader2, Package } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { Button } from "@/components/ui/button"; @@ -100,9 +100,35 @@ export const CodexCommonConfigModal: React.FC = ({ } >
-

+

+

+ {t("commonConfig.guideTitle")} +

+

+ {t("commonConfig.guidePurpose")} +

+

+ {t("commonConfig.guideUsage")} +

+

+ {t("commonConfig.guideReExtract")} +

+

+ {t("commonConfig.guideReassurance")} +

+
+

{t("codexConfig.commonConfigHint")}

+ {(!draftValue || draftValue.trim() === "") && ( +
+ +

+ {t("commonConfig.emptyTitle")} +

+

{t("commonConfig.emptyHint")}

+
+ )}
-

- {t("claudeConfig.commonConfigHint", { - defaultValue: "通用配置片段将合并到所有启用它的供应商配置中", - })} -

+
+

+ {t("commonConfig.guideTitle")} +

+

+ {t("commonConfig.guidePurpose")} +

+

+ {t("commonConfig.guideUsage")} +

+

+ {t("commonConfig.guideReExtract")} +

+

+ {t("commonConfig.guideReassurance")} +

+
+ {(!commonConfigSnippet || + commonConfigSnippet.trim() === "" || + commonConfigSnippet.trim() === "{}") && ( +
+ +

+ {t("commonConfig.emptyTitle")} +

+

{t("commonConfig.emptyHint")}

+
+ )}
-

+

+

+ {t("commonConfig.guideTitle")} +

+

+ {t("commonConfig.guidePurpose")} +

+

+ {t("commonConfig.guideUsage")} +

+

+ {t("commonConfig.guideReExtract")} +

+

+ {t("commonConfig.guideReassurance")} +

+
+

{t("geminiConfig.commonConfigHint", { defaultValue: "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)", })}

+ {(!draftValue || + draftValue.trim() === "" || + draftValue.trim() === "{}") && ( +
+ +

+ {t("commonConfig.emptyTitle")} +

+

{t("commonConfig.emptyHint")}

+
+ )} Date: Thu, 9 Apr 2026 16:48:21 +0800 Subject: [PATCH 45/77] feat(common-config): show first-run notice dialog when editing providers Display a one-time informational dialog explaining the Common Config Snippet feature when users first open the add/edit provider form. Uses a derived isOpen state from settings to avoid race conditions. Adds commonConfigConfirmed flag to both TS and Rust settings types. --- src-tauri/src/settings.rs | 4 ++ .../providers/forms/ProviderForm.tsx | 40 ++++++++++++++++--- src/i18n/locales/en.json | 5 +++ src/i18n/locales/ja.json | 5 +++ src/i18n/locales/zh.json | 5 +++ src/types.ts | 2 + 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8c937092e..22e415a4c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -208,6 +208,9 @@ pub struct AppSettings { /// User has confirmed the first-run welcome notice #[serde(default, skip_serializing_if = "Option::is_none")] pub first_run_notice_confirmed: Option, + /// User has confirmed the common config first-run notice + #[serde(default, skip_serializing_if = "Option::is_none")] + pub common_config_confirmed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, @@ -301,6 +304,7 @@ impl Default for AppSettings { enable_failover_toggle: false, failover_confirmed: None, first_run_notice_confirmed: None, + common_config_confirmed: None, language: None, visible_apps: None, claude_config_dir: None, diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index f5f5f6dbb..a49eb1092 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1,14 +1,14 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; -import { providersApi, type AppId } from "@/lib/api"; +import { providersApi, settingsApi, type AppId } from "@/lib/api"; import type { ProviderCategory, ProviderMeta, @@ -84,6 +84,8 @@ import { useCopilotAuth, useCodexOauth, } from "./hooks"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { useSettingsQuery } from "@/lib/query"; import { CLAUDE_DEFAULT_CONFIG, CODEX_DEFAULT_CONFIG, @@ -141,6 +143,22 @@ export function ProviderForm({ }: ProviderFormProps) { const { t } = useTranslation(); const isEditMode = Boolean(initialData); + const queryClient = useQueryClient(); + const { data: settingsData } = useSettingsQuery(); + const showCommonConfigNotice = + settingsData != null && settingsData.commonConfigConfirmed !== true; + + const handleCommonConfigConfirm = async () => { + try { + if (settingsData) { + const { webdavSync: _, ...rest } = settingsData; + await settingsApi.save({ ...rest, commonConfigConfirmed: true }); + await queryClient.invalidateQueries({ queryKey: ["settings"] }); + } + } catch (error) { + console.error("Failed to save commonConfigConfirmed:", error); + } + }; const [selectedPresetId, setSelectedPresetId] = useState( initialData ? null : "custom", @@ -1342,7 +1360,8 @@ export function ProviderForm({ ); return ( -
+ <> +
)} - - + + + + void handleCommonConfigConfirm()} + onCancel={() => void handleCommonConfigConfirm()} + /> + ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e2b314a78..5a0f16b42 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -232,6 +232,11 @@ "title": "Enable Auto Sync", "message": "When auto sync is enabled, every database change will be automatically uploaded to the WebDAV server.\n\nThis may result in significant network traffic. Please ensure your network and WebDAV service can handle frequent data transfers.", "confirm": "I understand, enable" + }, + "commonConfig": { + "title": "About Common Config", + "message": "The \"Common Config Snippet\" lets you share plugins, environment variables, and other settings across different providers — so they won't be lost when you switch.\n\nHow to use:\n① Edit a provider → click \"Edit Common Config\" → \"Extract from Editor\"\n② When creating a new provider, check \"Write Common Config\" (enabled by default)\n\nIf you've newly installed plugins or hooks, re-extract the common config to keep them in sync.", + "confirm": "Got it" } }, "settings": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a13e00628..bb0c8bf43 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -232,6 +232,11 @@ "title": "自動同期を有効にする", "message": "自動同期を有効にすると、データベースの変更ごとに WebDAV サーバーへ自動アップロードされます。\n\nネットワークトラフィックが大幅に増加する可能性があります。ネットワーク環境と WebDAV サービスが頻繁なデータ転送に対応できることをご確認ください。", "confirm": "理解しました、有効にする" + }, + "commonConfig": { + "title": "共通設定について", + "message": "「共通設定スニペット」を使うと、プラグインや環境変数などの設定を異なるプロバイダー間で共有でき、切り替え時に失われることがありません。\n\n使い方:\n① プロバイダーを編集 →「共通設定を編集」→「編集内容から抽出」\n② 新規プロバイダー作成時に「共通設定を書き込む」をチェック(デフォルトでオン)\n\n新しいプラグインや Hook をインストールした場合は、共通設定を再抽出してください。", + "confirm": "わかりました" } }, "settings": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cb9472515..8696647dd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -232,6 +232,11 @@ "title": "开启自动同步", "message": "开启自动同步后,每次数据库变更都会自动上传到 WebDAV 服务器。\n\n这可能会产生较高的网络流量消耗,请确保您的网络环境和 WebDAV 服务支持频繁的数据传输。", "confirm": "我已了解,继续开启" + }, + "commonConfig": { + "title": "关于通用配置", + "message": "「通用配置片段」可以在不同供应商之间共享插件、环境变量等配置,避免切换供应商时丢失这些设置。\n\n使用方法:\n① 编辑供应商时点击「编辑通用配置」→「从编辑内容提取」\n② 新建供应商时勾选「写入通用配置」(默认已勾选)\n\n如果您新安装了插件或 Hook,请重新提取一次通用配置。", + "confirm": "我知道了" } }, "settings": { diff --git a/src/types.ts b/src/types.ts index d7229ec98..c1b674418 100644 --- a/src/types.ts +++ b/src/types.ts @@ -268,6 +268,8 @@ export interface Settings { firstRunNoticeConfirmed?: boolean; // User has confirmed the auto-sync traffic warning autoSyncConfirmed?: boolean; + // User has confirmed the common config first-run notice + commonConfigConfirmed?: boolean; // 首选语言(可选,默认中文) language?: "en" | "zh" | "ja"; From 129b1b45b057353a30c8bfa655847d64f9cb8857 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 17:13:19 +0800 Subject: [PATCH 46/77] docs: add Shengsuanyun as sponsor in all README languages --- README.md | 5 +++++ README_JA.md | 5 +++++ README_ZH.md | 5 +++++ assets/partners/logos/shengsuanyun.svg | 4 ++++ 4 files changed, 19 insertions(+) create mode 100644 assets/partners/logos/shengsuanyun.svg diff --git a/README.md b/README.md index f5f222812..2933f95ad 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e 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. + +Shengsuanyun +Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with monitoring dashboards showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via this link as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up. + + 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! diff --git a/README_JA.md b/README_JA.md index 0340b4c35..2616c5da2 100644 --- a/README_JA.md +++ b/README_JA.md @@ -41,6 +41,11 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 + +Shengsuanyun +胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、監視ダッシュボードは常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。このリンクから新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。 + + AIGoCode 本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! diff --git a/README_ZH.md b/README_ZH.md index 6577ceadb..a80ec67a6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -41,6 +41,11 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 + +Shengsuanyun +感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、监测接口日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用此链接注册新用户可获10元模力及首充10%赠送。 + + AIGoCode 感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! diff --git a/assets/partners/logos/shengsuanyun.svg b/assets/partners/logos/shengsuanyun.svg new file mode 100644 index 000000000..e9ceb5b82 --- /dev/null +++ b/assets/partners/logos/shengsuanyun.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From 97152c0f21dcd4a2c7333b520a20bb1f3c5cbfe2 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 17:25:15 +0800 Subject: [PATCH 47/77] docs: add LionCC as sponsor in all README languages --- README.md | 5 +++++ README_JA.md | 5 +++++ README_ZH.md | 5 +++++ assets/partners/logos/lioncc.png | Bin 0 -> 27305 bytes 4 files changed, 15 insertions(+) create mode 100644 assets/partners/logos/lioncc.png diff --git a/README.md b/README.md index 2933f95ad..9f18acfa5 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click here to purchase! + +LionCC +Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click here to register! + + diff --git a/README_JA.md b/README_JA.md index 2616c5da2..7e401cab2 100644 --- a/README_JA.md +++ b/README_JA.md @@ -112,6 +112,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。こちらから購入してください! + +LionCC +LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChat(HSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。こちらから登録してください! + + diff --git a/README_ZH.md b/README_ZH.md index a80ec67a6..8c7460ee8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -113,6 +113,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击这里购买! + +LionCC +感谢 LionCC 狮子API 赞助了本项目!LionCC 专为追求极致开发体验的”Vibe Coders”而生。我们提供稳定、低延迟、优惠价格的 Claude Code、Codex 及 OpenClaw 算力服务,可节约 50% 成本。注册后添加客服微信 HSQBJ088888888,发暗号 cc-switch 备注即可送 10 美金额度(1000 万 token 算力)。其他项目合作关注博客 @LionCC.ai,点击这里注册! + + diff --git a/assets/partners/logos/lioncc.png b/assets/partners/logos/lioncc.png new file mode 100644 index 0000000000000000000000000000000000000000..3f7975e04334bf64ffc095ce2cc26407bda8f6f9 GIT binary patch literal 27305 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 Date: Thu, 9 Apr 2026 17:40:46 +0800 Subject: [PATCH 48/77] docs: add DDS as sponsor in all README languages --- README.md | 6 ++++++ README_JA.md | 7 +++++++ README_ZH.md | 5 +++++ assets/partners/logos/dds.png | Bin 0 -> 101161 bytes 4 files changed, 18 insertions(+) create mode 100644 assets/partners/logos/dds.png diff --git a/README.md b/README.md index 9f18acfa5..a173e85d9 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,12 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click here to register! + +DDS +Thanks to DDS for sponsoring this project! DDS Hub is a reliable and high-performance Claude API proxy service. We provides cost-effective domestic Claude direct acceleration services for both individual and enterprise users. We offer stable and low-latency Claude Max number pools, with full support for Claude Haiku, Opus, Sonnet and other flagship models. Invoices are available for recharges of 1000 RMB or more. Enterprise customers can also enjoy customized grouping and dedicated technical support services. +Exclusive benefit for CC Switch users: Register via the link below and enjoy an extra 10% credit on your first recharge (please contact the group admin to claim after recharging)! + + diff --git a/README_JA.md b/README_JA.md index 7e401cab2..6d1e41603 100644 --- a/README_JA.md +++ b/README_JA.md @@ -117,6 +117,13 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChat(HSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。こちらから登録してください! + +DDS +本プロジェクトのスポンサーである DDS に感謝いたします! DDS(呆呆獣 / DDS Hub)は、Claude に特化した信頼性とパフォーマンスの高い API プロキシサービスです。個人および企業ユーザーの皆様に、圧倒的なコストパフォーマンスを誇る Claude 直結アクセラレーションサービスを提供しています。Claude Haiku / Opus / Sonnet などのフルスペックモデルを完全サポートし、安定した低遅延のアクセスを実現します。 +1,000人民元以上のチャージで領収書(発票)の発行が可能です。さらに、企業のお客様にはカスタマイズされたグループ管理や専用テクニカルサポートをご提供しています。 +CC Switch ユーザー限定特典: 専用リンクからご登録いただくと、初回チャージ時に 10% の追加ボーナスクレジット をプレゼントいたします!(※チャージ完了後、グループ管理人へご連絡の上お受け取りください。) + + diff --git a/README_ZH.md b/README_ZH.md index 8c7460ee8..b60d1b080 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -118,6 +118,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢 LionCC 狮子API 赞助了本项目!LionCC 专为追求极致开发体验的”Vibe Coders”而生。我们提供稳定、低延迟、优惠价格的 Claude Code、Codex 及 OpenClaw 算力服务,可节约 50% 成本。注册后添加客服微信 HSQBJ088888888,发暗号 cc-switch 备注即可送 10 美金额度(1000 万 token 算力)。其他项目合作关注博客 @LionCC.ai,点击这里注册! + +DDS +感谢 DDS 赞助本项目!呆呆兽是一家专注 Claude 的可靠高效 API 中转站,为个人和企业用户提供极具性价比的国内 Claude 直连加速服务。支持 Claude Haiku / Opus / Sonnet 等满血模型。充值满 1000 元即可开具发票,企业客户更可享受定制化分组和技术支持服务。CC Switch 用户专属福利:通过此链接注册后,首单充值可额外赠送 10% 额度(充值后请联系群主领取)! + + diff --git a/assets/partners/logos/dds.png b/assets/partners/logos/dds.png new file mode 100644 index 0000000000000000000000000000000000000000..ded569ea180b360d65586af0842a953ff159e486 GIT binary patch literal 101161 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�*3$b{TV-T!?xvS(@b_UjR4hGL$rTJiu2 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 Date: Thu, 9 Apr 2026 21:13:32 +0800 Subject: [PATCH 49/77] style: fix prettier formatting in ProviderCard and ProviderForm --- src/components/providers/ProviderCard.tsx | 6 +- .../providers/forms/ProviderForm.tsx | 948 +++++++++--------- 2 files changed, 484 insertions(+), 470 deletions(-) diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 3f999937d..e85973e5b 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -369,7 +369,11 @@ export function ProviderCard({ isCurrent={isCurrent} /> ) : isOfficial ? ( - + ) : hasMultiplePlans ? (
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index a49eb1092..35de812d7 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1362,416 +1362,422 @@ export function ProviderForm({ return ( <>
- - {!initialData && ( - - )} - - - - - opencodeForm.setOpencodeProviderKey( - e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), - ) - } - placeholder={t("opencode.providerKeyPlaceholder")} - disabled={ - isProviderKeyLocked || isProviderKeyLockStateLoading - } - className={ - (additiveExistingProviderKeys.includes( - opencodeForm.opencodeProviderKey, - ) && - !isProviderKeyLocked) || - (opencodeForm.opencodeProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - opencodeForm.opencodeProviderKey, - )) - ? "border-destructive" - : "" - } - /> - {additiveExistingProviderKeys.includes( - opencodeForm.opencodeProviderKey, - ) && - !isProviderKeyLocked && ( -

- {t("opencode.providerKeyDuplicate")} -

- )} - {opencodeForm.opencodeProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - opencodeForm.opencodeProviderKey, - ) && ( -

- {t("opencode.providerKeyInvalid")} -

- )} - {!( - additiveExistingProviderKeys.includes( - opencodeForm.opencodeProviderKey, - ) && !isProviderKeyLocked - ) && - (opencodeForm.opencodeProviderKey.trim() === "" || - /^[a-z0-9]+(-[a-z0-9]+)*$/.test( - opencodeForm.opencodeProviderKey, - )) && ( -

- {isProviderKeyLocked - ? t("opencode.providerKeyLockedHint", { - defaultValue: - "该供应商已添加到应用配置中,供应商标识不可修改", - }) - : t("opencode.providerKeyHint")} -

- )} -
- ) : appId === "openclaw" ? ( -
- - - openclawForm.setOpenclawProviderKey( - e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), - ) - } - placeholder={t("openclaw.providerKeyPlaceholder")} - disabled={ - isProviderKeyLocked || isProviderKeyLockStateLoading - } - className={ - (additiveExistingProviderKeys.includes( - openclawForm.openclawProviderKey, - ) && - !isProviderKeyLocked) || - (openclawForm.openclawProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - openclawForm.openclawProviderKey, - )) - ? "border-destructive" - : "" - } - /> - {additiveExistingProviderKeys.includes( - openclawForm.openclawProviderKey, - ) && - !isProviderKeyLocked && ( -

- {t("openclaw.providerKeyDuplicate")} -

- )} - {openclawForm.openclawProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - openclawForm.openclawProviderKey, - ) && ( -

- {t("openclaw.providerKeyInvalid")} -

- )} - {!( - additiveExistingProviderKeys.includes( - openclawForm.openclawProviderKey, - ) && !isProviderKeyLocked - ) && - (openclawForm.openclawProviderKey.trim() === "" || - /^[a-z0-9]+(-[a-z0-9]+)*$/.test( - openclawForm.openclawProviderKey, - )) && ( -

- {isProviderKeyLocked - ? t("openclaw.providerKeyLockedHint", { - defaultValue: - "该供应商已添加到应用配置中,供应商标识不可修改", - }) - : t("openclaw.providerKeyHint")} -

- )} -
- ) : undefined - } - /> - - {appId === "claude" && ( - - )} - - {appId === "codex" && ( - - )} - - {appId === "gemini" && ( - - )} - - {appId === "opencode" && !isAnyOmoCategory && ( - - )} - - {appId === "opencode" && - (category === "omo" || category === "omo-slim") && ( - + {!initialData && ( + )} - {/* OpenClaw 专属字段 */} - {appId === "openclaw" && ( - + + + opencodeForm.setOpencodeProviderKey( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ) + } + placeholder={t("opencode.providerKeyPlaceholder")} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } + className={ + (additiveExistingProviderKeys.includes( + opencodeForm.opencodeProviderKey, + ) && + !isProviderKeyLocked) || + (opencodeForm.opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + )) + ? "border-destructive" + : "" + } + /> + {additiveExistingProviderKeys.includes( + opencodeForm.opencodeProviderKey, + ) && + !isProviderKeyLocked && ( +

+ {t("opencode.providerKeyDuplicate")} +

+ )} + {opencodeForm.opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + ) && ( +

+ {t("opencode.providerKeyInvalid")} +

+ )} + {!( + additiveExistingProviderKeys.includes( + opencodeForm.opencodeProviderKey, + ) && !isProviderKeyLocked + ) && + (opencodeForm.opencodeProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + )) && ( +

+ {isProviderKeyLocked + ? t("opencode.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("opencode.providerKeyHint")} +

+ )} +
+ ) : appId === "openclaw" ? ( +
+ + + openclawForm.setOpenclawProviderKey( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ) + } + placeholder={t("openclaw.providerKeyPlaceholder")} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } + className={ + (additiveExistingProviderKeys.includes( + openclawForm.openclawProviderKey, + ) && + !isProviderKeyLocked) || + (openclawForm.openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + )) + ? "border-destructive" + : "" + } + /> + {additiveExistingProviderKeys.includes( + openclawForm.openclawProviderKey, + ) && + !isProviderKeyLocked && ( +

+ {t("openclaw.providerKeyDuplicate")} +

+ )} + {openclawForm.openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + ) && ( +

+ {t("openclaw.providerKeyInvalid")} +

+ )} + {!( + additiveExistingProviderKeys.includes( + openclawForm.openclawProviderKey, + ) && !isProviderKeyLocked + ) && + (openclawForm.openclawProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + )) && ( +

+ {isProviderKeyLocked + ? t("openclaw.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("openclaw.providerKeyHint")} +

+ )} +
+ ) : undefined + } /> - )} - {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} - {appId === "codex" ? ( - <> - - {settingsConfigErrorField} - - ) : appId === "gemini" ? ( - <> - - {settingsConfigErrorField} - - ) : appId === "opencode" && - (category === "omo" || category === "omo-slim") ? ( -
- - {}} - rows={14} - showValidation={false} - language="json" + )} + + {appId === "codex" && ( + -
- ) : appId === "opencode" && - category !== "omo" && - category !== "omo-slim" ? ( - <> + )} + + {appId === "gemini" && ( + + )} + + {appId === "opencode" && !isAnyOmoCategory && ( + + )} + + {appId === "opencode" && + (category === "omo" || category === "omo-slim") && ( + + )} + + {/* OpenClaw 专属字段 */} + {appId === "openclaw" && ( + + )} + + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} + {appId === "codex" ? ( + <> + + {settingsConfigErrorField} + + ) : appId === "gemini" ? ( + <> + + {settingsConfigErrorField} + + ) : appId === "opencode" && + (category === "omo" || category === "omo-slim") ? (
- + form.setValue("settingsConfig", config)} - placeholder={`{ + value={omoDraft.mergedOmoJsonPreview} + onChange={() => {}} + rows={14} + showValidation={false} + language="json" + /> +
+ ) : appId === "opencode" && + category !== "omo" && + category !== "omo-slim" ? ( + <> +
+ + form.setValue("settingsConfig", config)} + placeholder={`{ "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "https://your-api-endpoint.com", @@ -1779,82 +1785,86 @@ export function ProviderForm({ }, "models": {} }`} - rows={14} - showValidation={true} - language="json" - /> -
- {settingsConfigErrorField} - - ) : appId === "openclaw" ? ( - <> -
- - form.setValue("settingsConfig", config)} - placeholder={`{ + rows={14} + showValidation={true} + language="json" + /> +
+ {settingsConfigErrorField} + + ) : appId === "openclaw" ? ( + <> +
+ + form.setValue("settingsConfig", config)} + placeholder={`{ "baseUrl": "https://api.example.com/v1", "apiKey": "your-api-key-here", "api": "openai-completions", "models": [] }`} - rows={14} - showValidation={true} - language="json" + rows={14} + showValidation={true} + language="json" + /> +
+ ( + + + + )} /> + + ) : ( + <> + form.setValue("settingsConfig", value)} + useCommonConfig={useCommonConfig} + onCommonConfigToggle={handleCommonConfigToggle} + commonConfigSnippet={commonConfigSnippet} + onCommonConfigSnippetChange={handleCommonConfigSnippetChange} + commonConfigError={commonConfigError} + onEditClick={() => setIsCommonConfigModalOpen(true)} + isModalOpen={isCommonConfigModalOpen} + onModalClose={() => setIsCommonConfigModalOpen(false)} + onExtract={handleClaudeExtract} + isExtracting={isClaudeExtracting} + /> + {settingsConfigErrorField} + + )} + + {!isAnyOmoCategory && + appId !== "opencode" && + appId !== "openclaw" && ( + + )} + + {showButtons && ( +
+ +
- ( - - - - )} - /> - - ) : ( - <> - form.setValue("settingsConfig", value)} - useCommonConfig={useCommonConfig} - onCommonConfigToggle={handleCommonConfigToggle} - commonConfigSnippet={commonConfigSnippet} - onCommonConfigSnippetChange={handleCommonConfigSnippetChange} - commonConfigError={commonConfigError} - onEditClick={() => setIsCommonConfigModalOpen(true)} - isModalOpen={isCommonConfigModalOpen} - onModalClose={() => setIsCommonConfigModalOpen(false)} - onExtract={handleClaudeExtract} - isExtracting={isClaudeExtracting} - /> - {settingsConfigErrorField} - - )} - - {!isAnyOmoCategory && appId !== "opencode" && appId !== "openclaw" && ( - - )} - - {showButtons && ( -
- - -
- )} + )} From 5ac6fb5315e74f689fd881e84f208efeba07d9da Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 23:18:06 +0800 Subject: [PATCH 50/77] feat(session-manager): extract meaningful titles for Claude sessions Instead of showing directory basenames for all Claude sessions, extract titles from JSONL content with a priority chain: 1. custom-title metadata (set via /rename in Claude Code) 2. First real user message (skipping /clear, /compact caveats) 3. Directory basename (fallback) --- .../src/session_manager/providers/claude.rs | 175 +++++++++++++++++- 1 file changed, 168 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index 865b9efa9..226613d13 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -12,6 +12,7 @@ use super::utils::{ }; const PROVIDER_ID: &str = "claude"; +const TITLE_MAX_CHARS: usize = 80; pub fn scan_sessions() -> Vec { let root = get_claude_config_dir().join("projects"); @@ -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, @@ -151,11 +153,41 @@ fn parse_session(path: &Path) -> Option { if created_at.is_none() { created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } + // Extract first real user message as title candidate + // Skip system-injected caveats and slash commands (e.g. /clear, /compact) + if first_user_message.is_none() { + let is_user = value.get("type").and_then(Value::as_str) == Some("user") + || value + .get("message") + .and_then(|m| m.get("role")) + .and_then(Value::as_str) + == Some("user"); + if is_user { + if let Some(message) = value.get("message") { + let text = message.get("content").map(extract_text).unwrap_or_default(); + let trimmed = text.trim(); + if !trimmed.is_empty() + && !trimmed.contains("") + && !trimmed.starts_with("") + { + 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) + // Extract last_active_at, summary, and custom-title from tail lines (reverse order) let mut last_active_at: Option = None; let mut summary: Option = None; + let mut custom_title: Option = None; for line in tail.iter().rev() { let value: Value = match serde_json::from_str(line) { @@ -165,6 +197,16 @@ fn parse_session(path: &Path) -> Option { if last_active_at.is_none() { last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } + // Look for custom-title entry (take the last one, i.e. first in reverse) + if custom_title.is_none() + && value.get("type").and_then(Value::as_str) == Some("custom-title") + { + custom_title = value + .get("customTitle") + .and_then(Value::as_str) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + } if summary.is_none() { if value.get("isMeta").and_then(Value::as_bool) == Some(true) { continue; @@ -176,7 +218,7 @@ fn parse_session(path: &Path) -> Option { } } } - if last_active_at.is_some() && summary.is_some() { + if last_active_at.is_some() && summary.is_some() && custom_title.is_some() { break; } } @@ -184,10 +226,16 @@ 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()); + // Title priority: custom-title > first user message > directory basename + let title = custom_title + .map(|t| truncate_summary(&t, TITLE_MAX_CHARS)) + .or_else(|| 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)); @@ -336,4 +384,117 @@ mod tests { assert_eq!(msgs[0].role, "user"); assert!(msgs[0].content.contains("Please continue")); } + + #[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!( + "{\"sessionId\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"sessionId\":\"session-abc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); + } + + #[test] + fn parse_session_custom_title_overrides_first_message() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-def.jsonl"); + std::fs::write( + &path, + concat!( + "{\"sessionId\":\"session-def\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"sessionId\":\"session-def\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n", + "{\"type\":\"custom-title\",\"customTitle\":\"fix-login-bug\",\"sessionId\":\"session-def\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("fix-login-bug")); + } + + #[test] + fn parse_session_falls_back_to_dir_basename() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-ghi.jsonl"); + std::fs::write( + &path, + concat!( + "{\"sessionId\":\"session-ghi\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + // No user message and no custom-title → 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!( + "{{\"sessionId\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\ + {{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"sessionId\":\"session-trunc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}}\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 parse_session_new_format_with_snapshot() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-new.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"请帮我重构这个函数\"},\"sessionId\":\"session-new\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"OK\"},\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("请帮我重构这个函数")); + } + + #[test] + fn parse_session_skips_command_caveat_and_slash_commands() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-clear.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"Caveat: The messages below were generated by the user while running local commands.\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"/clear\\nclear\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:01Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:00:02Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"帮我看看工作区的改动\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("帮我看看工作区的改动")); + } } From 308314baacfff769f90d12ff4d8281bf75ae2b1f Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 00:14:50 +0800 Subject: [PATCH 51/77] fix(session-manager): improve session search accuracy and Chinese support - Pre-filter sessions by provider before indexing to prevent result truncation when FlexSearch limit cuts across providers - Switch tokenizer from "forward" to "full" for Chinese substring matching - Preserve FlexSearch relevance ranking when search query is present --- src/hooks/useSessionSearch.ts | 49 ++++++++++++----------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/hooks/useSessionSearch.ts b/src/hooks/useSessionSearch.ts index 2f1b8c920..5d9710233 100644 --- a/src/hooks/useSessionSearch.ts +++ b/src/hooks/useSessionSearch.ts @@ -19,14 +19,18 @@ export function useSessionSearch({ sessions, providerFilter, }: UseSessionSearchOptions): UseSessionSearchResult { + const filteredByProvider = useMemo(() => { + if (providerFilter === "all") return sessions; + return sessions.filter((s) => s.providerId === providerFilter); + }, [sessions, providerFilter]); + const index = useMemo(() => { - // 使用 forward tokenizer 支持中文前缀搜索 const nextIndex = new FlexSearch.Index({ - tokenize: "forward", + tokenize: "full", resolution: 9, }); - sessions.forEach((session, idx) => { + filteredByProvider.forEach((session, idx) => { const metaContent = [ session.sessionId, session.title, @@ -41,49 +45,28 @@ export function useSessionSearch({ }); return nextIndex; - }, [sessions]); + }, [filteredByProvider]); - // 搜索函数 const search = useCallback( (query: string): SessionMeta[] => { - const needle = query.trim().toLowerCase(); + const needle = query.trim(); - // 先按 provider 过滤 - let filtered = sessions; - if (providerFilter !== "all") { - filtered = sessions.filter((s) => s.providerId === providerFilter); - } - - // 如果没有搜索词,返回按时间排序的结果 if (!needle) { - return [...filtered].sort((a, b) => { + return [...filteredByProvider].sort((a, b) => { const aTs = a.lastActiveAt ?? a.createdAt ?? 0; const bTs = b.lastActiveAt ?? b.createdAt ?? 0; return bTs - aTs; }); } - // 使用 FlexSearch 搜索 - const results = index.search(needle, { limit: 100 }) as number[]; + const results = index.search(needle, { + limit: filteredByProvider.length, + }) as number[]; - // 转换为 session 并过滤 - const matchedSessions = results - .map((idx) => sessions[idx]) - .filter( - (session) => - session && - (providerFilter === "all" || session.providerId === providerFilter), - ); - - // 按时间排序 - return matchedSessions.sort((a, b) => { - const aTs = a.lastActiveAt ?? a.createdAt ?? 0; - const bTs = b.lastActiveAt ?? b.createdAt ?? 0; - return bTs - aTs; - }); + return results.map((idx) => filteredByProvider[idx]); }, - [index, providerFilter, sessions], + [index, filteredByProvider], ); - return useMemo(() => ({ search }), [search]); + return { search }; } From f9fb9085acb3d1f6df6213246aa3a43200609251 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 08:30:37 +0800 Subject: [PATCH 52/77] feat(session-manager): highlight search keywords in session titles and messages --- src/components/sessions/SessionItem.tsx | 7 +++++- .../sessions/SessionManagerPage.tsx | 2 ++ .../sessions/SessionMessageItem.tsx | 13 +++++++++-- src/components/sessions/utils.ts | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/components/sessions/SessionItem.tsx b/src/components/sessions/SessionItem.tsx index dc2841c33..1cacc1405 100644 --- a/src/components/sessions/SessionItem.tsx +++ b/src/components/sessions/SessionItem.tsx @@ -15,6 +15,7 @@ import { getProviderIconName, getProviderLabel, getSessionKey, + highlightText, } from "./utils"; interface SessionItemProps { @@ -23,6 +24,7 @@ interface SessionItemProps { selectionMode: boolean; isChecked: boolean; isCheckDisabled?: boolean; + searchQuery?: string; onSelect: (key: string) => void; onToggleChecked: (checked: boolean) => void; } @@ -33,6 +35,7 @@ export function SessionItem({ selectionMode, isChecked, isCheckDisabled = false, + searchQuery, onSelect, onToggleChecked, }: SessionItemProps) { @@ -82,7 +85,9 @@ export function SessionItem({ {getProviderLabel(session.providerId, t)} - {title} + + {searchQuery ? highlightText(title, searchQuery) : title} + { if (el) messageRefs.current.set(index, el); }} diff --git a/src/components/sessions/SessionMessageItem.tsx b/src/components/sessions/SessionMessageItem.tsx index 2293241bf..1dd4d2b62 100644 --- a/src/components/sessions/SessionMessageItem.tsx +++ b/src/components/sessions/SessionMessageItem.tsx @@ -9,12 +9,18 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { SessionMessage } from "@/types"; -import { formatTimestamp, getRoleLabel, getRoleTone } from "./utils"; +import { + formatTimestamp, + getRoleLabel, + getRoleTone, + highlightText, +} from "./utils"; interface SessionMessageItemProps { message: SessionMessage; index: number; isActive: boolean; + searchQuery?: string; setRef: (el: HTMLDivElement | null) => void; onCopy: (content: string) => void; } @@ -22,6 +28,7 @@ interface SessionMessageItemProps { export function SessionMessageItem({ message, isActive, + searchQuery, setRef, onCopy, }: SessionMessageItemProps) { @@ -68,7 +75,9 @@ export function SessionMessageItem({ )}
- {message.content} + {searchQuery + ? highlightText(message.content, searchQuery) + : message.content}
); diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index 633d7136d..1c2c677a9 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; +import { createElement } from "react"; import { SessionMeta } from "@/types"; export const getSessionKey = (session: SessionMeta) => @@ -78,3 +80,23 @@ export const formatSessionTitle = (session: SessionMeta) => { session.sessionId.slice(0, 8) ); }; + +export const highlightText = (text: string, query: string): ReactNode => { + if (!query) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escaped})`, "gi")); + if (parts.length === 1) return text; + return parts.map((part, i) => + i % 2 === 1 + ? createElement( + "mark", + { + key: i, + className: + "bg-yellow-200/60 dark:bg-yellow-500/30 text-inherit rounded-sm px-0.5", + }, + part, + ) + : part, + ); +}; From cc1530f997f19649abc076a9bfcaf5668e8716e6 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 08:33:13 +0800 Subject: [PATCH 53/77] docs: add working directory feature implementation plan Design document for per-directory state switching (providers, MCP, skills, prompts). Not yet implemented - saved for future reference. --- docs/working-directory-plan.md | 597 +++++++++++++++++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/working-directory-plan.md diff --git a/docs/working-directory-plan.md b/docs/working-directory-plan.md new file mode 100644 index 000000000..ae2426146 --- /dev/null +++ b/docs/working-directory-plan.md @@ -0,0 +1,597 @@ +# CC-Switch "工作目录" 功能 — 实施方案 + +## Context + +CC-Switch 管理 5 个 CLI 工具(Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw)的供应商、MCP 服务器、Skills、提示词配置。当前所有启用状态是全局的——用户在不同项目间切换时需要手动 toggle。 + +本功能允许用户注册多个工作目录(项目文件夹),切换目录时自动保存/恢复各实体的启用状态。**不做数据隔离**——所有实体共享全局池,仅 "谁是激活的" 按目录区分。 + +--- + +## 一、需要按目录区分的实体(完整清单) + +| 实体 | 当前状态字段 | 存储方式 | 需要区分? | 理由 | +|------|-------------|---------|-----------|------| +| **Provider** | `is_current` | per `(id, app_type)` | **YES** | 不同项目用不同供应商 | +| **Provider (Failover)** | `in_failover_queue` | per `(id, app_type)` | **YES** | 备用供应商队列跟随主供应商配置 | +| **MCP Server** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 MCP 工具 | +| **Skill** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 Skills | +| **Prompt** | `enabled` | per `(id, app_type)`, 单选 | **YES** | 不同项目用不同系统提示词 | +| Proxy Config | `enabled`, thresholds | per `app_type` | NO | 基础设施级别,非项目相关 | +| Settings | key-value | flat table | NO | 全局用户偏好 | +| Provider Health | failures, errors | runtime | **CLEAR** | 切换时清除,重新计算 | +| Common Config | `common_config_{app}` | settings table | NO | 全局模板,非项目相关 | +| Usage/Logs | historical | various tables | NO | 历史数据,不应分区 | + +> 原计划遗漏了 **Failover Queue** 和 **Provider Health 清除**。 + +--- + +## 二、数据库变更(Schema v8 → v9) + +### 新增 5 张表 + +```sql +-- 1. 工作目录注册表 +CREATE TABLE IF NOT EXISTS working_directories ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + name TEXT, + is_current BOOLEAN NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0 +); + +-- 2. Provider 状态快照 (is_current + in_failover_queue) +-- 每个目录保存所有 provider 的两个状态标志 +CREATE TABLE IF NOT EXISTS dir_provider_state ( + dir_id TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + is_current BOOLEAN NOT NULL DEFAULT 0, + in_failover_queue BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, app_type, provider_id) +); + +-- 3. MCP 启用状态快照 (直接镜像 4 列,不做行展开) +CREATE TABLE IF NOT EXISTS dir_mcp_state ( + dir_id TEXT NOT NULL, + mcp_id TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, mcp_id) +); + +-- 4. Skill 启用状态快照 (直接镜像 4 列) +CREATE TABLE IF NOT EXISTS dir_skill_state ( + dir_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, skill_id) +); + +-- 5. Prompt 启用状态快照 (每个 app_type 只存激活的 prompt_id) +CREATE TABLE IF NOT EXISTS dir_prompt_state ( + dir_id TEXT NOT NULL, + app_type TEXT NOT NULL, + prompt_id TEXT NOT NULL, + PRIMARY KEY (dir_id, app_type) +); +``` + +### 设计决策说明 + +**MCP/Skill 用 4 列镜像而非 `(entity_id, app_type, enabled)` 行展开**: +- 与主表 `mcp_servers` / `skills` 结构一致,snapshot/apply 代码直接 copy 4 列 +- 避免 4 倍行膨胀(每个 MCP 服务器 1 行 vs 4 行) +- 未来增加新 app 时,两边同步加列即可 + +**Prompt 只存 `(dir_id, app_type, prompt_id)`**: +- 每个 app_type 最多一个 enabled prompt,不需要存 boolean +- 无记录 = 该 app 无激活 prompt + +**Provider 合并 `is_current` + `in_failover_queue`**: +- 两个标志都是 per `(app_type, provider_id)` 的状态 +- 存在同一表中避免多表 JOIN + +### 迁移脚本 + +在 `schema.rs` 中: +- `create_tables_on_conn()` 添加 5 个 CREATE TABLE +- 新增 `migrate_v8_to_v9(conn)`: 创建 5 张表 + 插入 `__default__` 行 +- `SCHEMA_VERSION` 升至 9 +- 迁移循环添加 `7 => ...` 后加 `8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }` + +```rust +fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> { + // 创建 5 张表(使用 IF NOT EXISTS,幂等) + // ... + // 插入 __default__ 虚拟目录,代表"全局默认"状态 + conn.execute( + "INSERT OR IGNORE INTO working_directories (id, path, name, is_current, created_at) \ + VALUES ('__default__', '__default__', NULL, 0, ?1)", + [crate::database::get_unix_timestamp()?], + )?; + Ok(()) +} +``` + +--- + +## 三、后端实现 + +### 3.1 DAO 层 — `src-tauri/src/database/dao/working_dir.rs` + +所有方法都是 `impl Database` 块,遵循现有 DAO 模式。 + +**关键方法签名**(需要 `_on_conn` 变体支持事务): + +```rust +// ═══ 工作目录 CRUD ═══ +pub fn list_working_directories(&self) -> Result, AppError> +pub fn add_working_directory(&self, id: &str, path: &str, name: Option<&str>) -> Result<(), AppError> +pub fn delete_working_directory(&self, id: &str) -> Result<(), AppError> +pub fn rename_working_directory(&self, id: &str, name: &str) -> Result<(), AppError> +pub fn get_current_working_directory(&self) -> Result, AppError> + +// 使用 _on_conn 变体,在 Service 层的事务中调用 +fn set_current_working_directory_on_conn(conn: &Connection, id: &str) -> Result<(), AppError> + +// ═══ 快照写入 ═══ (都有 _on_conn 变体) +fn snapshot_providers_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_mcp_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_skills_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_prompts_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> + +// ═══ 快照恢复 ═══ (都有 _on_conn 变体, 返回 bool = 是否有快照) +fn apply_provider_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +``` + +**snapshot_providers 实现思路**: +```sql +-- 先清除旧快照 +DELETE FROM dir_provider_state WHERE dir_id = ?1; +-- 从主表复制当前状态 +INSERT INTO dir_provider_state (dir_id, app_type, provider_id, is_current, in_failover_queue) +SELECT ?1, app_type, id, is_current, in_failover_queue +FROM providers +WHERE is_current = 1 OR in_failover_queue = 1; +``` + +**apply_provider_snapshot 实现思路**: +```sql +-- 检查是否有快照 +SELECT COUNT(*) FROM dir_provider_state WHERE dir_id = ?1; -- 如果 0,返回 false + +-- 在事务中:先清除主表所有 is_current 和 in_failover_queue +UPDATE providers SET is_current = 0; +UPDATE providers SET in_failover_queue = 0; + +-- 从快照恢复 +UPDATE providers SET is_current = 1 +WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND is_current = 1); + +UPDATE providers SET in_failover_queue = 1 +WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND in_failover_queue = 1); +``` + +**snapshot_mcp / snapshot_skills 实现思路**(直接镜像 4 列): +```sql +DELETE FROM dir_mcp_state WHERE dir_id = ?1; +INSERT INTO dir_mcp_state (dir_id, mcp_id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode) +SELECT ?1, id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode +FROM mcp_servers; +``` + +**apply_mcp_snapshot 实现思路**: +```sql +-- 先全部禁用 +UPDATE mcp_servers SET enabled_claude = 0, enabled_codex = 0, enabled_gemini = 0, enabled_opencode = 0; + +-- 从快照恢复 +UPDATE mcp_servers SET + enabled_claude = (SELECT enabled_claude FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_codex = (SELECT enabled_codex FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_gemini = (SELECT enabled_gemini FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_opencode = (SELECT enabled_opencode FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id) +WHERE id IN (SELECT mcp_id FROM dir_mcp_state WHERE dir_id = ?1); +``` + +### 3.2 Service 层 — `src-tauri/src/services/working_dir.rs` + +```rust +use crate::store::AppState; +use crate::error::AppError; +use crate::database::lock_conn; +use crate::app_config::AppType; +use crate::services::{McpService, ProviderService, SkillService}; +use crate::config::write_text_file; +use crate::prompt_files::prompt_file_path; + +pub struct WorkingDirService; + +impl WorkingDirService { + /// 核心切换逻辑 + pub fn switch(state: &AppState, target_dir_id: &str) -> Result<(), AppError> { + // ═══ 前置检查 ═══ + // 1. 检查代理接管状态,若活跃则拒绝切换 + // 使用 db.is_live_takeover_active() 或同步检查 proxy_config.live_takeover_active + // (因为 ProxyService::is_running() 是 async,而此函数是 sync) + Self::check_proxy_not_active(state)?; + + // ═══ Phase 1: 回填 Prompt ═══ + // 在 snapshot 之前,将 live 文件内容回填到当前 enabled prompt + // 这样即使用户手动编辑了 live 文件,内容也不会丢失 + Self::backfill_prompt_content(state)?; + + // ═══ Phase 2: 数据库操作(事务) ═══ + { + let conn = lock_conn!(state.db.conn); + conn.execute("BEGIN IMMEDIATE", [])?; + + let result = (|| -> Result<(), AppError> { + // 获取当前工作目录 + let current = Self::get_current_dir_id_on_conn(&conn)?; + + // 保存当前状态到旧目录 + if let Some(old_id) = ¤t { + Database::snapshot_providers_on_conn(&conn, old_id)?; + Database::snapshot_mcp_on_conn(&conn, old_id)?; + Database::snapshot_skills_on_conn(&conn, old_id)?; + Database::snapshot_prompts_on_conn(&conn, old_id)?; + } else { + // 无当前目录 = 全局模式,保存到 __default__ + Database::snapshot_providers_on_conn(&conn, "__default__")?; + Database::snapshot_mcp_on_conn(&conn, "__default__")?; + Database::snapshot_skills_on_conn(&conn, "__default__")?; + Database::snapshot_prompts_on_conn(&conn, "__default__")?; + } + + // 加载目标目录快照(如果有的话) + // 如果无快照(首次进入),保持主表不变 + Database::apply_provider_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_mcp_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_skill_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_prompt_snapshot_on_conn(&conn, target_dir_id)?; + + // 更新 is_current 标记 + Database::set_current_working_directory_on_conn(&conn, target_dir_id)?; + + Ok(()) + })(); + + match result { + Ok(()) => conn.execute("COMMIT", [])?, + Err(e) => { + let _ = conn.execute("ROLLBACK", []); + return Err(e); + } + }; + } + // conn 锁在此处释放 + + // ═══ Phase 3: 同步 live 配置文件 ═══ + Self::sync_all_live(state)?; + + // ═══ Phase 4: 清除 Provider Health ═══ + state.db.clear_all_provider_health()?; + + Ok(()) + } + + /// 回填 live prompt 文件内容到 DB(切换前调用) + fn backfill_prompt_content(state: &AppState) -> Result<(), AppError> { + for app in AppType::all() { + let path = prompt_file_path(&app)?; + if !path.exists() { continue; } + let live_content = std::fs::read_to_string(&path).unwrap_or_default(); + if live_content.trim().is_empty() { continue; } + + let mut prompts = state.db.get_prompts(app.as_str())?; + if let Some((_, prompt)) = prompts.iter_mut().find(|(_, p)| p.enabled) { + prompt.content = live_content; + prompt.updated_at = Some(get_unix_timestamp()?); + state.db.save_prompt(app.as_str(), prompt)?; + } + } + Ok(()) + } + + /// 将 DB 中的 enabled prompt 内容写入 live 文件(切换后调用) + /// 注意:不做回填!只写入。区别于 PromptService::enable_prompt() + fn write_prompts_to_live(state: &AppState) -> Result<(), AppError> { + for app in AppType::all() { + let path = prompt_file_path(&app)?; + let prompts = state.db.get_prompts(app.as_str())?; + if let Some(prompt) = prompts.values().find(|p| p.enabled) { + write_text_file(&path, &prompt.content)?; + } + // 无 enabled prompt 时不清空文件(保留现状) + } + Ok(()) + } + + /// 同步所有 live 配置(Provider + MCP + Skill + Prompt) + fn sync_all_live(state: &AppState) -> Result<(), AppError> { + // 1. Provider → live files + ProviderService::sync_current_to_live(state)?; + // sync_current_to_live 内部已调用 McpService::sync_all_enabled() + + // 2. Skills → app dirs (循环每个 app) + for app in AppType::all() { + let _ = SkillService::sync_to_app(&state.db, &app); + } + + // 3. Prompts → live files + Self::write_prompts_to_live(state)?; + + Ok(()) + } + + /// 检查代理是否活跃(同步检查数据库标志) + fn check_proxy_not_active(state: &AppState) -> Result<(), AppError> { + // 检查 proxy_config 表中 live_takeover_active 列 + // 如果有任何 app 的 live_takeover_active = 1,拒绝切换 + let conn = lock_conn!(state.db.conn); + let active: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM proxy_config WHERE live_takeover_active = 1)", + [], |r| r.get(0) + ).unwrap_or(false); + + if active { + return Err(AppError::Message( + "代理接管模式运行中,请先停止代理再切换工作目录".into() + )); + } + Ok(()) + } +} +``` + +### 3.3 Command 层 — `src-tauri/src/commands/working_dir.rs` + +遵循现有模式:`State<'_, AppState>` + `Result` + `.map_err(|e| e.to_string())`。 + +```rust +#[tauri::command] +pub fn list_working_directories(state: State<'_, AppState>) -> Result, String> + +#[tauri::command] +pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option) -> Result + +#[tauri::command] +pub fn delete_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> + +#[tauri::command] +pub fn rename_working_directory(state: State<'_, AppState>, id: String, name: String) -> Result<(), String> + +#[tauri::command] +pub fn switch_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> +// 调用 WorkingDirService::switch() + +#[tauri::command] +pub fn get_current_working_directory(state: State<'_, AppState>) -> Result, String> +``` + +### 3.4 需修改的现有文件 + +| 文件 | 修改内容 | +|------|---------| +| `src-tauri/src/database/schema.rs` | 添加 5 个 CREATE TABLE + `migrate_v8_to_v9()` | +| `src-tauri/src/database/mod.rs` | `SCHEMA_VERSION = 9` + 迁移循环加 `8 => ...` + `pub mod working_dir` in dao | +| `src-tauri/src/database/dao/mod.rs` | 添加 `pub mod working_dir;` | +| `src-tauri/src/services/mod.rs` | 添加 `pub mod working_dir;` + `pub use working_dir::WorkingDirService;` | +| `src-tauri/src/commands/mod.rs` | 添加 `mod working_dir;` + `pub use working_dir::*;` | +| `src-tauri/src/lib.rs` | invoke_handler 注册 6 个新命令 | + +### 3.5 可能需要新增的 DAO 辅助方法 + +`src-tauri/src/database/dao/failover.rs`: +```rust +/// 清除所有 provider_health 记录(切换目录时调用) +pub fn clear_all_provider_health(&self) -> Result<(), AppError> +``` + +--- + +## 四、前端实现 + +### 4.1 API — `src/lib/api/workingDir.ts` + +```typescript +import { invoke } from "@tauri-apps/api/core"; + +export interface WorkingDirectory { + id: string; + path: string; + name?: string; + isCurrent: boolean; + createdAt: number; +} + +export const workingDirApi = { + list: () => invoke("list_working_directories"), + add: (path: string, name?: string) => + invoke("add_working_directory", { path, name }), + delete: (id: string) => invoke("delete_working_directory", { id }), + rename: (id: string, name: string) => + invoke("rename_working_directory", { id, name }), + switch: (id: string) => invoke("switch_working_directory", { id }), + getCurrent: () => + invoke("get_current_working_directory"), +}; +``` + +### 4.2 组件 — `src/components/WorkingDirSwitcher.tsx` + +**位置**:Header toolbar,靠近 AppSwitcher。 + +**功能**: +- 下拉菜单显示已注册目录列表 +- 当前目录高亮 +- "浏览…" 按钮调用 Tauri 文件夹选择对话框 +- 右键菜单:重命名、删除 +- "__default__(全局)" 选项恢复到全局状态 +- 切换后 invalidate 所有相关 React Query + +**切换后的 Query Invalidation**: +```typescript +// 需要验证实际的 queryKey 名称 +queryClient.invalidateQueries({ queryKey: ["providers"] }); +queryClient.invalidateQueries({ queryKey: ["mcp-servers"] }); +queryClient.invalidateQueries({ queryKey: ["installed-skills"] }); +queryClient.invalidateQueries({ queryKey: ["prompts"] }); +queryClient.invalidateQueries({ queryKey: ["workingDirectories"] }); +``` + +### 4.3 i18n + +三个文件都需更新: +- `src/i18n/locales/zh.json` +- `src/i18n/locales/en.json` +- `src/i18n/locales/ja.json` + +--- + +## 五、切换流程时序 + +``` +用户选择目录 B + │ + ├── 1. check_proxy_not_active() + │ → 如果代理接管中,返回错误,终止 + │ + ├── 2. backfill_prompt_content() + │ → 读 live prompt 文件 → 更新 DB 中已启用 prompt 的 content + │ → 保护用户手动编辑的 prompt 不丢失 + │ + ├── 3. BEGIN TRANSACTION + │ ├── snapshot(old_dir / __default__) + │ │ ├── providers → dir_provider_state (is_current + in_failover_queue) + │ │ ├── mcp_servers → dir_mcp_state (4 列直接复制) + │ │ ├── skills → dir_skill_state (4 列直接复制) + │ │ └── prompts → dir_prompt_state (enabled prompt_id) + │ │ + │ ├── apply(target_dir) + │ │ ├── dir_provider_state → providers + │ │ ├── dir_mcp_state → mcp_servers + │ │ ├── dir_skill_state → skills + │ │ └── dir_prompt_state → prompts + │ │ + │ └── set_current_working_directory(target_dir) + │ + ├── COMMIT + │ + ├── 4. sync_all_live() + │ ├── ProviderService::sync_current_to_live(state) + │ │ └── 内部已调用 McpService::sync_all_enabled() + │ ├── for app in AppType::all() { SkillService::sync_to_app(&db, &app) } + │ └── write_prompts_to_live() ← 无回填,直接写 + │ + └── 5. clear_all_provider_health() + → 清除运行时熔断器状态 +``` + +--- + +## 六、边界情况处理 + +| 场景 | 处理方式 | +|------|---------| +| **首次进入目录(无快照)** | `apply_*_snapshot()` 返回 false,主表保持不变。用户调整后,下次切走时自动保存。 | +| **全局模式 → 目录** | 自动将当前状态 snapshot 到 `__default__` 虚拟目录。`__default__` 在 v9 迁移中预创建。 | +| **目录 → 全局模式** | 用户选择 `__default__`,恢复全局状态。 | +| **新增 MCP/Skill/Provider** | 新实体在 dir_*_state 中无记录。apply 时只更新有记录的实体,新增的保持 DB 默认值。 | +| **删除 MCP/Skill/Provider** | dir_*_state 中对应记录在 apply 时找不到主表行,UPDATE 影响 0 行,静默跳过。 | +| **删除工作目录** | 级联删除 dir_*_state 中所有 `dir_id` 匹配的行。若为当前目录,回退到 `__default__`。 | +| **代理接管中切换** | `check_proxy_not_active()` 检测到 `live_takeover_active = 1`,拒绝切换并提示用户先停止代理。 | +| **切换中途崩溃** | 事务保护 DB 操作的原子性。最坏情况:DB 已更新但 live 文件未同步。下次启动可添加恢复检查(Phase 2 优化)。 | +| **用户手动编辑了 prompt 文件** | `backfill_prompt_content()` 在切换前读取 live 文件回填到 DB,保护手动修改。 | + +--- + +## 七、实施顺序 + +### Phase 1: 数据库 +1. `database/schema.rs` — 5 个 CREATE TABLE + `migrate_v8_to_v9()` +2. `database/mod.rs` — `SCHEMA_VERSION = 9` + 迁移分支 +3. `database/dao/working_dir.rs` — 全部 DAO 方法(`_on_conn` 变体) +4. `database/dao/failover.rs` — 新增 `clear_all_provider_health()` +5. `database/dao/mod.rs` — 注册模块 + +### Phase 2: 服务 + 命令 +6. `services/working_dir.rs` — `WorkingDirService::switch()` 等 +7. `commands/working_dir.rs` — 6 个 Tauri 命令 +8. `services/mod.rs` — 注册模块 +9. `commands/mod.rs` — 注册模块 +10. `lib.rs` — invoke_handler 注册 + +### Phase 3: 前端 +11. `src/lib/api/workingDir.ts` — API 封装 +12. `src/types.ts` — WorkingDirectory 类型 +13. `src/components/WorkingDirSwitcher.tsx` — UI 组件 +14. `src/App.tsx` — 集成到 header toolbar +15. `src/i18n/locales/{zh,en,ja}.json` — 国际化 + +### Phase 4: 优化(可选) +16. 启动恢复检查(DB 状态 vs live 文件一致性) +17. 托盘菜单显示当前工作目录 + +--- + +## 八、关键文件索引 + +### 新增文件(5 个) +- `src-tauri/src/database/dao/working_dir.rs` +- `src-tauri/src/services/working_dir.rs` +- `src-tauri/src/commands/working_dir.rs` +- `src/lib/api/workingDir.ts` +- `src/components/WorkingDirSwitcher.tsx` + +### 必须修改的文件(7 个) +- `src-tauri/src/database/schema.rs` — CREATE TABLE + 迁移 +- `src-tauri/src/database/mod.rs` — 版本号 + 迁移循环 +- `src-tauri/src/database/dao/mod.rs` — 模块注册 +- `src-tauri/src/database/dao/failover.rs` — clear_all_provider_health +- `src-tauri/src/services/mod.rs` — 模块注册 +- `src-tauri/src/commands/mod.rs` — 模块注册 +- `src-tauri/src/lib.rs` — invoke_handler + +### 必须修改的前端文件(4 个) +- `src/App.tsx` — 集成 WorkingDirSwitcher +- `src/types.ts` — WorkingDirectory 接口 +- `src/i18n/locales/zh.json` — 中文 +- `src/i18n/locales/en.json` — 英文 +- `src/i18n/locales/ja.json` — 日文 + +### 参考文件(理解现有模式) +- `src-tauri/src/services/mcp.rs` — `sync_all_enabled()` (line 165) +- `src-tauri/src/services/skill.rs` — `sync_to_app()` (line 1707) +- `src-tauri/src/services/provider/mod.rs` — `sync_current_to_live()` (line 1552) +- `src-tauri/src/services/prompt.rs` — `enable_prompt()` (line 73) — 理解回填逻辑 +- `src-tauri/src/prompt_files.rs` — prompt 文件路径 +- `src-tauri/src/config.rs` — `write_text_file()` (line 176) + +--- + +## 九、验证计划 + +### 后端验证 +1. `cargo test` — DAO 层单元测试(使用 `Database::memory()`) + - 快照/恢复往返一致性 + - 新增/删除实体后的 apply 行为 + - `__default__` 全局状态保护 + - 事务回滚测试 +2. 手动测试 — 启动应用,创建两个目录,切换并验证 live 文件变化 + +### 前端验证 +1. `pnpm typecheck` — TypeScript 类型检查 +2. `pnpm lint` — ESLint 检查 +3. 手动 UI 测试 — 工作目录切换器交互、query invalidation 后数据刷新 From c4458cf28086cf37b5b61770a3a189faab2d6afc Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:08:09 +0800 Subject: [PATCH 54/77] fix: update tests for InstalledSkill new fields and missing hook mocks - Add content_hash and updated_at fields to 4 InstalledSkill literals in skill_sync.rs - Add useCheckSkillUpdates and useUpdateSkill to UnifiedSkillsPanel test mock - Suppress unused import warning in auto_launch.rs test module --- src-tauri/src/auto_launch.rs | 1 + src-tauri/tests/skill_sync.rs | 8 ++++++++ tests/components/UnifiedSkillsPanel.test.tsx | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/src-tauri/src/auto_launch.rs b/src-tauri/src/auto_launch.rs index c69ae46ad..2f44e8971 100644 --- a/src-tauri/src/auto_launch.rs +++ b/src-tauri/src/auto_launch.rs @@ -70,6 +70,7 @@ pub fn is_auto_launch_enabled() -> Result { #[cfg(test)] mod tests { + #[allow(unused_imports)] use super::*; #[cfg(target_os = "macos")] diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs index 3d4cf6814..b1e0c3a61 100644 --- a/src-tauri/tests/skill_sync.rs +++ b/src-tauri/tests/skill_sync.rs @@ -110,6 +110,8 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() { opencode: false, }, installed_at: 0, + content_hash: None, + updated_at: 0, }) .expect("save disabled skill"); @@ -154,6 +156,8 @@ fn uninstall_skill_creates_backup_before_removing_ssot() { opencode: false, }, installed_at: 123, + content_hash: None, + updated_at: 0, }) .expect("save skill"); @@ -222,6 +226,8 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() { opencode: false, }, installed_at: 456, + content_hash: None, + updated_at: 0, }) .expect("save skill"); @@ -303,6 +309,8 @@ fn delete_skill_backup_removes_backup_directory() { opencode: false, }, installed_at: 789, + content_hash: None, + updated_at: 0, }) .expect("save skill"); diff --git a/tests/components/UnifiedSkillsPanel.test.tsx b/tests/components/UnifiedSkillsPanel.test.tsx index 9fb641c49..38518ad9b 100644 --- a/tests/components/UnifiedSkillsPanel.test.tsx +++ b/tests/components/UnifiedSkillsPanel.test.tsx @@ -64,6 +64,15 @@ vi.mock("@/hooks/useSkills", () => ({ useInstallSkillsFromZip: () => ({ mutateAsync: installFromZipMock, }), + useCheckSkillUpdates: () => ({ + data: [], + refetch: vi.fn(), + isFetching: false, + }), + useUpdateSkill: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })); describe("UnifiedSkillsPanel", () => { From b85e44994923471308c72d8d0e606ea0b220df5b Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:20:47 +0800 Subject: [PATCH 55/77] fix: guard migrations against missing tables and fix highlighted text assertion - Make migrate_v6_to_v7 check skills table existence before ALTER - Make migrate_v7_to_v8 check model_pricing table existence before UPDATE - Fix SessionManagerPage test: use getByRole heading instead of getAllByText which breaks when highlightText splits text across elements --- src-tauri/src/database/schema.rs | 67 ++++++++++++-------- tests/components/SessionManagerPage.test.tsx | 4 +- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 2a9e1ded7..95abaa8e0 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1072,8 +1072,15 @@ impl Database { /// v6 -> v7: Skills 更新检测支持(content_hash + updated_at) fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> { - Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?; - Self::add_column_if_missing(conn, "skills", "updated_at", "INTEGER NOT NULL DEFAULT 0")?; + if Self::table_exists(conn, "skills")? { + Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?; + Self::add_column_if_missing( + conn, + "skills", + "updated_at", + "INTEGER NOT NULL DEFAULT 0", + )?; + } log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列"); Ok(()) } @@ -1103,32 +1110,36 @@ impl Database { .map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?; // 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD - let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[ - ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"), - ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"), - ("deepseek-v3", "0.28", "1.11", "0.028", "0"), - ("doubao-seed-code", "0.17", "1.11", "0.02", "0"), - ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"), - ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"), - ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"), - ("minimax-m2.1", "0.27", "0.95", "0.03", "0"), - ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"), - ("minimax-m2", "0.27", "0.95", "0.03", "0"), - ("glm-4.7", "0.39", "1.75", "0.04", "0"), - ("glm-4.6", "0.28", "1.11", "0.03", "0"), - ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"), - ]; - for (model_id, input, output, cache_read, cache_creation) in pricing_fixes { - conn.execute( - "UPDATE model_pricing SET - input_cost_per_million = ?2, - output_cost_per_million = ?3, - cache_read_cost_per_million = ?4, - cache_creation_cost_per_million = ?5 - WHERE model_id = ?1", - rusqlite::params![model_id, input, output, cache_read, cache_creation], - ) - .map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?; + if Self::table_exists(conn, "model_pricing")? { + let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[ + ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"), + ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"), + ("deepseek-v3", "0.28", "1.11", "0.028", "0"), + ("doubao-seed-code", "0.17", "1.11", "0.02", "0"), + ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"), + ("minimax-m2.1", "0.27", "0.95", "0.03", "0"), + ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"), + ("minimax-m2", "0.27", "0.95", "0.03", "0"), + ("glm-4.7", "0.39", "1.75", "0.04", "0"), + ("glm-4.6", "0.28", "1.11", "0.03", "0"), + ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"), + ]; + for (model_id, input, output, cache_read, cache_creation) in pricing_fixes { + conn.execute( + "UPDATE model_pricing SET + input_cost_per_million = ?2, + output_cost_per_million = ?3, + cache_read_cost_per_million = ?4, + cache_creation_cost_per_million = ?5 + WHERE model_id = ?1", + rusqlite::params![model_id, input, output, cache_read, cache_creation], + ) + .map_err(|e| { + AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")) + })?; + } } log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价"); diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx index a6e99115d..3b095ffd7 100644 --- a/tests/components/SessionManagerPage.test.tsx +++ b/tests/components/SessionManagerPage.test.tsx @@ -184,7 +184,9 @@ describe("SessionManagerPage", () => { }); await waitFor(() => - expect(screen.getAllByText("Alpha Session")).toHaveLength(2), + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), ); fireEvent.click(screen.getByRole("button", { name: /删除会话/i })); From 489c7c75eab5f7402303df733120bdaa84513109 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:23:08 +0800 Subject: [PATCH 56/77] style: apply cargo fmt to schema migration code --- src-tauri/src/database/schema.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 95abaa8e0..d755116fd 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1136,9 +1136,7 @@ impl Database { WHERE model_id = ?1", rusqlite::params![model_id, input, output, cache_read, cache_creation], ) - .map_err(|e| { - AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")) - })?; + .map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?; } } From 68df60bd4a61833fe022c932af618f66cbc78601 Mon Sep 17 00:00:00 2001 From: Max <6111715+cmzz@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:47:18 +0800 Subject: [PATCH 57/77] feat(provider): add TheRouter presets for OpenCode and OpenClaw (#1892) Co-authored-by: max --- src/config/openclawProviderPresets.ts | 66 +++++++++++++++++++ src/config/opencodeProviderPresets.ts | 31 +++++++++ .../therouterOpenCodeOpenClawPresets.test.ts | 50 ++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/config/therouterOpenCodeOpenClawPresets.test.ts diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index cf9ca37c5..965c4a6a3 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -719,6 +719,72 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + baseUrl: "https://api.therouter.ai/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + contextWindow: 1000000, + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + }, + { + id: "openai/gpt-5.3-codex", + name: "GPT-5.3 Codex", + contextWindow: 400000, + cost: { input: 5, output: 40, cacheRead: 0.5 }, + }, + { + id: "openai/gpt-5.2", + name: "GPT-5.2", + contextWindow: 400000, + cost: { input: 1.75, output: 14, cacheRead: 0.175 }, + }, + { + id: "google/gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + contextWindow: 1000000, + cost: { input: 0.5, output: 3, cacheRead: 0.05 }, + }, + { + id: "qwen/qwen3-coder-480b", + name: "Qwen3 Coder 480B", + contextWindow: 262144, + cost: { input: 0.6, output: 2.35 }, + }, + ], + }, + category: "aggregator", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "therouter/anthropic/claude-sonnet-4.6", + fallbacks: [ + "therouter/openai/gpt-5.2", + "therouter/google/gemini-3-flash-preview", + ], + }, + modelCatalog: { + "therouter/anthropic/claude-sonnet-4.6": { alias: "Sonnet" }, + "therouter/openai/gpt-5.2": { alias: "GPT-5.2" }, + "therouter/google/gemini-3-flash-preview": { alias: "Gemini Flash" }, + "therouter/openai/gpt-5.3-codex": { alias: "Codex" }, + "therouter/qwen/qwen3-coder-480b": { alias: "Qwen Coder" }, + }, + }, + }, { name: "ModelScope", websiteUrl: "https://modelscope.cn", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 18a830fca..9696ad086 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -879,6 +879,37 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "TheRouter", + options: { + baseURL: "https://api.therouter.ai/v1", + apiKey: "", + setCacheKey: true, + }, + models: { + "anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" }, + "openai/gpt-5.3-codex": { name: "GPT-5.3 Codex" }, + "openai/gpt-5.2": { name: "GPT-5.2" }, + "google/gemini-3-flash-preview": { + name: "Gemini 3 Flash Preview", + }, + "qwen/qwen3-coder-480b": { name: "Qwen3 Coder 480B" }, + }, + }, + category: "aggregator", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + }, { name: "Novita AI", websiteUrl: "https://novita.ai", diff --git a/tests/config/therouterOpenCodeOpenClawPresets.test.ts b/tests/config/therouterOpenCodeOpenClawPresets.test.ts new file mode 100644 index 000000000..29279f9d7 --- /dev/null +++ b/tests/config/therouterOpenCodeOpenClawPresets.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { opencodeProviderPresets } from "@/config/opencodeProviderPresets"; +import { openclawProviderPresets } from "@/config/openclawProviderPresets"; + +describe("TheRouter OpenCode and OpenClaw presets", () => { + it("uses OpenAI-compatible config for OpenCode", () => { + const preset = opencodeProviderPresets.find((item) => item.name === "TheRouter"); + const models = preset?.settingsConfig.models ?? {}; + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.settingsConfig.npm).toBe("@ai-sdk/openai-compatible"); + expect(preset?.settingsConfig.options?.baseURL).toBe( + "https://api.therouter.ai/v1", + ); + expect(preset?.settingsConfig.options?.setCacheKey).toBe(true); + expect(models).toHaveProperty("openai/gpt-5.3-codex"); + expect(models).toHaveProperty("anthropic/claude-sonnet-4.6"); + expect(models).toHaveProperty("google/gemini-3-flash-preview"); + }); + + it("uses OpenAI completions config for OpenClaw", () => { + const preset = openclawProviderPresets.find((item) => item.name === "TheRouter"); + const modelIds = (preset?.settingsConfig.models ?? []).map((model) => model.id); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.settingsConfig.baseUrl).toBe("https://api.therouter.ai/v1"); + expect(preset?.settingsConfig.api).toBe("openai-completions"); + expect(modelIds).toEqual( + expect.arrayContaining([ + "anthropic/claude-sonnet-4.6", + "openai/gpt-5.3-codex", + "openai/gpt-5.2", + "google/gemini-3-flash-preview", + ]), + ); + expect(preset?.suggestedDefaults?.model).toEqual({ + primary: "therouter/anthropic/claude-sonnet-4.6", + fallbacks: [ + "therouter/openai/gpt-5.2", + "therouter/google/gemini-3-flash-preview", + ], + }); + }); +}); From 7f1963ab493d02c71ec936b2ed72c05a31434809 Mon Sep 17 00:00:00 2001 From: Max <6111715+cmzz@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:48:14 +0800 Subject: [PATCH 58/77] feat(provider): add TheRouter presets for Claude, Codex, and Gemini (#1891) * feat(provider): add TheRouter presets for Claude and Codex * feat(provider): add TheRouter Gemini preset --------- Co-authored-by: max --- src/config/claudeProviderPresets.ts | 18 +++++ src/config/codexProviderPresets.ts | 13 ++++ src/config/geminiProviderPresets.ts | 16 +++++ tests/config/therouterProviderPresets.test.ts | 66 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 tests/config/therouterProviderPresets.test.ts diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 94f9f60e7..96353e626 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -671,6 +671,24 @@ export const providerPresets: ProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.therouter.ai", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5", + ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6", + }, + }, + category: "aggregator", + endpointCandidates: ["https://api.therouter.ai"], + }, { name: "Novita AI", websiteUrl: "https://novita.ai", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index dd0a687fe..97a0375bc 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -365,4 +365,17 @@ requires_openai_auth = true`, icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "therouter", + "https://api.therouter.ai/v1", + "openai/gpt-5.3-codex", + ), + endpointCandidates: ["https://api.therouter.ai/v1"], + category: "aggregator", + }, ]; diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index aaff2e257..4b7339b5e 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -241,6 +241,22 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + env: { + GOOGLE_GEMINI_BASE_URL: "https://api.therouter.ai", + GEMINI_MODEL: "gemini-2.5-pro", + }, + }, + baseURL: "https://api.therouter.ai", + model: "gemini-2.5-pro", + description: "TheRouter", + category: "aggregator", + endpointCandidates: ["https://api.therouter.ai"], + }, { name: "自定义", websiteUrl: "", diff --git a/tests/config/therouterProviderPresets.test.ts b/tests/config/therouterProviderPresets.test.ts new file mode 100644 index 000000000..97b77a24c --- /dev/null +++ b/tests/config/therouterProviderPresets.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { providerPresets } from "@/config/claudeProviderPresets"; +import { codexProviderPresets } from "@/config/codexProviderPresets"; +import { geminiProviderPresets } from "@/config/geminiProviderPresets"; + +describe("TheRouter provider presets", () => { + it("uses the Anthropic-compatible root endpoint for Claude", () => { + const preset = providerPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]); + + const env = (preset?.settingsConfig as { env: Record }).env; + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.therouter.ai"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe(""); + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.ANTHROPIC_MODEL).toBe("anthropic/claude-sonnet-4.6"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe( + "anthropic/claude-haiku-4.5", + ); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe( + "anthropic/claude-sonnet-4.6", + ); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe( + "anthropic/claude-opus-4.6", + ); + }); + + it("uses the OpenAI-compatible v1 endpoint for Codex", () => { + const preset = codexProviderPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual([ + "https://api.therouter.ai/v1", + ]); + expect(preset?.auth).toEqual({ OPENAI_API_KEY: "" }); + expect(preset?.config).toContain('model_provider = "therouter"'); + expect(preset?.config).toContain('model = "openai/gpt-5.3-codex"'); + expect(preset?.config).toContain( + 'base_url = "https://api.therouter.ai/v1"', + ); + expect(preset?.config).toContain('wire_api = "responses"'); + }); + + it("uses the Gemini-native root endpoint for Gemini", () => { + const preset = geminiProviderPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]); + expect(preset?.baseURL).toBe("https://api.therouter.ai"); + expect(preset?.model).toBe("gemini-2.5-pro"); + + const env = (preset?.settingsConfig as { env: Record }).env; + expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://api.therouter.ai"); + expect(env.GEMINI_MODEL).toBe("gemini-2.5-pro"); + }); +}); From afdda27bd5ca9585945f780e47749450e998032e Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:24:25 +0800 Subject: [PATCH 59/77] Restore auth tab localization in settings (#1985) The settings page already routes the auth tab label through the shared i18n key, but the locale bundles never defined that key. Adding the missing entries fixes the label with the same simple pattern used by the other tabs. Constraint: Keep the fix aligned with the existing settings-tab i18n flow Rejected: Add component-level bilingual rendering | unnecessary for a missing translation key Confidence: high Scope-risk: narrow Reversibility: clean Directive: When adding settings tabs, define locale keys in every bundled language before relying on fallback text Tested: pnpm format:check; pnpm typecheck Not-tested: Manual verification in the desktop UI --- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + 3 files changed, 3 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5a0f16b42..b03a54c65 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -243,6 +243,7 @@ "title": "Settings", "general": "General", "tabGeneral": "General", + "tabAuth": "Auth", "tabAdvanced": "Advanced", "tabProxy": "Proxy", "authCenter": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bb0c8bf43..90409338d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -243,6 +243,7 @@ "title": "設定", "general": "一般", "tabGeneral": "一般", + "tabAuth": "認証", "tabAdvanced": "詳細", "tabProxy": "プロキシ", "authCenter": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8696647dd..66128f02d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -243,6 +243,7 @@ "title": "设置", "general": "通用", "tabGeneral": "通用", + "tabAuth": "认证", "tabAdvanced": "高级", "tabProxy": "代理", "authCenter": { From 8c32610a7de81fd8e9b8cc0e6df971e3cf887349 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:34:18 +0800 Subject: [PATCH 60/77] Align Thinking fallback with main-model-only Claude mappings (#1984) The Claude provider form reopened with an empty Thinking model after users saved only a main model. This updates model-state hydration to mirror the existing Haiku-style fallback semantics: read ANTHROPIC_REASONING_MODEL when present, otherwise display ANTHROPIC_MODEL, without writing a synthetic reasoning field back into config. Constraint: Existing Haiku, Sonnet, and Opus selectors already rely on read-time fallback behavior Rejected: Persist ANTHROPIC_REASONING_MODEL from ANTHROPIC_MODEL automatically | would diverge from Haiku behavior and silently rewrite saved config Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Thinking fallback read-only unless all model-mapping fields are intentionally migrated to write-through semantics Tested: pnpm typecheck Tested: pnpm test:unit (1 unrelated pre-existing failure in tests/components/UnifiedSkillsPanel.test.tsx mock setup) Not-tested: Manual add-provider reopen flow in the desktop UI --- .../providers/forms/hooks/useModelState.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 46a1c6d7e..01278c4b5 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -14,10 +14,11 @@ function parseModelsFromConfig(settingsConfig: string) { const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const reasoning = + const explicitReasoning = typeof env.ANTHROPIC_REASONING_MODEL === "string" ? env.ANTHROPIC_REASONING_MODEL : ""; + const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -92,10 +93,11 @@ export function useModelState({ const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const reasoning = + const explicitReasoning = typeof env.ANTHROPIC_REASONING_MODEL === "string" ? env.ANTHROPIC_REASONING_MODEL : ""; + const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -148,16 +150,17 @@ export function useModelState({ ? JSON.parse(settingsConfig) : { env: {} }; if (!currentConfig.env) currentConfig.env = {}; + const env = currentConfig.env as Record; // 新键仅写入;旧键不再写入 const trimmed = value.trim(); if (trimmed) { - currentConfig.env[field] = trimmed; + env[field] = trimmed; } else { - delete currentConfig.env[field]; + delete env[field]; } // 删除旧键 - delete currentConfig.env["ANTHROPIC_SMALL_FAST_MODEL"]; + delete env["ANTHROPIC_SMALL_FAST_MODEL"]; onConfigChange(JSON.stringify(currentConfig, null, 2)); } catch (err) { From e4b58c72065dfb3d509091763dc9e525ba37cc4d Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:35:16 +0800 Subject: [PATCH 61/77] Let Kaku users launch sessions from their chosen terminal (#1954) (#1983) Kaku is a WezTerm-derived macOS terminal, so reusing the existing WezTerm-compatible launch path keeps the change small while making it selectable in settings and session resume flows. Constraint: Kaku support should stay macOS-only and avoid introducing a separate launcher model Rejected: Treat Kaku as a silent WezTerm fallback | users could not explicitly choose it in settings Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Kaku on the shared WezTerm-compatible launch path unless upstream drops the start-compatible CLI Tested: pnpm typecheck; pnpm format:check; cargo check --manifest-path src-tauri/Cargo.toml; cargo fmt --manifest-path src-tauri/Cargo.toml --check; cargo test --manifest-path src-tauri/Cargo.toml --lib session_manager::terminal::tests Not-tested: End-to-end launch against a locally installed Kaku.app Related: #1954 --- src-tauri/src/commands/misc.rs | 1 + src-tauri/src/session_manager/terminal/mod.rs | 94 +++++++++++++++---- src-tauri/src/settings.rs | 2 +- src/components/settings/TerminalSettings.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- src/types.ts | 2 +- 8 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 5001e1f37..623166e5a 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -948,6 +948,7 @@ exec bash --norc --noprofile "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_open_app("Ghostty", &script_file, true), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), + "kaku" => launch_macos_open_app("Kaku", &script_file, true), _ => launch_macos_terminal_app(&script_file), // "terminal" or default }; diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index ab13466ba..3e35df30e 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -20,6 +20,7 @@ pub fn launch_terminal( "ghostty" => launch_ghostty(command, cwd), "kitty" => launch_kitty(command, cwd), "wezterm" => launch_wezterm(command, cwd), + "kaku" => launch_kaku(command, cwd), "alacritty" => launch_alacritty(command, cwd), "custom" => launch_custom(command, cwd, custom_config), _ => Err(format!("Unsupported terminal target: {target}")), @@ -153,25 +154,10 @@ fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> { fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> { // wezterm start --cwd ... -- command // To invoke via `open`, we use `open -na "WezTerm" --args start ...` - - let full_command = build_shell_command(command, None); - - let mut args = vec!["-na", "WezTerm", "--args", "start"]; - - if let Some(dir) = cwd { - args.push("--cwd"); - args.push(dir); - } - - // Invoke shell to run the command string (to handle pipes, etc) - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - args.push("--"); - args.push(&shell); - args.push("-c"); - args.push(&full_command); + let args = build_wezterm_compatible_args("WezTerm", command, cwd); let status = Command::new("open") - .args(&args) + .args(args.iter().map(String::as_str)) .status() .map_err(|e| format!("Failed to launch WezTerm: {e}"))?; @@ -182,6 +168,54 @@ fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> { } } +fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> { + // Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint. + let args = build_wezterm_compatible_args("Kaku", command, cwd); + + let status = Command::new("open") + .args(args.iter().map(String::as_str)) + .status() + .map_err(|e| format!("Failed to launch Kaku: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Kaku.".to_string()) + } +} + +fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell) +} + +fn build_wezterm_compatible_args_with_shell( + app_name: &str, + command: &str, + cwd: Option<&str>, + shell: &str, +) -> Vec { + let full_command = build_shell_command(command, None); + let mut args = vec![ + "-na".to_string(), + app_name.to_string(), + "--args".to_string(), + "start".to_string(), + ]; + + if let Some(dir) = cwd { + args.push("--cwd".to_string()); + args.push(dir.to_string()); + } + + // Invoke shell to run the command string (to handle pipes, etc) + args.push("--".to_string()); + args.push(shell.to_string()); + args.push("-c".to_string()); + args.push(full_command); + args +} + fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { // Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command let full_command = build_shell_command(command, None); @@ -305,4 +339,30 @@ mod tests { "raw:echo foo\\\\\\\\bar\\npwd\\n" ); } + + #[test] + fn wezterm_compatible_terminals_use_start_and_cwd_arguments() { + let args = build_wezterm_compatible_args_with_shell( + "Kaku", + "claude --resume abc-123", + Some("/tmp/project dir"), + "/bin/zsh", + ); + + assert_eq!( + args, + vec![ + "-na".to_string(), + "Kaku".to_string(), + "--args".to_string(), + "start".to_string(), + "--cwd".to_string(), + "/tmp/project dir".to_string(), + "--".to_string(), + "/bin/zsh".to_string(), + "-c".to_string(), + "claude --resume abc-123".to_string(), + ] + ); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 22e415a4c..c9b4a4b41 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -273,7 +273,7 @@ pub struct AppSettings { // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) - /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" + /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku" /// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/components/settings/TerminalSettings.tsx b/src/components/settings/TerminalSettings.tsx index c1d62a158..4cf43d418 100644 --- a/src/components/settings/TerminalSettings.tsx +++ b/src/components/settings/TerminalSettings.tsx @@ -16,6 +16,7 @@ const MACOS_TERMINALS = [ { value: "kitty", labelKey: "settings.terminal.options.macos.kitty" }, { value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" }, { value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" }, + { value: "kaku", labelKey: "settings.terminal.options.macos.kaku" }, ] as const; const WINDOWS_TERMINALS = [ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b03a54c65..8e3040bcf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "Command Prompt", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 90409338d..e8bb0238d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "コマンドプロンプト", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 66128f02d..00990ace8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "命令提示符", diff --git a/src/types.ts b/src/types.ts index c1b674418..90cb06a22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -313,7 +313,7 @@ export interface Settings { // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) - // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" + // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku" // Windows: "cmd" | "powershell" | "wt" // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" preferredTerminal?: string; From 3aef5217cbe4796f4e03e8f2c65857b0d4460c26 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:36:32 +0800 Subject: [PATCH 62/77] Restore first-class OMO Slim council support (#1981) (#1982) cc-switch could already persist arbitrary OMO Slim agent keys and top-level fields, but the built-in metadata and UI copy still reflected the pre-council agent set. This made the upstream council feature look unsupported and pushed users toward manual JSON-only setup. Promote council to a built-in OMO Slim agent, add copy that points top-level plugin settings at Other Fields, and lock the behavior with regression tests. Constraint: oh-my-opencode-slim exposes council through both agents.council and top-level council config Rejected: Add a dedicated council editor UI now | too much surface area for issue #1981 Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep OMO Slim built-in agent metadata aligned with upstream agent additions before shipping UI support Tested: pnpm exec vitest run tests/utils/omoConfig.test.ts tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts Tested: pnpm typecheck Not-tested: End-to-end validation against a live oh-my-opencode-slim installation Related: farion1231/cc-switch#1981 --- .../providers/forms/OmoFormFields.tsx | 22 ++++++++--- src/i18n/locales/en.json | 7 +++- src/i18n/locales/ja.json | 7 +++- src/i18n/locales/zh.json | 7 +++- src/types/omo.ts | 9 +++++ tests/utils/omoConfig.test.ts | 38 +++++++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index 52cced9cb..c1a316515 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -1264,12 +1264,22 @@ export function OmoFormFields({ ) : undefined, maxHeightClass: "max-h-[500px]", children: ( -