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

+27
View File
@@ -78,6 +78,10 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
update({ background: { ...settings.background, ...patch } });
};
const updateWatch = (patch: Partial<AgentSettings["watch"]>) => {
update({ watch: { ...settings.watch, ...patch } });
};
const setRules = (rules: PermissionRule[]) => {
update({ permission: { rules } });
};
@@ -141,6 +145,15 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
value={settings.loop_control?.reserved_context_size ?? 50000}
onChange={(v) => updateLoopControl({ reserved_context_size: v })}
/>
{/* kimi-code 0.43.0+ `loop_control.compaction_max_attempts`. The
upstream default is 5; an empty field or 0 removes the key instead
of writing a literal 0 (see setAgentSettings). */}
<NumberField
label={t("compactionMaxAttempts")}
value={settings.loop_control?.compaction_max_attempts ?? 5}
onChange={(v) => updateLoopControl({ compaction_max_attempts: v })}
/>
<p className="text-xs text-content-muted">{t("compactionMaxAttemptsHint")}</p>
</Card>
<Card title={t("backgroundSettings")}>
@@ -156,6 +169,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
/>
</Card>
<Card title={t("watchSettings")}>
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since
2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env
var is probed by get_experimental_env_status as a non-flag entry
but deliberately locks nothing here, the toggle just documents the
config value that applies when the env var is unset. */}
<Checkbox
label={t("watchEnabled")}
checked={settings.watch?.enabled ?? false}
onChange={(checked) => updateWatch({ enabled: checked })}
/>
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
</Card>
<Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
+74 -14
View File
@@ -11,6 +11,7 @@ import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog";
import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker";
import { Segmented } from "./ui/controls";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [
@@ -127,6 +128,19 @@ export function ProviderEdit({
// Preset this provider was created from (if any) — provides the
// "Get API Key" / referral links shown under the key input.
const preset = findPresetForProvider(provider);
/**
* Credential source. `api_key_env` (kimi-code 2.0.0+) names an environment
* variable instead of storing the key in config.toml; the two are mutually
* exclusive upstream, so the UI is a mode switch and each mode clears the
* other field. Derived from the provider itself (no local state) so the
* switch follows provider switches and reloads; an empty-but-present value
* ("env mode picked, name not typed yet") still reads as env mode.
* Only Kimi Code understands `api_key_env` — Pi's provider file has no
* equivalent, so the switch and the env input are both gated on the agent
* (a Pi provider that somehow carries the key keeps its plain key input).
*/
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
@@ -322,27 +336,73 @@ export function ProviderEdit({
{!provider.managed && (
<div>
{apiKeyEnvSupported && (
<div className="flex items-center gap-3 mb-2">
<span className="text-sm text-content-muted">
{t("apiKeySource")}
</span>
<Segmented
options={[
{ key: "direct", label: t("apiKeySourceDirect") },
{ key: "env", label: t("apiKeySourceEnv") },
]}
value={apiKeyEnvMode ? "env" : "direct"}
onChange={(mode) => {
// Mutual exclusion: picking a source clears the
// other one so export never writes both keys.
if (mode === "env") {
onChange({
...provider,
api_key: null,
api_key_env: provider.api_key_env ?? "",
});
} else {
onChange({ ...provider, api_key_env: null });
}
}}
/>
</div>
)}
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")} <span className="text-red-500">*</span>
{apiKeyEnvMode ? t("apiKeyEnvName") : t("apiKey")}{" "}
<span className="text-red-500">*</span>
</label>
<div className="relative">
{apiKeyEnvMode ? (
<input
id={apiKeyId}
type={showApiKey ? "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={provider.api_key || ""}
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key_env ?? ""}
placeholder="KIMI_API_KEY"
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
onChange({ ...provider, api_key_env: e.target.value })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
) : (
<div className="relative">
<input
id={apiKeyId}
type={showApiKey ? "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={provider.api_key || ""}
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
)}
{apiKeyEnvMode && (
<p className="mt-1.5 text-xs text-content-muted">
{t("apiKeyEnvHint")}
</p>
)}
{(() => {
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
if (!linkUrl) return null;
+133 -23
View File
@@ -1,10 +1,26 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
import {
Pencil,
Copy,
Activity,
Loader2,
Trash2,
BarChart3,
Stethoscope,
} from "lucide-react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter";
import { useUsageQuery } from "../hooks/useUsageQuery";
import {
useHealthCheck,
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type HealthResult,
} from "../hooks/useHealthCheck";
import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult {
@@ -40,6 +56,12 @@ interface ProviderCardProps {
models: Record<string, Model>;
/** Inline connectivity-test state for this provider, or undefined when idle. */
ts: TestState | undefined;
/** 一键体检结论;undefined = 本轮未出结果(检测中或未检测)。 */
health: HealthResult | undefined;
/** 体检请求进行中。 */
healthRunning: boolean;
/** 体检至少跑过一轮(决定是否渲染灰色「未检测」点)。 */
healthRan: boolean;
onEdit: (name: string) => void;
onDelete: (name: string) => void;
onDuplicate: (name: string) => void;
@@ -54,6 +76,9 @@ function ProviderCard({
defaultModel,
models,
ts,
health,
healthRunning,
healthRan,
onEdit,
onDelete,
onDuplicate,
@@ -70,7 +95,8 @@ function ProviderCard({
provider.name,
provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false
provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold
);
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
@@ -80,6 +106,17 @@ function ProviderCard({
: null;
const isActive = provider.active === true;
// 一键体检状态点 tooltip:两项检测各自如实展示——探活可能是「不支持模型
// 列表」(中性),账单可能是「失败: xxx」,都不掩盖。
const healthTip = health
? [
health.probe ? probeLine(health.probe, health.latencyMs, t) : null,
health.usage ? usageLine(health.usage, t) : null,
]
.filter((s): s is string => s !== null)
.join(" · ")
: "";
return (
<div
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
@@ -102,6 +139,34 @@ function ProviderCard({
<h3 className="font-semibold text-content-primary truncate">
{provider.name}
</h3>
{/* 一键体检状态点:绿=正常、红=失败、灰=未判定(未检测 / 检测中) */}
{health ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
health.verdict
)}`}
title={healthTip}
aria-label={healthTip}
/>
) : healthRunning ? (
<span
className="shrink-0 flex items-center"
title={t("healthCheckRunning")}
aria-label={t("healthCheckRunning")}
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-content-muted" />
</span>
) : healthRan ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
"neutral"
)}`}
title={t("healthNotChecked")}
aria-label={t("healthNotChecked")}
/>
) : null}
{isActive && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
{t("inUse")}
@@ -273,6 +338,7 @@ export function ProviderList({
}: ProviderListProps) {
const { t } = useTranslation();
const [testState, setTestState] = useState<Record<string, TestState>>({});
const health = useHealthCheck(agent);
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
const sortedProviders = [...providers].sort(
@@ -312,28 +378,69 @@ export function ProviderList({
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="font-medium text-content-primary">{t("providers")}</h2>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<h2 className="font-medium text-content-primary shrink-0">
{t("providers")}
</h2>
{/* 体检汇总:检测中 / 请求失败 / x 正常 / y 失败 */}
{(health.running || health.ran || health.error) && (
<span
className={`text-xs tabular-nums truncate ${
health.error ? "text-red-500 dark:text-red-400" : "text-content-muted"
}`}
title={health.error ? localizeHealthError(health.error, t) : undefined}
>
{health.error
? t("healthRequestFailed", {
error: localizeHealthError(health.error, t),
})
: health.running
? t("healthCheckRunning")
: t("healthSummary", {
ok: health.okCount,
fail: health.failCount,
})}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={health.run}
disabled={health.running}
title={t("healthCheckHint")}
aria-label={t("healthCheck")}
className="flex items-center gap-1.5 h-9 px-3 rounded-full border border-border text-sm text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors whitespace-nowrap"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{health.running ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Stethoscope className="w-4 h-4" />
)}
{t("healthCheck")}
</button>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
@@ -371,6 +478,9 @@ export function ProviderList({
defaultModel={defaultModel}
models={models}
ts={testState[provider.name]}
health={health.results[provider.name]}
healthRunning={health.running}
healthRan={health.ran}
onEdit={onEdit}
onDelete={onDelete}
onDuplicate={onDuplicate}
+120
View File
@@ -5,12 +5,20 @@ import { listen } from "@tauri-apps/api/event";
import { useTranslation, type Language } from "../i18n";
import { useTheme, type Theme } from "../hooks/useTheme";
import type { UpdateInfo } from "../hooks/useUpdateCheck";
import {
useKimiCodeVersion,
isNewerVersion,
type KimiCodeStatus,
} from "../hooks/useKimiCodeVersion";
function formatVersion(version: string): string {
const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
}
/** 上游 kimi-code releases 页面。 */
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
@@ -30,6 +38,11 @@ export function SettingsModal({
}: SettingsModalProps) {
const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme();
const {
info: kimiCodeInfo,
checking: kimiCodeChecking,
checkNow: checkKimiCode,
} = useKimiCodeVersion();
const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
@@ -158,6 +171,47 @@ export function SettingsModal({
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
};
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
const kimiCodeBadge = (status: KimiCodeStatus) => {
switch (status) {
case "current":
return {
label: t("kimiCodeStatusCurrent"),
className:
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
};
case "outdated":
return {
label: t("kimiCodeStatusOutdated"),
className:
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
};
case "partial":
return {
label: t("kimiCodeStatusPartial"),
className:
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
};
case "unknown":
return {
label: t("kimiCodeStatusUnknown"),
className: "bg-hover-2 text-content-muted border-border",
};
default:
return {
label: t("kimiCodeStatusNotInstalled"),
className: "bg-hover-2 text-content-muted border-border",
};
}
};
const kimiCodeUpdateAvailable =
!!kimiCodeInfo?.installed &&
!!kimiCodeInfo?.latest &&
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@@ -362,6 +416,72 @@ export function SettingsModal({
</div>
</section>
{/* Kimi Code CLI version & compatibility */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("kimiCodeSection")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
{kimiCodeBadgeInfo ? (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
>
{kimiCodeBadgeInfo.label}
</span>
) : (
<span className="text-xs text-content-muted">—</span>
)}
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={checkKimiCode}
disabled={kimiCodeChecking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={kimiCodeChecking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
</button>
{kimiCodeUpdateAvailable && (
<button
type="button"
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiCodeOpenReleases")}
</button>
)}
</div>
</div>
</section>
{/* About */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
+26 -1
View File
@@ -364,7 +364,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
)}
</div>
{/* timeout + auto query interval */}
{/* timeout + auto query interval + alert threshold */}
<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>
@@ -394,6 +394,31 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
/>
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageThreshold")} %</label>
<input
type="number"
min={0}
max={100}
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.threshold ?? ""}
onChange={(e) => {
const raw = e.target.value.trim();
// 清空输入框写回 undefined(关闭),而不是 0:两者判定等价,
// 但 undefined 会被 serde 的 skip_serializing_if 从 SQLite
// JSON 里省掉,不留冗余字段。
if (raw === "") {
setCfg({ ...cfg, threshold: undefined });
return;
}
const n = parseInt(raw, 10);
// 非数字(如 "1e" / "-")不落库,保留上一次的有效值。
if (Number.isNaN(n)) return;
setCfg({ ...cfg, threshold: Math.min(100, Math.max(0, n)) });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageThresholdHint")}</p>
</div>
</div>
</>
)}
+38 -11
View File
@@ -1,7 +1,13 @@
import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import {
BOOSTER_WALLET_PLAN,
formatAmount,
localizeUsageError,
planLabel,
} from "../lib/usage-display";
import { isAlertTier } from "../hooks/useUsageQuery";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps {
@@ -14,6 +20,20 @@ interface UsageFooterProps {
variant?: "detail" | "compact";
}
/**
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
* 多少」,按 unit 判定会把它误渲染成余额。
*/
function isMoneyRow(d: UsageData): boolean {
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
}
function isPlanRow(d: UsageData): boolean {
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
}
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
const { t } = useTranslation();
const { status, data, error, updatedAt, supported, refresh } = usage;
@@ -85,10 +105,7 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
// 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);
const isPlan = isPlanRow(d);
if (d.isValid === false) {
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
}
@@ -207,13 +224,14 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
{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 isPlan = isPlanRow(d);
const pct = isPlan ? percentOf(d) : null;
const color =
pct == null
// 预警高亮:usage.alert 已按「查询成功」门控,失败时保留的上次数据
// 不会被误标红(此处是唯一的高亮来源,随每次查询结果重算)。
const rowAlert = usage.alert && isAlertTier(d, usage.threshold);
const color = rowAlert
? "text-red-500 dark:text-red-400"
: pct == null
? "text-content-muted"
: pct < 70
? "text-green-600 dark:text-green-400"
@@ -248,6 +266,15 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
>
{main}
</span>
{rowAlert && (
<span
className="shrink-0 text-red-500 dark:text-red-400"
title={t("usageThresholdAlert")}
aria-label={t("usageThresholdAlert")}
>
⚠
</span>
)}
{resetText && (
<span className="text-content-muted shrink-0">{resetText}</span>
)}
+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,
};
}
+49
View File
@@ -43,6 +43,26 @@ export const enTranslations: Record<TranslationKey, string> = {
connectivityOk: "{name} reachable ({latency}ms)",
connectivitySlow: "{name} reachable but slow ({latency}ms)",
connectivityFail: "{name} unreachable: {error}",
// Health check (ProviderList toolbar button + per-row status dot)
healthCheck: "Health check",
healthCheckHint:
"Probe every enabled provider's inference endpoint and billing query in parallel",
healthCheckRunning: "Checking…",
healthSummary: "{ok} OK / {fail} failed",
healthProbeOk: "Endpoint OK ({latency}ms)",
healthProbeUnsupported: "Endpoint exposes no model list (404)",
healthProbeFail: "Endpoint failed: {error}",
healthUsageOk: "Billing OK",
healthUsageFail: "Billing failed: {error}",
healthNotChecked: "Not checked",
healthRequestFailed: "Health check request failed: {error}",
healthErrNoBaseUrl: "Missing base URL",
healthErrNoKey: "No API key configured",
healthErrAuth: "Authentication failed",
healthErrNotFound: "Endpoint not found",
healthErrTimeout: "Connection timed out",
healthErrUnreachable: "Cannot connect (DNS failure or connection refused)",
healthErrBadJson: "Response is not valid JSON",
copiedProvider: "Duplicated as {name}",
// Provider edit
@@ -78,6 +98,12 @@ export const enTranslations: Record<TranslationKey, string> = {
authField: "Auth Field",
apiKey: "API Key",
getApiKeyLink: "Get API Key",
apiKeySource: "Credential source",
apiKeySourceDirect: "Enter key",
apiKeySourceEnv: "Environment variable",
apiKeyEnvName: "Variable name",
apiKeyEnvHint:
"Only the variable name (api_key_env) is written; Kimi Code reads the secret from its environment. The two sources are mutually exclusive, so switching clears the other.",
show: "Show",
hide: "Hide",
requestUrl: "Request URL",
@@ -142,9 +168,16 @@ export const enTranslations: Record<TranslationKey, string> = {
loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step",
reservedContextSize: "Reserved context size",
compactionMaxAttempts: "Max compaction attempts",
compactionMaxAttemptsHint:
"Retry limit after a failed context compaction. Empty or 0 leaves the key out of the config (upstream default 5).",
backgroundSettings: "Background Tasks",
maxRunningTasks: "Max running tasks",
keepAliveOnExit: "Keep alive on exit",
watchSettings: "File Watching",
watchEnabled: "Enable file watching",
watchEnabledDesc:
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, off by default since 2.0.2). The KIMI_CODE_WATCH environment variable overrides this setting.",
permissionRules: "Permission Rules",
permissionDecision: "Decision",
permissionPattern: "Pattern",
@@ -283,6 +316,18 @@ export const enTranslations: Record<TranslationKey, string> = {
install: "Install",
updateCheckFailed: "Update check failed",
lastChecked: "Last checked",
// Kimi Code version tracking
kimiCodeSection: "Kimi Code version",
kimiCodeInstalled: "Installed",
kimiCodeLatest: "Latest",
kimiCodeStatusLabel: "Compatibility",
kimiCodeStatusCurrent: "Supported",
kimiCodeStatusOutdated: "Update KimiSwitch recommended",
kimiCodeStatusPartial: "Partially compatible",
kimiCodeStatusUnknown: "Unknown version",
kimiCodeStatusNotInstalled: "Not installed",
kimiCodeCheck: "Check",
kimiCodeOpenReleases: "Open releases",
appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site",
@@ -450,6 +495,10 @@ export const enTranslations: Record<TranslationKey, string> = {
usageTierWeekly: "Weekly",
usageTierMonthly: "Monthly",
usageTierMonthCode: "Monthly (code)",
usageTierBoosterWallet: "Booster",
usageThreshold: "Alert threshold",
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
usageThresholdAlert: "Threshold reached",
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",
+47
View File
@@ -41,6 +41,25 @@ export const zhTranslations = {
connectivityOk: "{name} 连通正常({latency}ms)",
connectivitySlow: "{name} 连通较慢({latency}ms)",
connectivityFail: "{name} 无法连通:{error}",
// 一键体检(ProviderList 工具栏按钮 + 行内状态点)
healthCheck: "一键体检",
healthCheckHint: "并行检测所有启用供应商的推理端点与账单查询",
healthCheckRunning: "体检中…",
healthSummary: "{ok} 正常 / {fail} 失败",
healthProbeOk: "端点正常({latency}ms)",
healthProbeUnsupported: "端点不支持模型列表(404)",
healthProbeFail: "端点失败:{error}",
healthUsageOk: "账单正常",
healthUsageFail: "账单失败:{error}",
healthNotChecked: "未检测",
healthRequestFailed: "体检请求失败:{error}",
healthErrNoBaseUrl: "缺少 base_url",
healthErrNoKey: "未配置 API Key",
healthErrAuth: "鉴权失败",
healthErrNotFound: "端点不存在",
healthErrTimeout: "连接超时",
healthErrUnreachable: "无法连接(DNS 解析失败或连接被拒绝)",
healthErrBadJson: "响应不是合法 JSON",
copiedProvider: "已复制为 {name}",
// Provider edit
@@ -76,6 +95,12 @@ export const zhTranslations = {
authField: "认证字段",
apiKey: "API Key",
getApiKeyLink: "获取 API Key",
apiKeySource: "凭证来源",
apiKeySourceDirect: "直接填写",
apiKeySourceEnv: "环境变量",
apiKeyEnvName: "环境变量名",
apiKeyEnvHint:
"只写入变量名(api_key_env),密钥由 Kimi Code 从运行环境读取;两者互斥,切换后会清空另一种。",
show: "显示",
hide: "隐藏",
requestUrl: "请求地址",
@@ -140,9 +165,15 @@ export const zhTranslations = {
loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数",
reservedContextSize: "上下文预留大小",
compactionMaxAttempts: "压缩最大尝试次数",
compactionMaxAttemptsHint: "上下文压缩失败后的重试上限,留空或 0 表示不写入该项(上游默认 5)。",
backgroundSettings: "后台任务",
maxRunningTasks: "最大并发数",
keepAliveOnExit: "退出时保持运行",
watchSettings: "文件监听",
watchEnabled: "启用文件监听",
watchEnabledDesc:
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
permissionRules: "权限规则",
permissionDecision: "处置",
permissionPattern: "模式",
@@ -277,6 +308,18 @@ export const zhTranslations = {
install: "安装",
updateCheckFailed: "检查更新失败",
lastChecked: "上次检查",
// Kimi Code 版本跟踪
kimiCodeSection: "Kimi Code 版本",
kimiCodeInstalled: "已安装版本",
kimiCodeLatest: "最新版本",
kimiCodeStatusLabel: "适配状态",
kimiCodeStatusCurrent: "已适配",
kimiCodeStatusOutdated: "建议升级 KimiSwitch",
kimiCodeStatusPartial: "部分兼容",
kimiCodeStatusUnknown: "未知版本",
kimiCodeStatusNotInstalled: "未安装",
kimiCodeCheck: "检查",
kimiCodeOpenReleases: "打开 Releases",
appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站",
@@ -444,6 +487,10 @@ export const zhTranslations = {
usageTierWeekly: "7天",
usageTierMonthly: "30天",
usageTierMonthCode: "30天(代码)",
usageTierBoosterWallet: "加力钱包",
usageThreshold: "预警阈值",
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
usageThresholdAlert: "已达预警阈值",
usageErrNoKey: "未配置 API Key",
usageErrDisabled: "用量查询已在配置面板中停用",
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
+96 -2
View File
@@ -134,10 +134,104 @@ describe("loop_control — v1/v2 key handling", () => {
});
// ---------------------------------------------------------------------------
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means
// upstream default ON; an explicit false must survive even with no rules
// loop_control.compaction_max_attempts (kimi-code 0.43.0) — read via the loop
// spread, written only when set, and 0/empty removes the key (upstream
// default 5 applies)
// ---------------------------------------------------------------------------
describe("loop_control.compaction_max_attempts", () => {
it("reads an existing value through the loop spread", () => {
expect(
getAgentSettings({ loop_control: { compaction_max_attempts: 7 } })
.loop_control?.compaction_max_attempts
).toBe(7);
});
it("reads as undefined when the key is absent (UI falls back to 5)", () => {
expect(
getAgentSettings({ loop_control: {} }).loop_control?.compaction_max_attempts
).toBeUndefined();
});
it("writes the value when the patch sets it", () => {
const next = setAgentSettings({}, {
loop_control: { compaction_max_attempts: 8 },
});
expect(loopOf(next).compaction_max_attempts).toBe(8);
});
it("drops the key for 0 and for a cleared (undefined) field", () => {
const raw = { loop_control: { compaction_max_attempts: 6 } };
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: 0 },
}))).not.toHaveProperty("compaction_max_attempts");
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: undefined },
}))).not.toHaveProperty("compaction_max_attempts");
});
it("does not materialize the upstream default on an unrelated save", () => {
// An absent key must stay absent — a plain save must not pin `5`.
const next = setAgentSettings({ thinking: { effort: "high" } }, {});
expect(loopOf(next)).not.toHaveProperty("compaction_max_attempts");
// An existing value survives a save that does not touch it.
const kept = setAgentSettings(
{ loop_control: { compaction_max_attempts: 4 } },
{ thinking: { effort: "low" } }
);
expect(loopOf(kept).compaction_max_attempts).toBe(4);
});
});
// ---------------------------------------------------------------------------
// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since
// 2.0.2; mirrors the [background] handling
// ---------------------------------------------------------------------------
function watchOf(raw: unknown): Record<string, unknown> {
const w = (raw as { watch?: unknown })?.watch;
return w && typeof w === "object" && !Array.isArray(w)
? (w as Record<string, unknown>)
: {};
}
describe("[watch] enabled", () => {
it("reads as false when the section or key is absent (2.0.2 default)", () => {
expect(getAgentSettings({}).watch?.enabled).toBe(false);
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(false);
});
it("reads an explicit value", () => {
expect(getAgentSettings({ watch: { enabled: true } }).watch?.enabled).toBe(true);
expect(getAgentSettings({ watch: { enabled: false } }).watch?.enabled).toBe(false);
});
it("writes the top-level [watch] section", () => {
const next = setAgentSettings({}, { watch: { enabled: true } }) as {
watch?: Record<string, unknown>;
};
expect(next.watch).toEqual({ enabled: true });
});
it("keeps the section on saves that do not touch it", () => {
const next = setAgentSettings(
{ watch: { enabled: true } },
{ thinking: { effort: "high" } }
) as { watch?: Record<string, unknown> };
expect(next.watch).toEqual({ enabled: true });
});
it("does not disturb the other managed sections", () => {
const next = setAgentSettings(
{ background: { max_running_tasks: 2 } },
{ watch: { enabled: true } }
) as { background?: Record<string, unknown> };
expect(next.background?.max_running_tasks).toBe(2);
expect(watchOf(next).enabled).toBe(true);
});
});
describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => {
expect(
+17
View File
@@ -13,6 +13,11 @@ const DEFAULT_SETTINGS: AgentSettings = {
background: {
keep_alive_on_exit: false,
},
// `[watch] enabled` — kimi-code 2.0.1 added the key, 2.0.2 flipped the
// default to off, so an absent key reads as "off".
watch: {
enabled: false,
},
permission: { rules: [] },
hooks: [],
};
@@ -76,6 +81,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"),
},
watch: {
...DEFAULT_SETTINGS.watch,
...getSection<AgentSettings["watch"]>(rawOther, "watch"),
},
permission: {
rules: sectionPermission?.rules ?? [],
// `dangerous_command_guard` stays undefined unless the config carries
@@ -111,6 +120,7 @@ export function setAgentSettings(
thinking: { ...current.thinking, ...patch.thinking },
loop_control: { ...current.loop_control, ...patch.loop_control },
background: { ...current.background, ...patch.background },
watch: { ...current.watch, ...patch.watch },
permission,
hooks: patch.hooks ?? current.hooks ?? [],
};
@@ -129,9 +139,16 @@ export function setAgentSettings(
if (!opts?.legacyV1) {
delete next.loop_control?.max_retries_per_step;
}
// `compaction_max_attempts` (kimi-code 0.43.0+): 0 / undefined means "leave
// the key out" — the config then falls back to the upstream default (5)
// instead of pinning a literal 0.
if (!next.loop_control?.compaction_max_attempts) {
delete next.loop_control?.compaction_max_attempts;
}
if (next.thinking) root.thinking = next.thinking;
if (next.loop_control) root.loop_control = next.loop_control;
if (next.background) root.background = next.background;
if (next.watch) root.watch = next.watch;
// Keep `[permission]` when it carries rules *or* an explicit
// dangerous_command_guard — dropping the section would silently lose a
// guard=false write (absent key = upstream default on). An empty rules
+1
View File
@@ -59,6 +59,7 @@ describe("planLabel", () => {
expect(planLabel("five_hour", t)).toBe("usageTier5h");
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
expect(planLabel("NewAPI", t)).toBe("NewAPI");
expect(planLabel("¥", t)).toBe("¥");
});
+9
View File
@@ -5,6 +5,13 @@ type TranslateFn = (
vars?: Record<string, string | number>
) => string;
/**
* Kimi quota 的加力钱包行 id(src-tauri/src/services/coding_plan.rs 的
* PLAN_BOOSTER_WALLET)。它是金额行而非配额百分比行,planLabel() 与
* UsageFooter 的金额行判定共用此常量,避免字面量散落。
*/
export const BOOSTER_WALLET_PLAN = "booster_wallet";
/**
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
@@ -28,6 +35,8 @@ export function planLabel(name: string, t: TranslateFn): string {
return t("usageTierMonthly");
case "month_code":
return t("usageTierMonthCode");
case BOOSTER_WALLET_PLAN:
return t("usageTierBoosterWallet");
default:
return name;
}
+4 -1
View File
@@ -50,7 +50,10 @@ export function validateProviders(
const reasons: string[] = [];
const apiKey = provider.api_key?.trim();
if (!apiKey && !provider.managed) {
// kimi-code 2.0.0+: the credential may come from an environment variable
// instead of a stored key — a provider configured that way is complete.
const apiKeyEnv = provider.api_key_env?.trim();
if (!apiKey && !apiKeyEnv && !provider.managed) {
reasons.push(t("validationNoApiKey"));
}
+21
View File
@@ -15,6 +15,12 @@ export interface Provider {
provider_type: ProviderType;
base_url: string | null;
api_key: string | null;
/**
* Name of the process environment variable holding the credential
* (kimi-code 2.0.0+). Mutually exclusive with `api_key` upstream — the
* export writes only one of the two. Only the variable *name* is stored.
*/
api_key_env?: string | null;
env: Record<string, string>;
/** Optional note / remark for the provider. */
note?: string | null;
@@ -90,6 +96,9 @@ export interface UsageConfig {
autoQueryIntervalMinutes?: number;
/** Per-request timeout in seconds; 0/undefined = default (8s). */
timeoutSeconds?: number;
/** 用量预警阈值(百分比 0-100);0/undefined = 关闭预警。
* 判定见 src/hooks/useUsageQuery.ts 的 isAlertTier()。 */
threshold?: number;
}
/** A model discovered from a provider's API endpoint. */
@@ -125,6 +134,9 @@ export interface LoopControlConfig {
* KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */
max_retries_per_step?: number;
reserved_context_size?: number;
/** Retry budget for context compaction (kimi-code 0.43.0+). Absent/0 = the
* key is not written and upstream's default (5) applies. */
compaction_max_attempts?: number;
}
export interface BackgroundConfig {
@@ -132,6 +144,13 @@ export interface BackgroundConfig {
keep_alive_on_exit?: boolean;
}
/** Top-level `[watch]` section (kimi-code 2.0.1+). The key was added in
* 2.0.1 and its default flipped to off in 2.0.2, so an absent key reads as
* disabled. `KIMI_CODE_WATCH` overrides the config value at runtime. */
export interface WatchConfig {
enabled?: boolean;
}
export interface PermissionRule {
decision?: "allow" | "deny" | "ask";
scope?: string;
@@ -150,6 +169,8 @@ export interface AgentSettings {
thinking?: ThinkingConfig;
loop_control?: LoopControlConfig;
background?: BackgroundConfig;
/** Top-level `[watch]` section (kimi-code 2.0.1+). */
watch?: WatchConfig;
permission?: {
rules?: PermissionRule[];
/** kimi-code `[permission]` key. Default true when the key is