Compare commits

...
1 Commits
Author SHA1 Message Date
KimiSwitch Dev 769852c4b5 feat(flags): v0.7.8 — 实验开关同步 kimi-code 上游 7 旗标(tower/subagent_fork/wait_for/auto_session_title 新增,acp-v2 移除)+ 修复模型用量上下显示不同步(状态提升至卡片层)
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
2026-08-25 11:20:34 +08:00
14 changed files with 3031 additions and 1543 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.7", "version": "0.7.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+13
View File
@@ -0,0 +1,13 @@
## v0.7.8
### 修复:模型用量上下显示不同步
- 修复同一供应商卡片上,上方用量摘要(含 🔄 刷新按钮)刷新后、下方用量进度条仍停留在旧数据的问题(如上方 24% vs 下方 8%)
- 用量查询状态提升至卡片层统一持有:点刷新、自动轮询后,两处显示同帧同步更新
- 自动刷新间隔不再只挂在其中一处显示上,每卡片恰好一份定时器,行为不变
### 实验功能开关同步 kimi-code 最新旗标注册表
- 高级设置的实验功能清单对齐 kimi-code 上游 v2 旗标注册表(7 项)
- 新增开关:**Tower 模式**(多智能体协同,/tower 命令切换)、**子代理 Fork 上下文**(Agent/AgentSwarm 携带主代理会话快照)、**WaitFor 工具**(回合内等待后台任务)、**自动会话标题**(托管 chat_title 工具)
- 移除上游已废弃的 **ACP v2 协议** 开关(手写进 config.toml 的配置仍会完整保留)
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.7" version = "0.7.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.7" version = "0.7.8"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["codingplan.site"] authors = ["codingplan.site"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.7.7", "version": "0.7.8",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+239 -199
View File
@@ -4,6 +4,7 @@ import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter"; import { UsageFooter } from "./UsageFooter";
import { useUsageQuery } from "../hooks/useUsageQuery";
import type { Agent, Model, Provider } from "../types"; import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult { interface ConnectivityResult {
@@ -32,6 +33,228 @@ interface ProviderListProps {
agent: Agent; agent: Agent;
} }
interface ProviderCardProps {
provider: Provider;
agent: Agent;
defaultModel: string | null;
models: Record<string, Model>;
/** Inline connectivity-test state for this provider, or undefined when idle. */
ts: TestState | undefined;
onEdit: (name: string) => void;
onDelete: (name: string) => void;
onDuplicate: (name: string) => void;
onSwitchProvider: (name: string) => void;
onConfigureUsage: (name: string) => void;
onTest: (provider: Provider) => void;
}
function ProviderCard({
provider,
agent,
defaultModel,
models,
ts,
onEdit,
onDelete,
onDuplicate,
onSwitchProvider,
onConfigureUsage,
onTest,
}: ProviderCardProps) {
const { t } = useTranslation();
// Usage query state lives once per card; both the compact (card header) and
// detail (footer) UsageFooter variants read this same state, so a refresh
// from either side updates both.
const usage = useUsageQuery(
agent,
provider.name,
provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes
);
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
);
const defaultModelName = defaultModel
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
: null;
const isActive = provider.active === true;
return (
<div
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
isActive
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20"
: "bg-panel border-border hover:border-strong hover:bg-hover"
}`}
onClick={() => onEdit(provider.name)}
>
<div className="flex items-center gap-4 w-full">
<ProviderIcon
name={provider.name}
icon={provider.icon}
color={provider.icon_color}
size={44}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-content-primary truncate">
{provider.name}
</h3>
{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")}
</span>
)}
{provider.note && (
<span className="text-xs text-content-muted truncate max-w-[200px]">
{provider.note}
</span>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-content-muted flex-wrap">
<span className="font-mono text-xs">
{provider.official_url || provider.base_url || t("noUrl")}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border">
{provider.provider_type}
</span>
<span className="text-xs">
{t("modelCount", { count: providerModels.length })}
</span>
</div>
</div>
<div className="flex items-center flex-wrap justify-end gap-2">
{defaultModel &&
models[defaultModel]?.provider === provider.name && (
<span className="text-xs px-2 py-1 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/20">
{t("defaultModel", { name: defaultModelName ?? "" })}
</span>
)}
{/* inline connectivity test result */}
{ts && ts.status !== "testing" && (
<span
className={`text-xs px-2 py-0.5 rounded-full border tabular-nums ${
ts.status === "ok"
? ts.latency && ts.latency > 6000
? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30"
: "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
: "bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 border-red-300 dark:border-red-500/30"
}`}
title={
ts.status === "fail"
? ts.error || t("connectivityFail", { name: provider.name, error: "" })
: ts.latency && ts.latency > 6000
? t("connectivitySlow", { name: provider.name, latency: ts.latency ?? 0 })
: t("connectivityOk", { name: provider.name, latency: ts.latency ?? 0 })
}
>
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
</span>
)}
{/* compact usage summary — sits left of the switch button,
mirroring cc-switch's card layout (usage → action buttons) */}
{(provider.usageKinds?.length ?? 0) > 0 && (
<UsageFooter usage={usage} variant="compact" />
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSwitchProvider(provider.name);
}}
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${
isActive
? "bg-green-600 hover:bg-green-700 text-white focus:ring-green-500"
: "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
}`}
>
{isActive ? t("inUse") : t("switchTo")}
</button>
{agent === "kimi_code" && (
<span
className="text-sm text-content-muted hover:text-content-primary cursor-help select-none"
title={t("switchReloadHint")}
aria-label={t("switchReloadHint")}
>
ⓘ
</span>
)}
{/* icon button group */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit(provider.name);
}}
title={t("edit")}
className={iconBtn}
aria-label={t("edit")}
>
<Pencil className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDuplicate(provider.name);
}}
title={t("copyProvider")}
className={iconBtn}
aria-label={t("copyProvider")}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onTest(provider);
}}
disabled={ts?.status === "testing"}
title={t("testConnectivity")}
className={`${iconBtn} disabled:opacity-50`}
aria-label={t("testConnectivity")}
>
{ts?.status === "testing" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Activity className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onConfigureUsage(provider.name);
}}
title={t("usageConfigBtn")}
className={iconBtn}
aria-label={t("usageConfigBtn")}
>
<BarChart3 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(provider.name);
}}
title={t("delete")}
className={`${iconBtn} hover:text-red-400 hover:border-red-500/30 hover:bg-red-900/20`}
aria-label={t("delete")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<UsageFooter usage={usage} variant="detail" />
</div>
);
}
const iconBtn = const iconBtn =
"w-8 h-8 flex items-center justify-center rounded border border-border text-content-muted hover:text-content-primary hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors"; "w-8 h-8 flex items-center justify-center rounded border border-border text-content-muted hover:text-content-primary hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors";
@@ -139,205 +362,22 @@ export function ProviderList({
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-3 w-full"> <div className="grid grid-cols-1 gap-3 w-full">
{sortedProviders.map((provider) => { {sortedProviders.map((provider) => (
const providerModels = Object.values(models).filter( <ProviderCard
(m) => m.provider === provider.name key={provider.name}
); provider={provider}
const defaultModelName = defaultModel agent={agent}
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel defaultModel={defaultModel}
: null; models={models}
const isActive = provider.active === true; ts={testState[provider.name]}
const ts = testState[provider.name]; onEdit={onEdit}
onDelete={onDelete}
return ( onDuplicate={onDuplicate}
<div onSwitchProvider={onSwitchProvider}
key={provider.name} onConfigureUsage={onConfigureUsage}
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${ onTest={handleTest}
isActive />
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20" ))}
: "bg-panel border-border hover:border-strong hover:bg-hover"
}`}
onClick={() => onEdit(provider.name)}
>
<div className="flex items-center gap-4 w-full">
<ProviderIcon
name={provider.name}
icon={provider.icon}
color={provider.icon_color}
size={44}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-content-primary truncate">
{provider.name}
</h3>
{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")}
</span>
)}
{provider.note && (
<span className="text-xs text-content-muted truncate max-w-[200px]">
{provider.note}
</span>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-content-muted flex-wrap">
<span className="font-mono text-xs">
{provider.official_url || provider.base_url || t("noUrl")}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border">
{provider.provider_type}
</span>
<span className="text-xs">
{t("modelCount", { count: providerModels.length })}
</span>
</div>
</div>
<div className="flex items-center flex-wrap justify-end gap-2">
{defaultModel &&
models[defaultModel]?.provider === provider.name && (
<span className="text-xs px-2 py-1 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/20">
{t("defaultModel", { name: defaultModelName ?? "" })}
</span>
)}
{/* inline connectivity test result */}
{ts && ts.status !== "testing" && (
<span
className={`text-xs px-2 py-0.5 rounded-full border tabular-nums ${
ts.status === "ok"
? ts.latency && ts.latency > 6000
? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30"
: "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
: "bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 border-red-300 dark:border-red-500/30"
}`}
title={
ts.status === "fail"
? ts.error || t("connectivityFail", { name: provider.name, error: "" })
: ts.latency && ts.latency > 6000
? t("connectivitySlow", { name: provider.name, latency: ts.latency ?? 0 })
: t("connectivityOk", { name: provider.name, latency: ts.latency ?? 0 })
}
>
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
</span>
)}
{/* compact usage summary — sits left of the switch button,
mirroring cc-switch's card layout (usage → action buttons) */}
{(provider.usageKinds?.length ?? 0) > 0 && (
<UsageFooter
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
variant="compact"
autoIntervalMinutes={
provider.usageConfig?.autoQueryIntervalMinutes
}
/>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSwitchProvider(provider.name);
}}
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${
isActive
? "bg-green-600 hover:bg-green-700 text-white focus:ring-green-500"
: "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
}`}
>
{isActive ? t("inUse") : t("switchTo")}
</button>
{agent === "kimi_code" && (
<span
className="text-sm text-content-muted hover:text-content-primary cursor-help select-none"
title={t("switchReloadHint")}
aria-label={t("switchReloadHint")}
>
ⓘ
</span>
)}
{/* icon button group */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit(provider.name);
}}
title={t("edit")}
className={iconBtn}
aria-label={t("edit")}
>
<Pencil className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDuplicate(provider.name);
}}
title={t("copyProvider")}
className={iconBtn}
aria-label={t("copyProvider")}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleTest(provider);
}}
disabled={ts?.status === "testing"}
title={t("testConnectivity")}
className={`${iconBtn} disabled:opacity-50`}
aria-label={t("testConnectivity")}
>
{ts?.status === "testing" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Activity className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onConfigureUsage(provider.name);
}}
title={t("usageConfigBtn")}
className={iconBtn}
aria-label={t("usageConfigBtn")}
>
<BarChart3 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(provider.name);
}}
title={t("delete")}
className={`${iconBtn} hover:text-red-400 hover:border-red-500/30 hover:bg-red-900/20`}
aria-label={t("delete")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<UsageFooter
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
variant="detail"
/>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
+7 -1
View File
@@ -44,11 +44,17 @@ const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = { const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" }, "secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" }, "tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
"acp-v2": { name: "flagAcpV2", desc: "flagAcpV2Desc" },
persistence_minidb_readmodel: { persistence_minidb_readmodel: {
name: "flagMinidbReadmodel", name: "flagMinidbReadmodel",
desc: "flagMinidbReadmodelDesc", desc: "flagMinidbReadmodelDesc",
}, },
tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
auto_session_title: {
name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc",
},
}; };
/** Validation-error i18n key per engine rule, for the pre-write self-check. */ /** Validation-error i18n key per engine rule, for the pre-write self-check. */
+10 -162
View File
@@ -1,175 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { localizeUsageError, planLabel } from "../lib/usage-display"; import { localizeUsageError, planLabel } from "../lib/usage-display";
import type { Agent } from "../types"; import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageData {
planName?: string | null;
remaining?: number | null;
total?: number | null;
used?: number | null;
unit?: string | null;
isValid?: boolean | null;
resetsAt?: string | null;
}
interface UsageResult {
success: boolean;
data?: UsageData[] | null;
error?: string | null;
}
type UsageStatus = "idle" | "loading" | "success" | "error";
interface CacheEntry {
status: "success" | "error";
data: UsageData[];
/** Raw error text from Rust; null = transient failure (invoke rejected). */
error: string | null;
updatedAt: number;
}
// Module-level cache shared across mounts: re-entering the list within the
// stale TTL shows the last result without firing new requests. Both the
// compact (card header) and detail (footer) variants read this same cache.
const STALE_TTL_MS = 5 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
// Simple semaphore: at most MAX_CONCURRENT queries in flight at once.
const MAX_CONCURRENT = 3;
let running = 0;
const waiters: Array<() => void> = [];
async function acquireSlot(): Promise<void> {
if (running >= MAX_CONCURRENT) {
await new Promise<void>((resolve) => waiters.push(resolve));
}
running += 1;
}
function releaseSlot(): void {
running -= 1;
waiters.shift()?.();
}
interface UsageFooterProps { interface UsageFooterProps {
agent: Agent; /** Query state lifted to the provider card via useUsageQuery. Both the
providerName: string; * compact (card header) and detail (footer) variants read the same state,
usageKinds?: string[]; * so a refresh from either updates both. */
usage: UsageQueryState;
/** "detail" (default) renders the multi-line footer; "compact" renders a /** "detail" (default) renders the multi-line footer; "compact" renders a
* one-line summary + last-updated + refresh button for the card header. */ * one-line summary + last-updated + refresh button for the card header. */
variant?: "detail" | "compact"; variant?: "detail" | "compact";
/** Auto query interval in minutes; only wired on the compact variant so
* both variants do not double-fire. 0/undefined = manual only. */
autoIntervalMinutes?: number;
} }
export function UsageFooter({ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
agent,
providerName,
usageKinds,
variant = "detail",
autoIntervalMinutes,
}: UsageFooterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const supported = (usageKinds?.length ?? 0) > 0; const { status, data, error, updatedAt, supported, refresh } = usage;
// Cache key includes the agent so a Kimi Code provider and a Pi provider
// with the same name do not clobber each other's cached result.
const cacheKey = `${agent}:${providerName}`;
const [status, setStatus] = useState<UsageStatus>("idle");
const [data, setData] = useState<UsageData[]>([]);
/** undefined = no error; null = network error; string = Rust error text. */
const [error, setError] = useState<string | null | undefined>(undefined);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
// Generation counter: stale responses (unmounted / superseded query) are ignored.
const genRef = useRef(0);
const runQuery = useCallback(
async (forceRefresh: boolean) => {
const gen = ++genRef.current;
setStatus("loading");
setError(undefined);
const finish = (entry: CacheEntry) => {
cache.set(cacheKey, entry);
if (genRef.current !== gen) return;
setStatus(entry.status);
setData(entry.data);
setError(entry.error);
setUpdatedAt(entry.updatedAt);
};
try {
await acquireSlot();
let result: UsageResult;
try {
result = await invoke<UsageResult>("query_provider_usage", {
agent,
providerName,
forceRefresh,
});
} finally {
releaseSlot();
}
if (genRef.current !== gen) return;
if (result.success) {
finish({
status: "success",
data: result.data ?? [],
error: null,
updatedAt: Date.now(),
});
} else {
// Deterministic failure: keep last good data for ghost display.
finish({
status: "error",
data: cache.get(cacheKey)?.data ?? [],
error: result.error ?? "",
updatedAt: Date.now(),
});
}
} catch {
// Transient failure (network / timeout): invoke rejected.
finish({
status: "error",
data: cache.get(cacheKey)?.data ?? [],
error: null,
updatedAt: Date.now(),
});
}
},
[agent, providerName, cacheKey]
);
// On mount / provider change: serve fresh cache, otherwise query once.
useEffect(() => {
if (!supported) return;
const cached = cache.get(cacheKey);
if (cached) {
setStatus(cached.status);
setData(cached.data);
setError(cached.error);
setUpdatedAt(cached.updatedAt);
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
}
void runQuery(false);
}, [supported, cacheKey, runQuery]);
// Ignore late responses after unmount.
useEffect(() => {
return () => {
genRef.current += 1;
};
}, []);
// Auto query interval (compact variant only, to avoid double-firing).
useEffect(() => {
if (variant !== "compact" || !supported) return;
const mins = autoIntervalMinutes ?? 0;
if (mins <= 0) return;
const id = setInterval(() => void runQuery(false), mins * 60_000);
return () => clearInterval(id);
}, [variant, supported, autoIntervalMinutes, runQuery]);
// Tick relative times once a minute while showing data. // Tick relative times once a minute while showing data.
useEffect(() => { useEffect(() => {
@@ -226,7 +74,7 @@ export function UsageFooter({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void runQuery(true); void refresh(true);
}} }}
disabled={status === "loading"} disabled={status === "loading"}
title={t("usageRefresh")} title={t("usageRefresh")}
@@ -344,7 +192,7 @@ export function UsageFooter({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void runQuery(true); void refresh(true);
}} }}
className="ml-auto shrink-0 px-2 py-0.5 rounded border border-red-300 dark:border-red-500/30 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" className="ml-auto shrink-0 px-2 py-0.5 rounded border border-red-300 dark:border-red-500/30 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
> >
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Agent } from "../types";
export interface UsageData {
planName?: string | null;
remaining?: number | null;
total?: number | null;
used?: number | null;
unit?: string | null;
isValid?: boolean | null;
resetsAt?: string | null;
}
export interface UsageResult {
success: boolean;
data?: UsageData[] | null;
error?: string | null;
}
export type UsageStatus = "idle" | "loading" | "success" | "error";
export interface CacheEntry {
status: "success" | "error";
data: UsageData[];
/** Raw error text from Rust; null = transient failure (invoke rejected). */
error: string | null;
updatedAt: number;
}
// Module-level cache shared across mounts: re-entering the list within the
// stale TTL shows the last result without firing new requests. Both the
// compact (card header) and detail (footer) variants read this same cache.
const STALE_TTL_MS = 5 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
// Simple semaphore: at most MAX_CONCURRENT queries in flight at once.
const MAX_CONCURRENT = 3;
let running = 0;
const waiters: Array<() => void> = [];
async function acquireSlot(): Promise<void> {
if (running >= MAX_CONCURRENT) {
await new Promise<void>((resolve) => waiters.push(resolve));
}
running += 1;
}
function releaseSlot(): void {
running -= 1;
waiters.shift()?.();
}
export interface UsageQueryState {
status: UsageStatus;
data: UsageData[];
error: string | null | undefined;
updatedAt: number | null;
refresh: (force: boolean) => void;
supported: boolean;
}
export function useUsageQuery(
agent: Agent,
providerName: string,
usageKinds?: string[],
autoIntervalMinutes?: number
): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider
// with the same name do not clobber each other's cached result.
const cacheKey = `${agent}:${providerName}`;
const [status, setStatus] = useState<UsageStatus>("idle");
const [data, setData] = useState<UsageData[]>([]);
/** undefined = no error; null = network error; string = Rust error text. */
const [error, setError] = useState<string | null | undefined>(undefined);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
// Generation counter: stale responses (unmounted / superseded query) are ignored.
const genRef = useRef(0);
const runQuery = useCallback(
async (forceRefresh: boolean) => {
const gen = ++genRef.current;
setStatus("loading");
setError(undefined);
const finish = (entry: CacheEntry) => {
cache.set(cacheKey, entry);
if (genRef.current !== gen) return;
setStatus(entry.status);
setData(entry.data);
setError(entry.error);
setUpdatedAt(entry.updatedAt);
};
try {
await acquireSlot();
let result: UsageResult;
try {
result = await invoke<UsageResult>("query_provider_usage", {
agent,
providerName,
forceRefresh,
});
} finally {
releaseSlot();
}
if (genRef.current !== gen) return;
if (result.success) {
finish({
status: "success",
data: result.data ?? [],
error: null,
updatedAt: Date.now(),
});
} else {
// Deterministic failure: keep last good data for ghost display.
finish({
status: "error",
data: cache.get(cacheKey)?.data ?? [],
error: result.error ?? "",
updatedAt: Date.now(),
});
}
} catch {
// Transient failure (network / timeout): invoke rejected.
finish({
status: "error",
data: cache.get(cacheKey)?.data ?? [],
error: null,
updatedAt: Date.now(),
});
}
},
[agent, providerName, cacheKey]
);
// On mount / provider change: serve fresh cache, otherwise query once.
useEffect(() => {
if (!supported) return;
const cached = cache.get(cacheKey);
if (cached) {
setStatus(cached.status);
setData(cached.data);
setError(cached.error);
setUpdatedAt(cached.updatedAt);
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
}
void runQuery(false);
}, [supported, cacheKey, runQuery]);
// Ignore late responses after unmount.
useEffect(() => {
return () => {
genRef.current += 1;
};
}, []);
// Auto query interval — the hook is called once per provider card and both
// variants read the same state, so there is no second mount to double-fire.
useEffect(() => {
if (!supported) return;
const mins = autoIntervalMinutes ?? 0;
if (mins <= 0) return;
const id = setInterval(() => void runQuery(false), mins * 60_000);
return () => clearInterval(id);
}, [supported, autoIntervalMinutes, runQuery]);
return {
status,
data,
error,
updatedAt,
refresh: runQuery,
supported,
};
}
+8 -2
View File
@@ -465,10 +465,16 @@ export const enTranslations: Record<TranslationKey, string> = {
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config", flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
flagToolSelect: "Tool Select", flagToolSelect: "Tool Select",
flagToolSelectDesc: "Enable the experimental tool selection feature", flagToolSelectDesc: "Enable the experimental tool selection feature",
flagAcpV2: "ACP v2 Protocol",
flagAcpV2Desc: "Enable Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB Read Model", flagMinidbReadmodel: "MiniDB Read Model",
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend", flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
flagTower: "Tower Mode",
flagTowerDesc: "Enable tower mode: coordinate multiple agents on a shared objective (/tower command)",
flagSubagentFork: "Subagent Fork Context",
flagSubagentForkDesc: "Agent/AgentSwarm tools can start a subagent with a snapshot of the calling agent's history (fork parameter)",
flagWaitFor: "WaitFor Tool",
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
flagAutoSessionTitle: "Auto Session Title",
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
lockedByEnv: "Locked by env var", lockedByEnv: "Locked by env var",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var", masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
secondaryModelSection: "Subagent Model (Secondary Model)", secondaryModelSection: "Subagent Model (Secondary Model)",
+8 -2
View File
@@ -460,10 +460,16 @@ export const zhTranslations = {
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置", flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
flagToolSelect: "工具选择", flagToolSelect: "工具选择",
flagToolSelectDesc: "启用实验性工具选择功能", flagToolSelectDesc: "启用实验性工具选择功能",
flagAcpV2: "ACP v2 协议",
flagAcpV2Desc: "启用 Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB 读模型", flagMinidbReadmodel: "MiniDB 读模型",
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端", flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
flagTower: "Tower 模式",
flagTowerDesc: "启用 Tower 多智能体协同模式(/tower 命令切换)",
flagSubagentFork: "子代理 Fork 上下文",
flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)",
flagWaitFor: "WaitFor 工具",
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
flagAutoSessionTitle: "自动会话标题",
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
lockedByEnv: "被环境变量锁定", lockedByEnv: "被环境变量锁定",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启", masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
secondaryModelSection: "子代理模型(次主力模型)", secondaryModelSection: "子代理模型(次主力模型)",
+1385 -633
View File
File diff suppressed because it is too large. Load diff
+1171 -538
View File
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -30,15 +30,25 @@ export interface ExperimentalFlagDef {
envVar: string; envVar: string;
} }
/** Known experimental flags (Kimi Code v1 + v2 registries, merged). */ /** Known experimental flags — mirrors the kimi-code v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src:
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
* subagent_fork, wait_for, auto_session_title). `acp-v2` was removed
* upstream and is dropped here. */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" }, { id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" }, { id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ id: "acp-v2", envVar: "KIMI_CODE_EXPERIMENTAL_ACP_V2" },
{ {
id: "persistence_minidb_readmodel", id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL", envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
}, },
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{ id: "wait_for", envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR" },
{
id: "auto_session_title",
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
},
]; ];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG"; export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";