- 订阅套餐零价条目按官方付费模型计价(zhipuai-coding-plan/glm-5.2 → zhipuai/glm-5.2 牌价) - -highspeed / -free 派生变体自动回退到基础模型价格;Kimi For Coding 无牌价时按旗舰 kimi-k3 估算 - 设置页:image_in 识图能力可手动编辑,原生多模态模型不再提示 kimi-eyes 插件 - 修复仅单个套餐档位时用量卡片不显示的问题 - kimi usages 响应异常时透出原始返回(仅用量数字),便于对照接口排查 - models.dev 价格快照同步
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
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 {
|
|
planName?: string | null;
|
|
remaining?: number | null;
|
|
total?: number | null;
|
|
used?: number | null;
|
|
unit?: string | null;
|
|
isValid?: boolean | null;
|
|
resetsAt?: string | null;
|
|
}
|
|
|
|
interface UsageResult {
|
|
success: boolean;
|
|
data?: UsageData[] | null;
|
|
error?: string | null;
|
|
}
|
|
|
|
type UsageStatus = "idle" | "loading" | "success" | "error";
|
|
|
|
interface CacheEntry {
|
|
status: "success" | "error";
|
|
data: UsageData[];
|
|
/** Raw error text from Rust; null = transient failure (invoke rejected). */
|
|
error: string | null;
|
|
updatedAt: number;
|
|
}
|
|
|
|
// Module-level cache shared across mounts: re-entering the list within the
|
|
// 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>();
|
|
|
|
// Simple semaphore: at most MAX_CONCURRENT queries in flight at once.
|
|
const MAX_CONCURRENT = 3;
|
|
let running = 0;
|
|
const waiters: Array<() => void> = [];
|
|
async function acquireSlot(): Promise<void> {
|
|
if (running >= MAX_CONCURRENT) {
|
|
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
}
|
|
running += 1;
|
|
}
|
|
function releaseSlot(): void {
|
|
running -= 1;
|
|
waiters.shift()?.();
|
|
}
|
|
|
|
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,
|
|
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
|
|
// with the same name do not clobber each other's cached result.
|
|
const cacheKey = `${agent}:${providerName}`;
|
|
|
|
const [status, setStatus] = useState<UsageStatus>("idle");
|
|
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);
|
|
|
|
const runQuery = useCallback(
|
|
async (forceRefresh: boolean) => {
|
|
const gen = ++genRef.current;
|
|
setStatus("loading");
|
|
setError(undefined);
|
|
const finish = (entry: CacheEntry) => {
|
|
cache.set(cacheKey, entry);
|
|
if (genRef.current !== gen) return;
|
|
setStatus(entry.status);
|
|
setData(entry.data);
|
|
setError(entry.error);
|
|
setUpdatedAt(entry.updatedAt);
|
|
};
|
|
try {
|
|
await acquireSlot();
|
|
let result: UsageResult;
|
|
try {
|
|
result = await invoke<UsageResult>("query_provider_usage", {
|
|
agent,
|
|
providerName,
|
|
forceRefresh,
|
|
});
|
|
} finally {
|
|
releaseSlot();
|
|
}
|
|
if (genRef.current !== gen) return;
|
|
if (result.success) {
|
|
finish({
|
|
status: "success",
|
|
data: result.data ?? [],
|
|
error: null,
|
|
updatedAt: Date.now(),
|
|
});
|
|
} else {
|
|
// Deterministic failure: keep last good data for ghost display.
|
|
finish({
|
|
status: "error",
|
|
data: cache.get(cacheKey)?.data ?? [],
|
|
error: result.error ?? "",
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
} catch {
|
|
// Transient failure (network / timeout): invoke rejected.
|
|
finish({
|
|
status: "error",
|
|
data: cache.get(cacheKey)?.data ?? [],
|
|
error: null,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
},
|
|
[agent, providerName, cacheKey]
|
|
);
|
|
|
|
// On mount / provider change: serve fresh cache, otherwise query once.
|
|
useEffect(() => {
|
|
if (!supported) return;
|
|
const cached = cache.get(cacheKey);
|
|
if (cached) {
|
|
setStatus(cached.status);
|
|
setData(cached.data);
|
|
setError(cached.error);
|
|
setUpdatedAt(cached.updatedAt);
|
|
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
|
}
|
|
void runQuery(false);
|
|
}, [supported, cacheKey, runQuery]);
|
|
|
|
// Ignore late responses after unmount.
|
|
useEffect(() => {
|
|
return () => {
|
|
genRef.current += 1;
|
|
};
|
|
}, []);
|
|
|
|
// 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);
|
|
return () => clearInterval(id);
|
|
}, [status]);
|
|
|
|
if (!supported || status === "idle") return null;
|
|
|
|
const percentOf = (d: UsageData): number | null => {
|
|
if (d.used != null && d.total != null && d.total > 0)
|
|
return (d.used / d.total) * 100;
|
|
if (d.remaining != null && d.total != null && d.total > 0)
|
|
return (1 - d.remaining / d.total) * 100;
|
|
return null;
|
|
};
|
|
|
|
const formatAmount = (
|
|
value: number | null | undefined,
|
|
unit?: string | null
|
|
): string => {
|
|
if (value == null) return "—";
|
|
const symbol =
|
|
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : "";
|
|
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
return `${symbol}${num}`;
|
|
};
|
|
|
|
const formatReset = (resetsAt?: string | null): string | null => {
|
|
if (!resetsAt) return null;
|
|
const ms = new Date(resetsAt).getTime() - now;
|
|
if (Number.isNaN(ms)) return null;
|
|
if (ms <= 0) return t("usageResetDone");
|
|
const mins = Math.floor(ms / 60_000);
|
|
if (mins < 60) return t("usageResetIn", { time: `${Math.max(mins, 1)}m` });
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return t("usageResetIn", { time: `${hours}h` });
|
|
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"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void runQuery(true);
|
|
}}
|
|
disabled={status === "loading"}
|
|
title={t("usageRefresh")}
|
|
aria-label={t("usageRefresh")}
|
|
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" />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
)}
|
|
</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;
|
|
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
|
|
// entries carry only remaining + unit — their plan_name is a currency /
|
|
// brand label and must render as an amount, not a percentage.
|
|
const isPlan =
|
|
!!d.planName &&
|
|
d.planName !== "NewAPI" &&
|
|
(d.total != null || d.used != null);
|
|
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 (
|
|
<div
|
|
className="w-full border-t border-border pt-2 mt-1"
|
|
aria-label={t("usageLoading")}
|
|
>
|
|
<div className="h-3.5 w-2/3 rounded bg-hover-2 animate-pulse" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status === "error" && data.length === 0) {
|
|
const text =
|
|
error == null
|
|
? t("usageNetworkError")
|
|
: /401|api[ -]?key|invalid key/i.test(error)
|
|
? t("usageInvalidKey")
|
|
: 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">
|
|
<span className="text-red-500 dark:text-red-400 truncate" title={text}>
|
|
{text}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void runQuery(true);
|
|
}}
|
|
className="ml-auto shrink-0 px-2 py-0.5 rounded border border-red-300 dark:border-red-500/30 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
|
>
|
|
{t("usageRetry")}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
// NOTE: single-tier plans (e.g. only five_hour) must still render — hiding
|
|
// them made kimi-for-coding look like it had no plan quota at all.
|
|
if (data.length === 0 && status === "success") return null;
|
|
const ghost = status !== "success";
|
|
return (
|
|
<div
|
|
className={`w-full border-t border-border pt-2 mt-1 flex flex-col gap-1 text-xs ${
|
|
ghost ? "opacity-50" : ""
|
|
}`}
|
|
>
|
|
{data.map((d, i) => {
|
|
// Same plan-vs-balance rule as compactSummary: balance rows have no
|
|
// quota fields (total/used), so they render as an amount.
|
|
const isPlan =
|
|
!!d.planName &&
|
|
d.planName !== "NewAPI" &&
|
|
(d.total != null || d.used != null);
|
|
const pct = isPlan ? percentOf(d) : null;
|
|
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 resetText = isPlan ? formatReset(d.resetsAt) : null;
|
|
|
|
let main: string;
|
|
if (d.isValid === false) {
|
|
main = t("usageInvalidKey");
|
|
} else if (isPlan) {
|
|
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} · ` : "";
|
|
main =
|
|
pct != null
|
|
? `${label}${Math.round(pct)}% ${t("usageUsed")}`
|
|
: d.remaining != null
|
|
? `${label}${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`
|
|
: label || "—";
|
|
} else {
|
|
main = `${t("usageBalance")} ${formatAmount(d.remaining, d.unit)}`;
|
|
}
|
|
|
|
return (
|
|
<div key={i} className="flex items-center gap-1.5 min-w-0">
|
|
<span aria-hidden="true">{isPlan ? "⚡" : "💰"}</span>
|
|
<span
|
|
className={`tabular-nums truncate ${
|
|
d.isValid === false ? "text-red-500 dark:text-red-400" : color
|
|
}`}
|
|
title={main}
|
|
>
|
|
{main}
|
|
</span>
|
|
{resetText && (
|
|
<span className="text-content-muted shrink-0">{resetText}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{data.length === 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-content-muted">—</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|