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
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:
1 parent
35581103ed
commit
fcd08eae32
35 files changed
+2567
-91
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user