From 4737158439a7d7bd109f7e9614bb39629456decf Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 23 Apr 2026 17:34:39 +0800 Subject: [PATCH] feat(tray): show coding-plan usage for Kimi / Zhipu / MiniMax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dc04165f surfaced tray usage badges for Claude/Codex/Gemini official OAuth only. Chinese coding-plan providers already expose 5h + weekly windows through coding_plan::get_coding_plan_quota, but two gaps kept the tray from rendering them. - format_script_summary read only data.first(), truncating the tier- flattened UsageResult to a single window. Detect plan_name matching TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT and emit the "🟢 h12% w80%" layout used by format_subscription_summary; worst utilization drives the emoji. Copilot / balance / custom scripts keep the legacy single- bucket output via fallback. - usage_script previously required manual activation through UsageScriptModal. Auto-inject meta.usage_script on Claude provider creation when ANTHROPIC_BASE_URL matches a known coding plan, so the tray lights up without the user opening the modal. Does not overwrite existing usage_script on update. Extract the URL route table out of UsageScriptModal into a shared codingPlanProviders module so the modal, the creation hook, and the Rust coding_plan::detect_provider mirror all agree on one list. Add TIER_WEEKLY_LIMIT alongside TIER_FIVE_HOUR and a createUsageScript() factory to collapse the duplicated default fields across four call sites and drop the remaining stringly-typed tier names. --- src-tauri/src/services/subscription.rs | 4 + src-tauri/src/tray.rs | 159 +++++++++++++++++++++++-- src/components/UsageScriptModal.tsx | 67 +++-------- src/config/codingPlanProviders.ts | 81 +++++++++++++ src/hooks/useProviderActions.ts | 4 +- src/types.ts | 14 +++ 6 files changed, 267 insertions(+), 62 deletions(-) create mode 100644 src/config/codingPlanProviders.ts diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index e4530a08c..0349bfeb9 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -297,6 +297,10 @@ pub const TIER_SEVEN_DAY: &str = "seven_day"; pub const TIER_SEVEN_DAY_OPUS: &str = "seven_day_opus"; pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet"; +/// Coding Plan(Kimi / MiniMax)的周窗口 tier 名。与 `coding_plan::query_*` +/// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。 +pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; + /// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。 pub const TIER_GEMINI_PRO: &str = "gemini_pro"; pub const TIER_GEMINI_FLASH: &str = "gemini_flash"; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 73088f208..b727e35ca 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -172,17 +172,59 @@ fn format_subscription_summary( Some(format!("{emoji} {body}")) } +fn tier_pct(data: &crate::provider::UsageData) -> Option { + match (data.used, data.total) { + (Some(used), Some(total)) if total > 0.0 => Some(used / total * 100.0), + _ => None, + } +} + fn format_script_summary(result: &crate::provider::UsageResult) -> Option { + use crate::services::subscription::{TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT}; + if !result.success { return None; } - let data = result.data.as_ref()?.first()?; - let pct = match (data.used, data.total) { - (Some(used), Some(total)) if total > 0.0 => used / total * 100.0, - _ => return None, - }; + let data = result.data.as_ref()?; + if data.is_empty() { + return None; + } + + // commands::provider 的 token_plan 分支把 SubscriptionQuota 的每个 tier + // 扁平化为一条 UsageData(plan_name 承载 tier 名),所以这里按 plan_name + // 识别双桶形态,其余 usage 结果(Copilot / balance / 自定义脚本)走 fallback。 + const TOKEN_PLAN_LABELS: &[(&str, &str)] = &[(TIER_FIVE_HOUR, "h"), (TIER_WEEKLY_LIMIT, "w")]; + + let mut parts: Vec<(&'static str, f64)> = Vec::new(); + for &(tier_name, label) in TOKEN_PLAN_LABELS { + let Some(d) = data + .iter() + .find(|d| d.plan_name.as_deref() == Some(tier_name)) + else { + continue; + }; + if let Some(u) = tier_pct(d) { + parts.push((label, u)); + } + } + if !parts.is_empty() { + let worst = parts + .iter() + .map(|(_, u)| *u) + .fold(f64::NEG_INFINITY, f64::max); + let emoji = emoji_for_utilization(worst); + let body = parts + .iter() + .map(|(label, u)| format!("{label}{}%", u.round() as i64)) + .collect::>() + .join(" "); + return Some(format!("{emoji} {body}")); + } + + let first = data.first()?; + let pct = tier_pct(first)?; let emoji = emoji_for_utilization(pct); - let plan = data.plan_name.as_deref().unwrap_or(""); + let plan = first.plan_name.as_deref().unwrap_or(""); let rounded = pct.round() as i64; if plan.is_empty() { Some(format!("{} {}%", emoji, rounded)) @@ -805,8 +847,11 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { #[cfg(test)] mod tests { - use super::{format_subscription_summary, TRAY_ID}; - use crate::services::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota}; + use super::{format_script_summary, format_subscription_summary, TRAY_ID}; + use crate::provider::{UsageData, UsageResult}; + use crate::services::subscription::{ + CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT, + }; #[test] fn tray_id_is_unique_to_app() { @@ -934,4 +979,102 @@ mod tests { let quota = make_quota("gemini", true, vec![tier("some_future_tier", 80.0)]); assert!(format_subscription_summary("a).is_none()); } + + fn usage_data(plan_name: Option<&str>, utilization: f64) -> UsageData { + UsageData { + plan_name: plan_name.map(String::from), + extra: None, + is_valid: Some(true), + invalid_message: None, + total: Some(100.0), + used: Some(utilization), + remaining: Some(100.0 - utilization), + unit: Some("%".to_string()), + } + } + + fn usage_result(success: bool, data: Vec) -> UsageResult { + UsageResult { + success, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + } + } + + #[test] + fn script_summary_token_plan_two_tiers() { + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_FIVE_HOUR), 12.0), + usage_data(Some(TIER_WEEKLY_LIMIT), 80.0), + ], + ); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("h12%"), "expected h12% in {s}"); + assert!(s.contains("w80%"), "expected w80% in {s}"); + assert!(s.starts_with("\u{1F7E0}"), "expected orange emoji in {s}"); + } + + #[test] + fn script_summary_token_plan_worst_drives_emoji() { + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_FIVE_HOUR), 20.0), + usage_data(Some(TIER_WEEKLY_LIMIT), 95.0), + ], + ); + let s = format_script_summary(&r).unwrap(); + assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}"); + } + + #[test] + fn script_summary_token_plan_five_hour_only() { + let r = usage_result(true, vec![usage_data(Some(TIER_FIVE_HOUR), 8.0)]); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("h8%"), "expected h8% in {s}"); + assert!( + !s.contains("plan_name"), + "plan_name should not leak into label: {s}" + ); + } + + #[test] + fn script_summary_token_plan_weekly_only() { + let r = usage_result(true, vec![usage_data(Some(TIER_WEEKLY_LIMIT), 50.0)]); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("w50%"), "expected w50% in {s}"); + } + + #[test] + fn script_summary_single_bucket_fallback_with_plan_name() { + let r = usage_result(true, vec![usage_data(Some("Copilot Pro"), 40.0)]); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("Copilot Pro"), "expected plan name in {s}"); + assert!(s.contains("40%"), "expected 40% in {s}"); + assert!( + !s.contains("h40%"), + "must not relabel non-token-plan data as h: {s}" + ); + } + + #[test] + fn script_summary_single_bucket_fallback_without_plan_name() { + let r = usage_result(true, vec![usage_data(None, 15.0)]); + let s = format_script_summary(&r).expect("should format"); + assert_eq!(s, "\u{1F7E2} 15%", "expected emoji + pct only, got {s}"); + } + + #[test] + fn script_summary_failure_returns_none() { + let r = usage_result(false, vec![usage_data(Some(TIER_FIVE_HOUR), 12.0)]); + assert!(format_script_summary(&r).is_none()); + } + + #[test] + fn script_summary_empty_data_returns_none() { + let r = usage_result(true, vec![]); + assert!(format_script_summary(&r).is_none()); + } } diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 7571933e1..e3083d716 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -3,7 +3,7 @@ import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { Provider, UsageScript, UsageData } from "@/types"; +import { Provider, UsageScript, UsageData, createUsageScript } from "@/types"; import { usageApi, settingsApi, type AppId } from "@/lib/api"; import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot"; import { useSettingsQuery } from "@/lib/query"; @@ -21,6 +21,10 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { cn } from "@/lib/utils"; import { TEMPLATE_TYPES, PROVIDER_TYPES } from "@/config/constants"; +import { + CODING_PLAN_PROVIDERS, + detectCodingPlanProvider, +} from "@/config/codingPlanProviders"; interface UsageScriptModalProps { provider: Provider; @@ -114,21 +118,6 @@ const TEMPLATE_NAME_KEYS: Record = { [TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance", }; -/** Coding Plan 供应商选项 */ -const TOKEN_PLAN_PROVIDERS = [ - { id: "kimi", label: "Kimi For Coding", pattern: /api\.kimi\.com\/coding/i }, - { - id: "zhipu", - label: "Zhipu GLM (智谱)", - pattern: /bigmodel\.cn|api\.z\.ai/i, - }, - { - id: "minimax", - label: "MiniMax", - pattern: /api\.minimaxi?\.com|api\.minimax\.io/i, - }, -] as const; - /** 官方余额查询供应商检测 */ const BALANCE_PROVIDERS = [ { id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i }, @@ -148,15 +137,6 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean { return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl)); } -/** 根据 Base URL 自动检测 Coding Plan 供应商 */ -function detectTokenPlanProvider(baseUrl: string | undefined): string | null { - if (!baseUrl) return null; - for (const cp of TOKEN_PLAN_PROVIDERS) { - if (cp.pattern.test(baseUrl)) return cp.id; - } - return null; -} - const UsageScriptModal: React.FC = ({ provider, appId, @@ -237,43 +217,24 @@ const UsageScriptModal: React.FC = ({ return { ...savedScript, codingPlanProvider: - detectTokenPlanProvider(providerCredentials.baseUrl) || "kimi", + detectCodingPlanProvider(providerCredentials.baseUrl) || "kimi", }; } return savedScript; } - // 新配置:如果 URL 匹配 Coding Plan,自动初始化 - const autoDetected = detectTokenPlanProvider(providerCredentials.baseUrl); + const autoDetected = detectCodingPlanProvider(providerCredentials.baseUrl); if (autoDetected) { - return { - enabled: false, - language: "javascript" as const, - code: "", - timeout: 10, - autoQueryInterval: 5, - codingPlanProvider: autoDetected, - }; + return createUsageScript({ codingPlanProvider: autoDetected }); } - // 新配置:如果 URL 匹配官方余额查询供应商,自动初始化 if (detectBalanceProvider(providerCredentials.baseUrl)) { - return { - enabled: false, - language: "javascript" as const, - code: "", - timeout: 10, - autoQueryInterval: 5, - }; + return createUsageScript(); } - return { - enabled: false, - language: "javascript" as const, + return createUsageScript({ code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL], - timeout: 10, - autoQueryInterval: 5, - }; + }); }); const [testing, setTesting] = useState(false); @@ -346,7 +307,7 @@ const UsageScriptModal: React.FC = ({ return TEMPLATE_TYPES.GENERAL; } // 新配置:如果 URL 匹配 Coding Plan 供应商,自动选择 Coding Plan 模板 - if (detectTokenPlanProvider(providerCredentials.baseUrl)) { + if (detectCodingPlanProvider(providerCredentials.baseUrl)) { return TEMPLATE_TYPES.TOKEN_PLAN; } // 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板 @@ -623,7 +584,7 @@ const UsageScriptModal: React.FC = ({ }); } else if (presetName === TEMPLATE_TYPES.TOKEN_PLAN) { // Coding Plan 模板不需要脚本,使用 Rust 原生查询 - const autoDetected = detectTokenPlanProvider( + const autoDetected = detectCodingPlanProvider( providerCredentials.baseUrl, ); setScript({ @@ -862,7 +823,7 @@ const UsageScriptModal: React.FC = ({ {t("usageScript.tokenPlanHint")}

- {TOKEN_PLAN_PROVIDERS.map((cp) => ( + {CODING_PLAN_PROVIDERS.map((cp) => (