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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user