feat: 阈值预警/加力钱包/api_key_env/版本跟踪器/一键体检 — bump to v0.7.20
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
Release / Attach macOS install script (push) Canceled after 0s

This commit is contained in:
KimiSwitch Dev committed 2026-09-20 09:49:49 +08:00
1 parent 35581103ed
commit fcd08eae32
35 files changed
+2567 -91

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import {
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type CheckResult,
} from "./useHealthCheck";
/**
* 只回显 key,便于断言选中的是哪条文案(真实插值由 i18n Provider 负责)。
*/
const t = ((key: string) => `<${key}>`) as unknown as Parameters<
typeof localizeHealthError
>[1];
/** 带插值的假 t:`<key|{a}=1>`,用于断言参数透传。 */
const tWithVars = ((key: string, vars?: Record<string, string | number>) =>
vars
? `<${key}|${Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(",")}>`
: `<${key}>`) as unknown as Parameters<typeof localizeHealthError>[1];
describe("localizeHealthError", () => {
it("探活错误按前缀映射到对应文案", () => {
expect(localizeHealthError("missing base_url", t)).toBe(
"<healthErrNoBaseUrl>"
);
expect(localizeHealthError("no API key configured", t)).toBe(
"<healthErrNoKey>"
);
expect(localizeHealthError("Authentication failed (HTTP 401)", t)).toBe(
"<healthErrAuth>"
);
expect(localizeHealthError("endpoint not found (HTTP 404)", t)).toBe(
"<healthErrNotFound>"
);
expect(localizeHealthError("request timed out", t)).toBe(
"<healthErrTimeout>"
);
expect(
localizeHealthError("connection failed (DNS or refused)", t)
).toBe("<healthErrUnreachable>");
expect(localizeHealthError("response is not valid JSON", t)).toBe(
"<healthErrBadJson>"
);
});
it("未知错误原样透出(HTTP 状态码等诊断细节)", () => {
expect(localizeHealthError("API error (HTTP 502)", t)).toBe(
"API error (HTTP 502)"
);
});
it("探活 + 账单两段拼接时逐段映射", () => {
expect(
localizeHealthError(
"endpoint not found (HTTP 404); usage query disabled in config panel",
t
)
).toBe("<healthErrNotFound>; <usageErrDisabled>");
});
it("账单链路的报错复用用量错误映射", () => {
// commands.rs 的 kinds 循环会把单条错误包成 `{kind}: {msg}`。
expect(
localizeHealthError(
"Authentication failed (HTTP 401); balance:deepseek: Authentication failed (HTTP 401)",
t
)
).toBe("<healthErrAuth>; <usageInvalidKey>");
});
});
describe("probeLine", () => {
it("通过时带延迟", () => {
expect(probeLine({ state: "pass" }, 123, tWithVars)).toBe(
"<healthProbeOk|latency=123>"
);
});
it("中性(404)说明端点不支持模型列表,而不是报失败", () => {
expect(
probeLine(
{ state: "neutral", error: "endpoint not found (HTTP 404)" },
0,
tWithVars
)
).toBe("<healthProbeUnsupported>");
});
it("失败时透出本地化后的原因", () => {
expect(
probeLine({ state: "fail", error: "request timed out" }, 0, tWithVars)
).toBe("<healthProbeFail|error=<healthErrTimeout>>");
});
});
describe("usageLine", () => {
it("通过 / 失败 / 兜底三态", () => {
const pass: CheckResult = { state: "pass" };
expect(usageLine(pass, tWithVars)).toBe("<healthUsageOk>");
expect(
usageLine(
{ state: "fail", error: "Authentication failed (HTTP 401)" },
tWithVars
)
).toBe("<healthUsageFail|error=<healthErrAuth>>");
// 账单链路不会产生中性结论,兜底按「未判定」展示
expect(usageLine({ state: "neutral" }, tWithVars)).toBe(
"<healthNotChecked>"
);
});
});
describe("healthDotClass", () => {
it("绿=通过、红=失败、灰=未判定(中性)", () => {
expect(healthDotClass("pass")).toBe("bg-green-500");
expect(healthDotClass("fail")).toBe("bg-red-500");
expect(healthDotClass("neutral")).toBe("bg-gray-400 dark:bg-gray-500");
});
});
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Agent } from "../types";
import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError } from "../lib/usage-display";
type TranslateFn = (
key: TranslationKey,
vars?: Record<string, string | number>
) => string;
/** 单项检测的三态结论(mirrors Rust `HealthCheck`)。 */
export type HealthCheck = "pass" | "fail" | "neutral";
/** 单项检测结论(探活 / 账单各一份,mirrors Rust `CheckResult`)。 */
export interface CheckResult {
state: HealthCheck;
/** 失败 / 中性原因的原始文案;通过为 null。 */
error?: string | null;
}
/** Mirrors the Rust `HealthResult` (camelCase via serde). */
export interface HealthResult {
providerName: string;
/** 总体判定(Rust 侧是唯一事实来源):pass=绿 / fail=红 / neutral=灰。 */
verdict: HealthCheck;
/** verdict === "pass" 的便捷布尔。 */
ok: boolean;
/** 失败原因汇总(仅 fail 项,`; ` 分隔);无失败为 null。 */
error?: string | null;
/** 账单/用量结论;null/undefined = 未执行。 */
usage?: CheckResult | null;
/** 推理端点探活结论;探活是必做项,恒为 Some。 */
probe?: CheckResult | null;
/** 探活耗时(毫秒)。 */
latencyMs: number;
}
/**
* 单条 Rust 错误文案 → 本地化文案。未知错误原样透出(HTTP 状态码等诊断
* 细节)。前缀与 src-tauri/src/services/probe.rs、commands.rs 保持一致。
*/
function localizeHealthPart(error: string, t: TranslateFn): string {
if (error.startsWith("missing base_url")) return t("healthErrNoBaseUrl");
if (error.startsWith("no API key configured")) return t("healthErrNoKey");
if (error.startsWith("Authentication failed")) return t("healthErrAuth");
if (error.startsWith("endpoint not found")) return t("healthErrNotFound");
if (error.startsWith("request timed out")) return t("healthErrTimeout");
if (error.startsWith("connection failed")) return t("healthErrUnreachable");
if (error.startsWith("response is not valid JSON")) return t("healthErrBadJson");
// 账单链路的报错复用已有的用量错误映射(禁用 / OAuth 过期 / NewAPI 凭据…)。
return localizeUsageError(error, t);
}
/**
* 体检结论里的错误文案本地化。探活与账单两条链路的错误由 Rust 用 `; `
* 拼进同一个字段,逐段映射后再拼回,保证两段都能翻译。
*/
export function localizeHealthError(error: string, t: TranslateFn): string {
return error
.split("; ")
.map((part) => localizeHealthPart(part, t))
.join("; ");
}
/** 探活一项的 tooltip 文案。 */
export function probeLine(
probe: CheckResult,
latencyMs: number,
t: TranslateFn
): string {
if (probe.state === "pass") return t("healthProbeOk", { latency: latencyMs });
// 中性 = 端点未实现 /v1/models(404),如实说明「不支持」而不是报失败。
if (probe.state === "neutral") return t("healthProbeUnsupported");
return t("healthProbeFail", {
error: probe.error ? localizeHealthError(probe.error, t) : "",
});
}
/** 账单一项的 tooltip 文案。 */
export function usageLine(usage: CheckResult, t: TranslateFn): string {
if (usage.state === "pass") return t("healthUsageOk");
if (usage.state === "fail") {
return t("healthUsageFail", {
error: usage.error ? localizeHealthError(usage.error, t) : "",
});
}
// 账单链路不产生中性结论(只有探活 404 会),兜底按「未判定」展示。
return t("healthNotChecked");
}
/**
* 状态点颜色:绿=正常、红=失败、灰=未判定(未检测 / 检测中 / 全部中性)。
* 判定本身由 Rust 的 overall_verdict 给出,这里只做映射。
*/
export function healthDotClass(verdict: HealthCheck): string {
switch (verdict) {
case "pass":
return "bg-green-500";
case "fail":
return "bg-red-500";
default:
return "bg-gray-400 dark:bg-gray-500";
}
}
export interface HealthCheckState {
/** 体检进行中:按钮转圈、尚未出结果的行显示灰色检测中指示。 */
running: boolean;
/** 至少跑过一轮(决定是否渲染状态点)。 */
ran: boolean;
/** provider name → 结果。 */
results: Record<string, HealthResult>;
okCount: number;
failCount: number;
/** 整体请求失败(配置读取失败 / 总超时)的文案;null = 无错误。 */
error: string | null;
run: () => void;
}
/**
* 一键体检:invoke `health_check_all`,把结果按 provider 名索引。
* 结果保留到下次点击或组件卸载(页面刷新),不在后台轮询。
*/
export function useHealthCheck(agent: Agent): HealthCheckState {
const [running, setRunning] = useState(false);
const [ran, setRan] = useState(false);
const [results, setResults] = useState<Record<string, HealthResult>>({});
const [error, setError] = useState<string | null>(null);
// Generation counter: 重跑/卸载后到达的旧响应直接丢弃,不写回状态。
const genRef = useRef(0);
useEffect(
() => () => {
genRef.current += 1;
},
[]
);
const run = useCallback(async () => {
const gen = ++genRef.current;
setRunning(true);
setError(null);
// 清空上一轮结果:检测中的行显示灰色转圈,而不是留着旧结论误导。
setResults({});
try {
const list = await invoke<HealthResult[]>("health_check_all", { agent });
if (genRef.current !== gen) return;
const map: Record<string, HealthResult> = {};
for (const r of list) map[r.providerName] = r;
setResults(map);
} catch (e) {
if (genRef.current !== gen) return;
// invoke 拒绝时 Rust 侧 Err(String) 是字符串,不是 Error 实例。
setError(typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
} finally {
if (genRef.current === gen) {
setRunning(false);
setRan(true);
}
}
}, [agent]);
const values = Object.values(results);
return {
running,
ran,
results,
// 中性(灰)既不算正常也不算失败,不计入汇总。
okCount: values.filter((r) => r.verdict === "pass").length,
failCount: values.filter((r) => r.verdict === "fail").length,
error,
run: () => void run(),
};
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isNewerVersion } from "./useKimiCodeVersion";
describe("isNewerVersion", () => {
it("同版本不算有更新", () => {
expect(isNewerVersion("2.0.2", "2.0.2")).toBe(false);
expect(isNewerVersion("v2.0.2", "2.0.2")).toBe(false);
});
it("按 major/minor/patch 逐段比较", () => {
expect(isNewerVersion("2.0.2", "2.0.1")).toBe(true);
expect(isNewerVersion("2.1.0", "2.0.9")).toBe(true);
expect(isNewerVersion("3.0.0", "2.99.99")).toBe(true);
expect(isNewerVersion("2.0.1", "2.0.2")).toBe(false);
expect(isNewerVersion("1.9.9", "2.0.0")).toBe(false);
});
it("忽略 v 前缀与预发布后缀", () => {
expect(isNewerVersion("v0.44.0-beta.1", "0.43.1")).toBe(true);
expect(isNewerVersion("0.43.1", "0.43.1-rc.2")).toBe(false);
});
it("缺位补 0,无法解析时不显示更新", () => {
expect(isNewerVersion("2.1", "2.0.9")).toBe(true);
expect(isNewerVersion("abc", "2.0.1")).toBe(false);
expect(isNewerVersion("2.0.2", "abc")).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
/** kimi-code 与本应用的适配状态(Rust 侧基线表决定)。 */
export type KimiCodeStatus =
| "current"
| "outdated"
| "partial"
| "unknown"
| "not_installed";
export interface KimiCodeVersionInfo {
/** 本机安装的 kimi-code 版本;未安装 / 检测失败为 null。 */
installed: string | null;
/** 上游最新 release tag(已去掉 v 前缀);查询失败为 null。 */
latest: string | null;
status: KimiCodeStatus;
}
const THROTTLE_KEY = "kimi-switch-last-kc-version-check";
// 与 useUpdateCheck 一致:节流窗口与定时复查同为 8h,长时间挂着的窗口也能
// 自动拿到新版本,同时避免短时间反复触发后端调用。
const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours
/** 解析 major.minor.patch 三元组;缺位补 0,无法解析返回 null。 */
function versionTriple(version: string): [number, number, number] | null {
const core = version.trim().replace(/^[vV]/, "").split(/[-+]/)[0];
const nums = core.split(".").slice(0, 3).map((p) => Number.parseInt(p.trim(), 10));
if (nums.length === 0 || nums.some((n) => !Number.isFinite(n) || n < 0)) return null;
while (nums.length < 3) nums.push(0);
return [nums[0], nums[1], nums[2]];
}
/** latest 是否严格高于 installed(用于决定是否显示 releases 链接)。 */
export function isNewerVersion(latest: string, installed: string): boolean {
const l = versionTriple(latest);
const i = versionTriple(installed);
if (!l || !i) return false;
for (let k = 0; k < 3; k += 1) {
if (l[k] !== i[k]) return l[k] > i[k];
}
return false;
}
/**
* 读取「本机 kimi-code 版本 / 上游最新版 / 适配状态」,模式与 useUpdateCheck
* 相同:挂载时按 8h 节流自动查一次,之后定时复查,checkNow 强制刷新。
*/
export function useKimiCodeVersion() {
const [info, setInfo] = useState<KimiCodeVersionInfo | null>(null);
const [checking, setChecking] = useState(false);
const [lastChecked, setLastChecked] = useState<number | null>(() => {
try {
const v = localStorage.getItem(THROTTLE_KEY);
return v ? Number(v) : null;
} catch {
return null;
}
});
const doCheck = useCallback(
async (force: boolean) => {
if (!force) {
if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return;
}
setChecking(true);
try {
const next = await invoke<KimiCodeVersionInfo>("get_kimi_code_version_info");
setInfo(next);
const now = Date.now();
setLastChecked(now);
try {
localStorage.setItem(THROTTLE_KEY, String(now));
} catch {
// ignore
}
} catch {
// 静默失败:检测不到 kimi / 网络不通都不该打扰用户,手动检查仍可用。
} finally {
setChecking(false);
}
},
[lastChecked]
);
// 定时器不随 lastChecked 重建,所以用 ref 持有最新的 doCheck。
const doCheckRef = useRef(doCheck);
useEffect(() => {
doCheckRef.current = doCheck;
}, [doCheck]);
useEffect(() => {
doCheckRef.current(false);
const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
const checkNow = useCallback(() => doCheck(true), [doCheck]);
return { info, checking, checkNow, lastChecked };
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { isAlertTier, type UsageData } from "./useUsageQuery";
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
function percentTier(used: number): UsageData {
return { planName: "five_hour", used, total: 100, remaining: 100 - used, unit: "%" };
}
describe("isAlertTier", () => {
it("阈值缺省或 0(关闭)时不告警", () => {
expect(isAlertTier(percentTier(100), undefined)).toBe(false);
expect(isAlertTier(percentTier(100), null)).toBe(false);
expect(isAlertTier(percentTier(100), 0)).toBe(false);
});
it("低于阈值不告警", () => {
expect(isAlertTier(percentTier(79), 80)).toBe(false);
expect(isAlertTier(percentTier(79.9), 80)).toBe(false);
});
it("等于或超过阈值告警", () => {
expect(isAlertTier(percentTier(80), 80)).toBe(true);
expect(isAlertTier(percentTier(80.5), 80)).toBe(true);
expect(isAlertTier(percentTier(100), 80)).toBe(true);
});
it("非 % unit 的行不告警(金额行的 used 是钱数)", () => {
// Kimi 加力钱包:used = monthlyUsedCents/100,与百分比阈值不可比。
expect(
isAlertTier(
{ planName: "booster_wallet", used: 8, total: 50, remaining: 12.34, unit: "USD" },
5
)
).toBe(false);
// sub2api 的 USD 配额行同理,不参与百分比预警。
expect(
isAlertTier({ planName: "daily_limit", used: 90, total: 100, unit: "USD" }, 50)
).toBe(false);
});
it("used 缺失时不告警", () => {
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
});
});
+30 -1
View File
@@ -56,6 +56,23 @@ export interface UsageQueryState {
updatedAt: number | null;
refresh: (force: boolean) => void;
supported: boolean;
/** 生效的预警阈值(0/缺失已归一为 undefined);footer 用它判定单行高亮。 */
threshold?: number;
/** 查询成功且任一百分比行 used ≥ threshold。失败/加载中恒为 false, */
/** 所以失败时保留的上次数据不会被误高亮。 */
alert: boolean;
}
/**
* 单行是否触发预警。只判定百分比行(unit === "%"):余额/钱包等金额行的
* used 是钱数,与百分比阈值不可比。threshold 缺失或 ≤0(关闭)时恒为 false。
*/
export function isAlertTier(
d: UsageData,
threshold?: number | null
): boolean {
if (threshold == null || threshold <= 0) return false;
return d.unit === "%" && d.used != null && d.used >= threshold;
}
export function useUsageQuery(
@@ -63,7 +80,8 @@ export function useUsageQuery(
providerName: string,
usageKinds?: string[],
autoIntervalMinutes?: number,
disabled?: boolean
disabled?: boolean,
threshold?: number
): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider
@@ -175,6 +193,15 @@ export function useUsageQuery(
return () => clearInterval(id);
}, [supported, autoIntervalMinutes, runQuery, disabled]);
// 阈值 0/负数/非有限值一律视为关闭(与配置面板「0 或空 = 关闭」一致)。
// 每次查询结果写入 data 后此处重新求值,因此刷新后预警自动出现/消除。
const alertThreshold =
typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
? threshold
: undefined;
const alert =
status === "success" && data.some((d) => isAlertTier(d, alertThreshold));
return {
status,
data,
@@ -182,5 +209,7 @@ export function useUsageQuery(
updatedAt,
refresh: runQuery,
supported,
threshold: alertThreshold,
alert,
};
}