feat: 新增部分套餐账单查询(v0.6.4)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s

- 账单/用量查询:balance:kimi(Moonshot 开放平台余额)、NewAPI 中转站余额(accessToken+userId)、Kimi/智谱/MiniMax 套餐档位(5小时/7天)
- managed:kimi-code 走 Kimi Code OAuth 登录凭据查询订阅用量,access token 过期自动 refresh 并写回凭据(单 flight 锁 + 写回前重读防 CLI 竞态)
- 全屏「配置用量查询」页面:启用开关 / 自动检测 vs NewAPI 模板 / 超时与自动查询间隔 / 测试查询
- 供应商卡片紧凑用量行(切换使用左侧)+ 多档明细行;managed 供应商列表置顶(仅展示层)
- 用量显示中英文适配(five_hour→5小时、weekly_limit→7天,Rust 错误串本地化映射)
- 修复:usage_kinds serde rename 导致账单入口不显示;测试查询因 save 触发 loading 翻页卸载面板导致结果丢失(silent save)
This commit is contained in:
KimiSwitch Dev committed 2026-08-01 09:44:36 +08:00
1 parent 12dcb17771
commit ace9842556
26 files changed
+1822 -60

No files matched your search

+34 -2
View File
@@ -8,6 +8,7 @@ import { ProviderEdit } from "./components/ProviderEdit";
import { DashboardPage } from "./components/dashboard/DashboardPage";
import { SessionsPage } from "./components/sessions/SessionsPage";
import { SettingsModal } from "./components/SettingsModal";
import { UsageConfigModal } from "./components/UsageConfigModal";
import { PresetPickerModal } from "./components/PresetPickerModal";
import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults";
@@ -17,7 +18,7 @@ import {
type ProviderPreset,
} from "./config/providerPresets";
import { validateProviders } from "./lib/validation";
import type { Agent, Model, Provider } from "./types";
import type { Agent, Model, Provider, UsageConfig } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
@@ -75,6 +76,8 @@ export default function App() {
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showPresetPicker, setShowPresetPicker] = useState(false);
/** Provider whose usage-config panel is open; null = closed. */
const [usageConfigProvider, setUsageConfigProvider] = useState<string | null>(null);
// Provider names that have been added to the in-memory config but not
// yet committed via save(). Pressing "back" in the edit form for one of
// these drops it silently (no confirm); all other dirty navigation
@@ -306,6 +309,24 @@ export default function App() {
}
};
const handleSaveUsageConfig = (name: string, usageConfig: UsageConfig) => {
updateConfig((cfg) => {
const target = cfg.providers[name];
if (!target) return cfg;
return {
...cfg,
providers: {
...cfg.providers,
[name]: { ...target, usageConfig },
},
};
});
// Silent save: a normal save flips App to its loading screen, which
// unmounts this panel and swallows the in-flight test query's result.
// Returned so the panel's test can await persistence before querying.
return save({ silent: true });
};
const handleDuplicateProvider = async (name: string) => {
updateConfig((cfg) => {
const src = cfg.providers[name];
@@ -524,7 +545,7 @@ export default function App() {
<button
type="button"
className="mt-2 px-2 py-1 border border-red-400/30 rounded hover:bg-red-900/30"
onClick={refresh}
onClick={() => refresh()}
>
{t("retry")}
</button>
@@ -633,6 +654,7 @@ export default function App() {
onDuplicate={handleDuplicateProvider}
onAdd={handleAddProvider}
onSwitchProvider={handleSwitchProvider}
onConfigureUsage={(name) => setUsageConfigProvider(name)}
agent={agent}
/>
) : currentProvider ? (
@@ -802,6 +824,16 @@ export default function App() {
onSelect={handleSelectPreset}
onCustom={handleAddCustomProvider}
/>
{usageConfigProvider && config.providers[usageConfigProvider] && (
<UsageConfigModal
open={usageConfigProvider != null}
agent={agent}
provider={config.providers[usageConfigProvider]}
onClose={() => setUsageConfigProvider(null)}
onSave={handleSaveUsageConfig}
/>
)}
</div>
);
}
+35 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter";
@@ -28,6 +28,7 @@ interface ProviderListProps {
onDuplicate: (name: string) => void;
onAdd: () => void;
onSwitchProvider: (name: string) => void;
onConfigureUsage: (name: string) => void;
agent: Agent;
}
@@ -43,11 +44,17 @@ export function ProviderList({
onDuplicate,
onAdd,
onSwitchProvider,
onConfigureUsage,
agent,
}: ProviderListProps) {
const { t } = useTranslation();
const [testState, setTestState] = useState<Record<string, TestState>>({});
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
const sortedProviders = [...providers].sort(
(a, b) => Number(b.managed === true) - Number(a.managed === true)
);
const handleTest = async (provider: Provider) => {
setTestState((s) => ({ ...s, [provider.name]: { status: "testing" } }));
try {
@@ -132,7 +139,7 @@ export function ProviderList({
</div>
) : (
<div className="grid grid-cols-1 gap-3 w-full">
{providers.map((provider) => {
{sortedProviders.map((provider) => {
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
);
@@ -217,6 +224,19 @@ export function ProviderList({
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
</span>
)}
{/* compact usage summary — sits left of the switch button,
mirroring cc-switch's card layout (usage → action buttons) */}
{(provider.usageKinds?.length ?? 0) > 0 && (
<UsageFooter
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
variant="compact"
autoIntervalMinutes={
provider.usageConfig?.autoQueryIntervalMinutes
}
/>
)}
<button
type="button"
onClick={(e) => {
@@ -283,6 +303,18 @@ export function ProviderList({
<Activity className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onConfigureUsage(provider.name);
}}
title={t("usageConfigBtn")}
className={iconBtn}
aria-label={t("usageConfigBtn")}
>
<BarChart3 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
@@ -301,6 +333,7 @@ export function ProviderList({
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
variant="detail"
/>
</div>
);
+433
View File
@@ -0,0 +1,433 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError, planLabel } from "../lib/usage-display";
import type { Agent, Provider, UsageConfig } from "../types";
interface UsageResult {
success: boolean;
data?: Array<{
planName?: string | null;
remaining?: number | null;
total?: number | null;
used?: number | null;
unit?: string | null;
}> | null;
error?: string | null;
}
interface UsageConfigModalProps {
open: boolean;
agent: Agent;
provider: Provider;
onClose: () => void;
/** Persist the edited config (goes through updateConfig → silent save).
* Awaited by the panel's test query so the backend sees the same config. */
onSave: (providerName: string, config: UsageConfig) => Promise<void> | void;
}
const TEMPLATES: ReadonlyArray<{
id: "auto" | "newapi";
labelKey: TranslationKey;
hintKey: TranslationKey;
}> = [
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
];
function defaultConfig(provider: Provider): UsageConfig {
return (
provider.usageConfig ?? {
enabled: true,
templateType: "auto",
baseUrl: provider.base_url ?? undefined,
autoQueryIntervalMinutes: 0,
}
);
}
/** cc-switch FullScreenPanel 行为:焦点在输入框/文本域时 ESC 不关闭面板。 */
function isTextEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable
);
}
export function UsageConfigModal({ open, agent, provider, onClose, onSave }: UsageConfigModalProps) {
const { t } = useTranslation();
const [cfg, setCfg] = useState<UsageConfig>(() => defaultConfig(provider));
const [showToken, setShowToken] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<string | null>(null);
const [testOk, setTestOk] = useState<boolean | null>(null);
// Re-seed when switching provider or reopening.
useEffect(() => {
if (open) {
setCfg(defaultConfig(provider));
setTestResult(null);
setTestOk(null);
setShowToken(false);
setShowApiKey(false);
}
}, [open, provider.name]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isTextEditableTarget(e.target)) onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
const isManaged = provider.managed === true;
const detectedKinds = useMemo(() => provider.usageKinds ?? [], [provider.usageKinds]);
if (!open) return null;
const handleSave = () => {
void onSave(provider.name, {
...cfg,
templateType: isManaged ? "auto" : cfg.templateType,
});
onClose();
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
setTestOk(null);
// 短路:自动模板 + 无识别类型 → 不发请求,直接给出本地化提示 + 切换入口。
// managed 供应商除外:它们走 OAuth 查询,与 detectedKinds 无关。
if (!isManaged && cfg.templateType === "auto" && detectedKinds.length === 0) {
setTestOk(false);
setTestResult(t("usageUnsupportedProvider"));
setTesting(false);
return;
}
try {
// Persist first (awaited) so the backend query sees the same config
// the user is testing.
await onSave(provider.name, cfg);
const result = await invoke<UsageResult>("query_provider_usage", {
agent,
providerName: provider.name,
forceRefresh: true,
});
if (result.success) {
setTestOk(true);
const summary = (result.data ?? [])
.map((d) => {
const parts: string[] = [];
if (d.planName) parts.push(planLabel(d.planName, t));
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`);
if (d.used != null && d.total != null && d.total > 0)
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
return parts.join(" ");
})
.filter(Boolean)
.join(", ");
setTestResult(summary || t("usageQueryOk"));
} else {
setTestOk(false);
setTestResult(result.error ? localizeUsageError(result.error, t) : t("usageQueryFailed"));
}
} catch {
setTestOk(false);
// Transient failure (network/timeout) — same label as the card footer.
setTestResult(t("usageNetworkError"));
} finally {
setTesting(false);
}
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex flex-col bg-app text-content-primary">
{/* header */}
<div className="h-16 shrink-0 flex items-center gap-4 px-6 border-b border-border">
<button
type="button"
onClick={onClose}
aria-label={t("usageBack")}
className="w-8 h-8 flex items-center justify-center rounded-lg text-content-muted hover:text-content-primary hover:bg-hover-2 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h2 className="text-lg font-semibold">
{t("usageConfigTitle", { name: provider.name })}
</h2>
</div>
{/* body */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6 space-y-5">
{/* enable toggle */}
<div className="flex items-center justify-between rounded-lg border border-border bg-hover/40 px-4 py-3">
<span className="text-sm text-content-primary">{t("usageEnable")}</span>
<button
type="button"
role="switch"
aria-checked={cfg.enabled}
onClick={() => setCfg({ ...cfg, enabled: !cfg.enabled })}
className={`relative w-10 h-5 rounded-full transition-colors ${
cfg.enabled ? "bg-blue-600" : "bg-hover-2"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
cfg.enabled ? "left-5" : "left-0.5"
}`}
/>
</button>
</div>
{cfg.enabled && (
<>
{/* template picker */}
<div className="rounded-lg border border-border px-4 py-4 space-y-3">
<label className="block text-sm font-medium text-content-primary">{t("usageTemplate")}</label>
{isManaged ? (
<div className="text-sm text-content-muted rounded-lg border border-border bg-hover/40 px-4 py-3">
{t("usageOAuthManagedHint")}
</div>
) : (
<>
<div className="flex gap-2 flex-wrap">
{TEMPLATES.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => setCfg({ ...cfg, templateType: tpl.id })}
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
cfg.templateType === tpl.id
? "bg-blue-600 text-white border-blue-600"
: "border-border text-content-primary hover:bg-hover-2"
}`}
>
{t(tpl.labelKey)}
</button>
))}
</div>
<p className="text-xs text-content-muted">
{cfg.templateType === "newapi"
? t("usageNewapiHint")
: t("usageAutoDetectHint")}
</p>
</>
)}
{/* supported variables (read-only, cc-switch 风格) */}
<div className="pt-1 space-y-1.5">
<span className="block text-xs text-content-muted">{t("usageSupportedVars")}</span>
<div className="flex items-center gap-2 text-xs font-mono">
<span className="text-blue-500">{"{{baseUrl}}"}</span>
<span className="text-content-muted">=</span>
<span className="text-content-primary break-all">
{cfg.templateType === "newapi"
? cfg.baseUrl || provider.base_url || "-"
: provider.base_url || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-xs font-mono">
<span className="text-blue-500">{"{{apiKey}}"}</span>
<span className="text-content-muted">=</span>
<span className="text-content-primary break-all">
{provider.api_key
? showApiKey
? provider.api_key
: "••••••••"
: "-"}
</span>
{provider.api_key && (
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="text-content-muted hover:text-content-primary"
>
{showApiKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
</div>
</div>
{/* auto-detect: show resolved kinds as read-only chips */}
{!isManaged && cfg.templateType === "auto" && (
<div className="space-y-2">
<label className="block text-sm text-content-muted">{t("usageDetectedKinds")}</label>
{detectedKinds.length === 0 ? (
<div className="rounded-lg border border-amber-300/40 bg-amber-50/40 dark:bg-amber-500/10 px-3 py-2.5 space-y-2">
<p className="text-xs text-content-primary">{t("usageNoKindsHint")}</p>
<button
type="button"
onClick={() => setCfg({ ...cfg, templateType: "newapi" })}
className="text-xs px-2.5 py-1 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
{t("usageSwitchToNewapi")}
</button>
</div>
) : (
<div className="flex gap-2 flex-wrap">
{detectedKinds.map((k) => (
<span
key={k}
className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border font-mono"
>
{k}
</span>
))}
</div>
)}
</div>
)}
{/* newapi creds */}
{!isManaged && cfg.templateType === "newapi" && (
<div className="space-y-3">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
<input
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.baseUrl ?? ""}
placeholder={provider.base_url ?? "https://your-newapi-site.com"}
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">
{t("usageAccessToken")} <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showToken ? "text" : "password"}
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.accessToken ?? ""}
placeholder={t("usageAccessTokenPlaceholder")}
onChange={(e) => setCfg({ ...cfg, accessToken: e.target.value || undefined })}
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary"
>
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="mt-1 text-xs text-content-muted">{t("usageAccessTokenHint")}</p>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">
{t("usageUserId")} <span className="text-red-500">*</span>
</label>
<input
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.userId ?? ""}
placeholder={t("usageUserIdPlaceholder")}
onChange={(e) => setCfg({ ...cfg, userId: e.target.value || undefined })}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageUserIdHint")}</p>
</div>
</div>
)}
</div>
{/* timeout + auto query interval */}
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
<input
type="number"
min={0}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.timeoutSeconds ?? 0}
onChange={(e) => {
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
setCfg({ ...cfg, timeoutSeconds: n });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageTimeoutHint")}</p>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageAutoInterval")}</label>
<input
type="number"
min={0}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.autoQueryIntervalMinutes ?? 0}
onChange={(e) => {
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
setCfg({ ...cfg, autoQueryIntervalMinutes: n });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
</div>
</div>
</>
)}
{/* test result - sticky bottom inside scroll container so it's always visible above the footer */}
</div>
{testResult && (
<div className="sticky bottom-0">
<div className="max-w-3xl mx-auto px-6 pb-6">
<div
className={`text-sm rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm ${
testOk
? "border-green-300 dark:border-green-500/30 bg-green-50/95 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "border-red-300 dark:border-red-500/30 bg-red-50/95 dark:bg-red-900/30 text-red-600 dark:text-red-300"
}`}
>
{testResult}
</div>
</div>
</div>
)}
</div>
{/* footer */}
<div className="shrink-0 border-t border-border">
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleTest}
disabled={testing || !cfg.enabled}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
>
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
{t("usageTestQuery")}
</button>
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 transition-colors"
>
{t("cancel")}
</button>
<button
type="button"
onClick={handleSave}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
{t("saveConfig")}
</button>
</div>
</div>
</div>,
document.body
);
}
+112 -8
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n";
import { localizeUsageError, planLabel } from "../lib/usage-display";
import type { Agent } from "../types";
interface UsageData {
@@ -31,7 +32,8 @@ interface CacheEntry {
}
// Module-level cache shared across mounts: re-entering the list within the
// stale TTL shows the last result without firing new requests.
// stale TTL shows the last result without firing new requests. Both the
// compact (card header) and detail (footer) variants read this same cache.
const STALE_TTL_MS = 5 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
@@ -54,9 +56,21 @@ interface UsageFooterProps {
agent: Agent;
providerName: string;
usageKinds?: string[];
/** "detail" (default) renders the multi-line footer; "compact" renders a
* one-line summary + last-updated + refresh button for the card header. */
variant?: "detail" | "compact";
/** Auto query interval in minutes; only wired on the compact variant so
* both variants do not double-fire. 0/undefined = manual only. */
autoIntervalMinutes?: number;
}
export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProps) {
export function UsageFooter({
agent,
providerName,
usageKinds,
variant = "detail",
autoIntervalMinutes,
}: UsageFooterProps) {
const { t } = useTranslation();
const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider
@@ -67,6 +81,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
const [data, setData] = useState<UsageData[]>([]);
/** undefined = no error; null = network error; string = Rust error text. */
const [error, setError] = useState<string | null | undefined>(undefined);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now());
// Generation counter: stale responses (unmounted / superseded query) are ignored.
const genRef = useRef(0);
@@ -82,6 +97,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
setStatus(entry.status);
setData(entry.data);
setError(entry.error);
setUpdatedAt(entry.updatedAt);
};
try {
await acquireSlot();
@@ -133,6 +149,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
setStatus(cached.status);
setData(cached.data);
setError(cached.error);
setUpdatedAt(cached.updatedAt);
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
}
void runQuery(false);
@@ -145,7 +162,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
};
}, []);
// Tick the reset countdown once a minute while showing data.
// Auto query interval (compact variant only, to avoid double-firing).
useEffect(() => {
if (variant !== "compact" || !supported) return;
const mins = autoIntervalMinutes ?? 0;
if (mins <= 0) return;
const id = setInterval(() => void runQuery(false), mins * 60_000);
return () => clearInterval(id);
}, [variant, supported, autoIntervalMinutes, runQuery]);
// Tick relative times once a minute while showing data.
useEffect(() => {
if (status !== "success") return;
const id = setInterval(() => setNow(Date.now()), 60_000);
@@ -185,6 +211,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
return t("usageResetIn", { time: `${Math.floor(hours / 24)}d` });
};
const formatAgo = (ts: number | null): string | null => {
if (ts == null) return null;
const mins = Math.floor((now - ts) / 60_000);
if (mins < 1) return t("usageUpdatedAgo", { time: "1m" });
if (mins < 60) return t("usageMinAgo", { n: mins });
const hours = Math.floor(mins / 60);
if (hours < 24) return t("usageHourAgo", { n: hours });
return t("usageDayAgo", { n: Math.floor(hours / 24) });
};
const refreshBtn = (
<button
type="button"
@@ -195,7 +231,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
disabled={status === "loading"}
title={t("usageRefresh")}
aria-label={t("usageRefresh")}
className="ml-auto w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
className="w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
>
{status === "loading" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -205,6 +241,73 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
</button>
);
/** One-line summary of the primary entry (first tier / balance). */
const compactSummary = (): { icon: string; text: string; color: string } | null => {
const d = data[0];
if (!d) return null;
const isPlan = !!d.planName && d.planName !== "NewAPI";
if (d.isValid === false) {
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
}
if (d.planName === "NewAPI" && d.remaining == null && d.total == null) {
return { icon: "💰", text: `${t("usageBalance")} ∞`, color: "text-green-600 dark:text-green-400" };
}
if (isPlan) {
const pct = percentOf(d);
const color =
pct == null
? "text-content-muted"
: pct < 70
? "text-green-600 dark:text-green-400"
: pct < 90
? "text-orange-600 dark:text-orange-400"
: "text-red-500 dark:text-red-400";
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} ` : "";
return {
icon: "⚡",
text: pct != null ? `${label}${Math.round(pct)}%` : `${label}—`,
color,
};
}
return {
icon: "💰",
text: formatAmount(d.remaining, d.unit),
color: "text-content-primary",
};
};
if (variant === "compact") {
if (status === "loading" && data.length === 0) {
return <div className="h-3.5 w-20 rounded bg-hover-2 animate-pulse" />;
}
if (status === "error" && data.length === 0) {
return (
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400">
<span className="truncate max-w-[120px]" title={error ?? t("usageNetworkError")}>
{error == null ? t("usageNetworkError") : localizeUsageError(error, t)}
</span>
{refreshBtn}
</span>
);
}
const s = compactSummary();
const ghost = status !== "success";
const ago = formatAgo(updatedAt);
return (
<span className={`flex items-center gap-1.5 text-xs tabular-nums ${ghost ? "opacity-50" : ""}`}>
{s && (
<>
<span aria-hidden="true">{s.icon}</span>
<span className={`font-medium ${s.color}`}>{s.text}</span>
</>
)}
{ago && <span className="text-content-muted">{ago}</span>}
{refreshBtn}
</span>
);
}
// ── detail variant ──
// Loading with no prior data: skeleton bar.
if (status === "loading" && data.length === 0) {
return (
@@ -224,7 +327,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
: /401|api[ -]?key|invalid key/i.test(error)
? t("usageInvalidKey")
: error
? `${t("usageQueryFailed")} · ${error}`
? `${t("usageQueryFailed")} · ${localizeUsageError(error, t)}`
: t("usageQueryFailed");
return (
<div className="w-full border-t border-border pt-2 mt-1 flex items-center gap-2 text-xs">
@@ -246,6 +349,9 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
}
// success, or error/loading with last-good data (ghost).
// Compact header already shows the primary entry; detail only adds value
// when there are multiple tiers (e.g. 5h + weekly) or on error ghost.
if (data.length <= 1 && status === "success") return null;
const ghost = status !== "success";
return (
<div
@@ -270,7 +376,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
if (d.isValid === false) {
main = t("usageInvalidKey");
} else if (isPlan) {
const label = data.length > 1 && d.planName ? `${d.planName} · ` : "";
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} · ` : "";
main =
pct != null
? `${label}${Math.round(pct)}% ${t("usageUsed")}`
@@ -295,14 +401,12 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
{resetText && (
<span className="text-content-muted shrink-0">{resetText}</span>
)}
{i === data.length - 1 && refreshBtn}
</div>
);
})}
{data.length === 0 && (
<div className="flex items-center gap-1.5">
<span className="text-content-muted">—</span>
{refreshBtn}
</div>
)}
</div>
+5
View File
@@ -13,6 +13,7 @@ export type UsageKind =
| "balance:openrouter"
| "balance:stepfun"
| "balance:novita"
| "balance:kimi"
| "plan:kimi_coding"
| "plan:zhipu"
| "plan:minimax";
@@ -27,6 +28,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:openrouter",
"balance:stepfun",
"balance:novita",
"balance:kimi",
"plan:kimi_coding",
"plan:zhipu",
"plan:minimax",
@@ -141,6 +143,9 @@ export const providerPresets: ProviderPreset[] = [
{ model: "kimi-k2.6" },
],
billingMode: "pay_as_you_go",
// Kimi 开放平台余额:国内站 api.moonshot.cn / 国际站 api.moonshot.ai,
// Rust detect 按 host 消歧(api.moonshot.ai → USD,api.moonshot.cn → CNY)。
usageKinds: ["balance:kimi"],
},
{
id: "deepseek",
+12 -9
View File
@@ -8,8 +8,8 @@ interface UseConfigReturn {
dirty: boolean;
error: string | null;
loading: boolean;
refresh: () => Promise<void>;
save: () => Promise<void>;
refresh: (opts?: { silent?: boolean }) => Promise<void>;
save: (opts?: { silent?: boolean }) => Promise<void>;
updateConfig: (updater: (config: Config) => Config) => void;
}
@@ -25,8 +25,11 @@ export function useConfig(agent: Agent): UseConfigReturn {
configRef.current = config;
}, [config]);
const refresh = useCallback(async () => {
setLoading(true);
const refresh = useCallback(async (opts?: { silent?: boolean }) => {
// silent: skip the loading flip — App returns a bare loading screen while
// `loading` is true, which would unmount any open panel (and swallow its
// in-flight async results).
if (!opts?.silent) setLoading(true);
setError(null);
try {
const loadedConfig = await invoke<Config>("load_agent_config_command", {
@@ -39,7 +42,7 @@ export function useConfig(agent: Agent): UseConfigReturn {
setError(message);
console.error("useConfig refresh failed:", message);
} finally {
setLoading(false);
if (!opts?.silent) setLoading(false);
}
}, [agent]);
@@ -61,19 +64,19 @@ export function useConfig(agent: Agent): UseConfigReturn {
setDirty(true);
}, []);
const save = useCallback(async () => {
const save = useCallback(async (opts?: { silent?: boolean }) => {
const current = configRef.current;
if (!current) return;
setLoading(true);
if (!opts?.silent) setLoading(true);
setError(null);
try {
await invoke("save_agent_config_command", { agent, config: current });
await refresh();
await refresh({ silent: opts?.silent });
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
setError(raw);
} finally {
setLoading(false);
if (!opts?.silent) setLoading(false);
}
}, [refresh, t, agent]);
+41
View File
@@ -372,6 +372,47 @@ export const enTranslations: Record<TranslationKey, string> = {
usageResetIn: "resets in {time}",
usageResetDone: "reset",
usageLoading: "Querying…",
usageQueryOk: "Query succeeded",
// Usage config panel (cc-switch style)
usageConfigTitle: "Configure Usage Query - {name}",
usageConfigBtn: "Configure usage query",
usageEnable: "Enable usage query",
usageTemplate: "Query template",
usageTemplateAuto: "Auto-detect",
usageTemplateNewapi: "NewAPI gateway",
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
usageDetectedKinds: "Detected query kinds",
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
usageBaseUrl: "Query URL (Base URL)",
usageAccessToken: "Access Token",
usageAccessTokenPlaceholder: "Access Token from the web console",
usageAccessTokenHint: "Generated in the NewAPI web console (Personal Settings → Access Token) — NOT the sk- inference key",
usageUserId: "User ID",
usageUserIdPlaceholder: "NewAPI user id (numeric)",
usageUserIdHint: "User id from the web console profile page, sent as the New-Api-User header",
usageAutoInterval: "Auto query interval (minutes)",
usageAutoIntervalHint: "0 = query only on page open or manual refresh",
usageTimeout: "Timeout (seconds)",
usageTimeoutHint: "0 = use the default (8 seconds)",
usageSupportedVars: "Supported variables",
usageBack: "Back",
usageNoKindsHint: "No auto-detected query type for this provider. Switch to the NewAPI template to configure manually, or add a custom query script (coming soon).",
usageSwitchToNewapi: "Switch to NewAPI template",
usageUnsupportedProvider: "This provider is not supported by auto query",
usageTier5h: "5-hour",
usageTierWeekly: "Weekly",
usageErrNoKey: "No API key configured",
usageErrDisabled: "Usage query is disabled in the config panel",
usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again",
usageErrNoOauth: "No Kimi Code OAuth credentials found; run `kimi login` first",
usageErrNewapiCreds: "The NewAPI template requires Access Token and User ID",
usageTestQuery: "Test query",
usageUpdatedAgo: "{time} ago",
usageMinAgo: "{n} min ago",
usageHourAgo: "{n} h ago",
usageDayAgo: "{n} d ago",
// Billing mode (subscription / pay-as-you-go)
billingTabSubscription: "Subscription",
+41
View File
@@ -367,6 +367,47 @@ export const zhTranslations = {
usageResetIn: "{time} 后重置",
usageResetDone: "已重置",
usageLoading: "查询中…",
usageQueryOk: "查询成功",
// Usage config panel (cc-switch style)
usageConfigTitle: "配置用量查询 - {name}",
usageConfigBtn: "配置用量查询",
usageEnable: "启用用量查询",
usageTemplate: "查询模板",
usageTemplateAuto: "自动检测",
usageTemplateNewapi: "NewAPI 中转站",
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
usageDetectedKinds: "检测到的查询类型",
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
usageBaseUrl: "查询地址 (Base URL)",
usageAccessToken: "Access Token",
usageAccessTokenPlaceholder: "网页后台生成的 Access Token",
usageAccessTokenHint: "在 NewAPI 站点网页后台「个人设置 → Access Token」生成,不是 sk- 开头的推理 Key",
usageUserId: "User ID",
usageUserIdPlaceholder: "NewAPI 用户 ID(数字)",
usageUserIdHint: "网页后台「个人中心」页面的用户 ID,用于 New-Api-User 请求头",
usageAutoInterval: "自动查询间隔(分钟)",
usageAutoIntervalHint: "0 表示不自动查询,仅在打开页面或手动刷新时查询",
usageTimeout: "超时时间(秒)",
usageTimeoutHint: "0 表示使用默认值(8 秒)",
usageSupportedVars: "支持的变量",
usageBack: "返回",
usageNoKindsHint: "未识别此供应商的自动查询类型,请切换到 NewAPI 模板手动配置,或添加自定义查询脚本(即将支持)",
usageSwitchToNewapi: "切换到 NewAPI 模板",
usageUnsupportedProvider: "此供应商暂不支持自动查询",
usageTier5h: "5小时",
usageTierWeekly: "7天",
usageErrNoKey: "未配置 API Key",
usageErrDisabled: "用量查询已在配置面板中停用",
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
usageErrNoOauth: "未找到 Kimi Code 登录凭据,请先运行 `kimi login`",
usageErrNewapiCreds: "NewAPI 模板需要填写 Access Token 和 User ID",
usageTestQuery: "测试查询",
usageUpdatedAgo: "{time}前",
usageMinAgo: "{n} 分钟前",
usageHourAgo: "{n} 小时前",
usageDayAgo: "{n} 天前",
// Billing mode (套餐 / 按量)
billingTabSubscription: "套餐",
+44
View File
@@ -0,0 +1,44 @@
import type { TranslationKey } from "../i18n/zh";
type TranslateFn = (
key: TranslationKey,
vars?: Record<string, string | number>
) => string;
/**
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
*
* Coverage note: ALL plan-type (套餐) queries flow through
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT — so this mapping covers every
* current and future plan provider automatically. If a new tier id is ever
* added there, add a case here too.
*/
export function planLabel(name: string, t: TranslateFn): string {
switch (name) {
case "five_hour":
return t("usageTier5h");
case "weekly_limit":
return t("usageTierWeekly");
default:
return name;
}
}
/**
* Map known Rust error strings to localized text. Unknown errors pass
* through unchanged (they are diagnostic details like HTTP status + body).
* Keep patterns aligned with the exact prefixes emitted in
* src-tauri/src/commands.rs and src-tauri/src/services/*.
*/
export function localizeUsageError(error: string, t: TranslateFn): string {
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
if (error.startsWith("no API key configured")) return t("usageErrNoKey");
if (error.startsWith("usage query disabled")) return t("usageErrDisabled");
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired");
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
if (error.startsWith("Authentication failed")) return t("usageInvalidKey");
return error;
}
+24
View File
@@ -34,6 +34,11 @@ export interface Provider {
* and persisted back to SQLite on save; never written to config.toml.
*/
usageKinds?: string[];
/**
* Usage query configuration edited via the "配置用量查询" panel.
* Same persistence as usageKinds (SQLite settings, never config.toml).
*/
usageConfig?: UsageConfig;
/** Extra agent-specific provider fields preserved across edits. */
raw_other?: unknown;
}
@@ -59,6 +64,25 @@ export interface Config {
raw_other?: unknown;
}
/**
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
* NewAPI/OneAPI gateway with accessToken + userId.
*/
export interface UsageConfig {
enabled: boolean;
templateType: "auto" | "newapi";
baseUrl?: string;
/** NewAPI web-console access token (NOT the sk- inference key). */
accessToken?: string;
/** NewAPI user id, sent as the New-Api-User header. */
userId?: string;
/** Auto query interval in minutes; 0/undefined = manual refresh only. */
autoQueryIntervalMinutes?: number;
/** Per-request timeout in seconds; 0/undefined = default (8s). */
timeoutSeconds?: number;
}
/** A model discovered from a provider's API endpoint. */
export interface DiscoveredModel {
id: string;