v0.6.0: 预设供应商 + 供应商账单查询

## 新增

- 15 条主流供应商预设(Kimi Coding / Moonshot / Anthropic /
  DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun /
  SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio /
  火山方舟),通过 PresetPickerModal 一键填表
- 供应商余额/套餐查询(卡片底部 UsageFooter):余额类
  (DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita) +
  套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL +
  force_refresh + 并发 ≤3
- Rust 端 services/ 模块(balance / coding_plan / usage_types),
  参考 cc-switch(MIT,© Jason Young)实现
- query_provider_usage 命令:Rust 端从 config 加载 key,
  不经 IPC 序列化 API key
- usageKinds 持久化到 SQLite settings(JSON 数组),
  load 时合并回 config,导出 config.toml 时不写
- detect_provider 启发式:旧用户升级后自动获得账单支持
- save-time 校验:未完成供应商(缺 api_key / base_url / 模型)
  保存时弹 confirm 列出原因;新增未提交 back 时静默 drop
- docs/PROPOSAL-presets-and-usage.md(v0.4-draft 实施档)
- docs/VERIFICATION-CHECKLIST.md(70+ 项手动验证清单)

## 修复

- handleDuplicateProvider 的 alias.slice 漏洞:老数据
  非规范 alias 复制不再错位
- handleSelectPreset 不再 auto-save,半成品不会落盘
- handleSwitchProvider 校验取消时自动 refresh 回滚,
  不会出现内存改 / 盘没动的脏态

## 文档

- README / README_EN 致谢段补 cc-switch(预设 + 账单)
- README 已知限制与后续建议段(/reload 上游限制调研结论)
- i18n 补 5 条校验 key + 11 条预设 UI key + 12 条账单 key

详见 docs/PROPOSAL-presets-and-usage.md 与
docs/VERIFICATION-CHECKLIST.md
This commit is contained in:
KimiSwitch Dev committed 2026-07-29 20:59:53 +08:00
1 parent a1fdc6c93d
commit 2614f80d0f
27 files changed
+3442 -67

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets";
import { ProviderIcon } from "./ProviderIcon";
interface PresetPickerModalProps {
open: boolean;
onClose: () => void;
onSelect: (preset: ProviderPreset) => void;
onCustom: () => void;
}
const CATEGORY_BADGE: Record<Exclude<PresetCategory, "custom">, string> = {
official: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400",
cn_official: "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400",
third_party: "bg-gray-200 text-gray-600 dark:bg-gray-500/15 dark:text-gray-400",
aggregator: "bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400",
};
const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey> = {
official: "presetCategoryOfficial",
cn_official: "presetCategoryCnOfficial",
third_party: "presetCategoryThirdParty",
aggregator: "presetCategoryAggregator",
};
type SortMode = "original" | "alpha";
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("original");
// Esc to close
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Scroll lock while open
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Reset transient state each time the modal opens
useEffect(() => {
if (open) {
setQuery("");
setSort("original");
}
}, [open]);
const presetName = (p: ProviderPreset) =>
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
let list = providerPresets;
if (q) {
list = list.filter(
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
);
}
if (sort === "alpha") {
list = [...list].sort((a, b) => presetName(a).localeCompare(presetName(b)));
}
return list;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, sort, t]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={t("presetPickerTitle")}
>
<div
className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<div className="text-lg font-semibold text-content-primary">
{t("presetPickerTitle")}
</div>
<button
type="button"
onClick={onClose}
aria-label={t("close")}
className="rounded-md p-1 text-content-muted hover:bg-border hover:text-content-primary transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* Search + sort */}
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("presetSearchPlaceholder")}
autoFocus
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex items-center rounded border border-border bg-input p-0.5">
<button
type="button"
onClick={() => setSort("original")}
className={`px-2 py-1 text-xs rounded transition-colors ${
sort === "original"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("presetSortOriginal")}
</button>
<button
type="button"
onClick={() => setSort("alpha")}
className={`px-2 py-1 text-xs rounded transition-colors ${
sort === "alpha"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("presetSortAlpha")}
</button>
</div>
</div>
{/* Preset grid */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{visible.length === 0 ? (
<div className="py-8 text-center text-sm text-content-muted">
{t("presetNoResults")}
</div>
) : (
<div
className="grid gap-2"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}
>
{visible.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onSelect(p)}
className="flex flex-col items-start gap-2 rounded-lg border border-border bg-input p-3 text-left transition-colors hover:border-blue-500 hover:bg-hover-2"
>
<ProviderIcon name={presetName(p)} icon={p.icon ?? null} color={p.iconColor ?? null} size={32} />
<div className="w-full truncate text-sm font-medium text-content-primary">
{presetName(p)}
</div>
{p.category !== "custom" && (
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
>
{t(CATEGORY_LABEL[p.category])}
</span>
)}
</button>
))}
</div>
)}
{/* Custom (empty form) entry */}
<button
type="button"
onClick={onCustom}
className="mt-3 flex w-full items-center justify-center gap-1 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-content-muted transition-colors hover:border-blue-500 hover:text-content-primary"
>
{t("presetCustomConfig")}
</button>
</div>
{/* Footer hint */}
<div className="border-t border-border px-5 py-2.5 text-xs text-content-muted">
{t("presetFooterHint")}
</div>
</div>
</div>,
document.body
);
}
+9 -1
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter";
import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult {
@@ -144,13 +145,14 @@ export function ProviderList({
return (
<div
key={provider.name}
className={`group relative flex items-center gap-4 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
isActive
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20"
: "bg-panel border-border hover:border-strong hover:bg-hover"
}`}
onClick={() => onEdit(provider.name)}
>
<div className="flex items-center gap-4 w-full">
<ProviderIcon
name={provider.name}
icon={provider.icon}
@@ -294,6 +296,12 @@ export function ProviderList({
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<UsageFooter
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
/>
</div>
);
})}
+310
View File
@@ -0,0 +1,310 @@
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 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.
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[];
}
export function UsageFooter({ agent, providerName, usageKinds }: 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 [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);
};
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);
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;
};
}, []);
// Tick the reset countdown 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 refreshBtn = (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void runQuery(true);
}}
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"
>
{status === "loading" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
);
// 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")} · ${error}`
: 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).
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) => {
const isPlan = !!d.planName;
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 ? `${d.planName} · ` : "";
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>
)}
{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>
);
}