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) => (