feat(subagent): v0.7.5 — 适配 kimi-code 0.36.0 子代理模型池(双写兼容/池管理/force/前置校验)+ 热力图详情按需加载 + models.dev 刷新(GLM-5.3)
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-08-15 23:21:22 +08:00
1 parent 89c71fbfe6
commit 9a3875d990
19 files changed
+152041 -140593

No files matched your search

+331 -31
View File
@@ -8,12 +8,19 @@ import {
clearSecondaryModel,
forcedEnvValue,
getExperimentalFlags,
getSecondaryModel,
getSubagentModelPool,
isFlagLockedByEnv,
isMasterEnvOn,
removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly,
setSubagentDefault,
setSubagentForce,
upsertSubagentPoolEntry,
validateSubagentPool,
type ExperimentalFlagDef,
type PoolValidationError,
type ValidationErrorKey,
} from "../lib/subagent-settings";
import { Card, Toggle } from "./ui/controls";
@@ -44,6 +51,97 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
},
};
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
const POOL_ERROR_KEYS: Record<ValidationErrorKey, TranslationKey> = {
primaryReserved: "secondaryPoolErrorPrimaryReserved",
defaultRequired: "secondaryPoolErrorDefaultRequired",
defaultNotInPool: "secondaryPoolErrorDefaultNotInPool",
aliasNotInModels: "secondaryPoolErrorAliasNotInModels",
forceRequiresDefault: "secondaryPoolErrorForceRequiresDefault",
forceExcludesModels: "secondaryPoolErrorForceExcludesModels",
};
/**
* One row of the subagent model pool: default radio + alias + editable
* description (saved on blur/Enter via the parent's write chain) + remove.
* The description input keeps a local draft so typing is not persisted per
* keystroke; everything else is derived from `rawOther` on each render.
*/
function PoolEntryRow({
alias,
displayName,
description,
isDefault,
canRemove,
onSetDefault,
onSave,
onRemove,
}: {
alias: string;
displayName: string | null;
description: string;
isDefault: boolean;
canRemove: boolean;
onSetDefault: (alias: string) => void;
onSave: (alias: string, description: string) => void;
onRemove: (alias: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(description);
const commit = () => {
if (draft === description) return;
onSave(alias, draft);
};
return (
<div className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={isDefault}
aria-label={t("setDefault")}
onChange={() => onSetDefault(alias)}
className="shrink-0 accent-blue-600 focus:ring-blue-500"
/>
<span className="shrink-0 font-medium text-content-primary">{alias}</span>
{displayName && (
<span className="shrink-0 text-xs text-content-muted">{displayName}</span>
)}
{isDefault && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("default")}
</span>
)}
<input
type="text"
value={draft}
placeholder={t("secondaryPoolDescPlaceholder")}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(description);
(e.currentTarget as HTMLInputElement).blur();
}
}}
className="flex-1 min-w-0 bg-input border border-border rounded px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
{canRemove && (
<button
type="button"
onClick={() => onRemove(alias)}
className="shrink-0 px-2 py-1 text-xs border border-border rounded hover:bg-hover-2 text-content-muted focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("secondaryPoolRemove")}
</button>
)}
</div>
);
}
export function SubagentSettingsPage({
rawOther,
models,
@@ -52,6 +150,10 @@ export function SubagentSettingsPage({
}: SubagentSettingsPageProps) {
const { t } = useTranslation();
const [env, setEnv] = useState<ExperimentalEnvStatus | null>(null);
/** Pre-write validation failures (fallback guard; normal flows stay clear). */
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
/** Local draft for the "add pool entry" select (reset after each pick). */
const [addSelection, setAddSelection] = useState("");
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -60,7 +162,17 @@ export function SubagentSettingsPage({
}, []);
const flags = getExperimentalFlags(rawOther);
const secondary = getSecondaryModel(rawOther);
const pool = getSubagentModelPool(rawOther);
const poolKeys = pool ? Object.keys(pool.models) : [];
// View selection: no/empty section or a bare legacy `model` → simple view;
// a `models` table or `default_model` → pool management view.
const isPoolView =
!!pool && (poolKeys.length > 0 || pool.defaultModel !== undefined);
// Effective default: v2 pool key first, legacy v1 recipe key as fallback.
const effectiveDefault = pool ? pool.defaultModel ?? pool.model : undefined;
// "Upgrade" only makes sense for the bare legacy form and when `force` is
// off — upgrading with force would create an invalid force+pool-table combo.
const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force;
const masterOn = isMasterEnvOn(env);
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
@@ -75,10 +187,25 @@ export function SubagentSettingsPage({
const aliasList = Object.keys(models).sort();
const aliasLabel = (alias: string): string =>
models[alias]?.display_name
? `${alias}(${models[alias].display_name})`
: alias;
// Rows of the pool view: every `models` table key plus the effective default
// when it is not already in the table (the implicit single-entry form).
const implicitDefault =
pool && effectiveDefault && !(effectiveDefault in pool.models)
? [effectiveDefault]
: [];
const poolEntries = [...poolKeys, ...implicitDefault];
// Aliases that can still be added to the pool (not already in the table).
const addableAliases = aliasList.filter((a) => !(a in (pool?.models ?? {})));
// Inherited settings shown for the picked model: subagents bind the pointed
// entry directly (no patch fields), so context size and effort come from the
// model entry / global [thinking], not from a `[secondary_model]` patch.
const selectedEntry = secondary.model ? models[secondary.model] : undefined;
const selectedEntry = effectiveDefault ? models[effectiveDefault] : undefined;
const inheritedContext = selectedEntry?.max_context_size;
const modelEffort =
selectedEntry &&
@@ -106,6 +233,61 @@ export function SubagentSettingsPage({
return key ? t(key) : e;
};
/**
* Fallback guard before any pool-related write: validate the *target* state
* against the engine rules (mirrored by `validateSubagentPool`) and block the
* write when it would produce an invalid config. Normal UI flows never trip
* this — it protects against stale/divergent configs.
*/
const validateWrite = (nextRaw: unknown): boolean => {
const nextPool = getSubagentModelPool(nextRaw);
const errors = nextPool ? validateSubagentPool(nextPool, aliasList) : [];
setPoolErrors(errors);
return errors.length === 0;
};
const handleUpgrade = () => {
if (!effectiveDefault) return;
const nextRaw = setSubagentDefault(rawOther, effectiveDefault);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleSetDefault = (alias: string) => {
if (alias === effectiveDefault) return;
const nextRaw = setSubagentDefault(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleSaveDescription = (alias: string, description: string) => {
const nextRaw = upsertSubagentPoolEntry(rawOther, alias, description);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleRemoveEntry = (alias: string) => {
const nextRaw = removeSubagentPoolEntry(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleAddEntry = (alias: string) => {
const nextRaw = upsertSubagentPoolEntry(rawOther, alias, "");
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleForceChange = (next: boolean) => {
if (!pool) return;
// Turning force on drops the `models` table — confirm when it is non-empty.
if (next && Object.keys(pool.models).length > 0) {
if (!confirm(t("secondaryPoolForceConfirm"))) return;
}
const nextRaw = setSubagentForce(rawOther, next);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleClear = () => {
setPoolErrors([]);
onChange(clearSecondaryModel(rawOther));
};
const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id];
const locked = masterOn || isFlagLockedByEnv(env, flag);
@@ -194,36 +376,154 @@ export function SubagentSettingsPage({
</div>
</Card>
{/* Subagent: secondary model picker + inherited settings */}
{/* Subagent: secondary model picker / pool + inherited settings */}
<Card title={t("secondaryModelSection")}>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{t("secondaryModelLabel")}</span>
<select
className="flex-1 max-w-md bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={secondary.model ?? ""}
onChange={(e) => {
const alias = e.target.value;
if (alias === "") {
onChange(clearSecondaryModel(rawOther));
} else {
// Only the model reference is persisted — no patch fields.
// Subagents bind the pointed entry directly and inherit its
// settings; usage records stay keyed on the real alias
// instead of Kimi Code's synthesized `__secondary__` entry.
onChange(setSecondaryModelOnly(rawOther, alias));
}
}}
{isPoolView && pool ? (
<>
{pool.force ? (
/* Force on: the `models` table was dropped — only the pinned
default is shown, entries and "add" are hidden. */
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5">
<div className="text-sm font-medium text-content-primary">
🔒{" "}
{t("secondaryPoolForceOn", {
alias: aliasLabel(effectiveDefault ?? ""),
})}
</div>
</div>
) : (
<>
<div className="space-y-2">
{poolEntries.map((alias) => (
<PoolEntryRow
key={alias}
alias={alias}
displayName={models[alias]?.display_name ?? null}
description={pool.models[alias] ?? ""}
isDefault={alias === effectiveDefault}
canRemove={alias in pool.models}
onSetDefault={handleSetDefault}
onSave={handleSaveDescription}
onRemove={handleRemoveEntry}
/>
))}
</div>
<p className="text-xs text-content-muted">
{t("secondaryPoolDescHint")}
</p>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="shrink-0 text-content-muted">
{t("secondaryPoolAddLabel")}
</span>
<select
className="flex-1 max-w-md bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={addSelection}
onChange={(e) => {
const alias = e.target.value;
setAddSelection("");
if (alias) handleAddEntry(alias);
}}
>
<option value="">{t("secondaryPoolAddPlaceholder")}</option>
{addableAliases.map((alias) => (
<option key={alias} value={alias}>
{aliasLabel(alias)}
</option>
))}
</select>
</label>
</>
)}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-content-primary">
{t("secondaryPoolForce")}
</div>
<div className="text-xs text-content-muted">
{t("secondaryPoolForceDesc")}
</div>
</div>
<Toggle
checked={pool.force}
ariaLabel={t("secondaryPoolForce")}
onChange={handleForceChange}
/>
</div>
<button
type="button"
onClick={handleClear}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("secondaryModelClear")}
</button>
</>
) : (
<>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="shrink-0 text-content-muted">
{t("secondaryModelLabel")}
</span>
<select
className="flex-1 max-w-md bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={effectiveDefault ?? ""}
onChange={(e) => {
const alias = e.target.value;
if (alias === "") {
setPoolErrors([]);
onChange(clearSecondaryModel(rawOther));
} else {
// Only the model reference is persisted — no patch fields.
// Subagents bind the pointed entry directly and inherit
// its settings; usage records stay keyed on the real
// alias instead of the synthesized `__secondary__` entry.
const nextRaw = setSecondaryModelOnly(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
}
}}
>
<option value="">{t("secondaryModelUnset")}</option>
{aliasList.map((alias) => (
<option key={alias} value={alias}>
{aliasLabel(alias)}
</option>
))}
</select>
{pool?.force && (
<span className="shrink-0 text-xs text-content-muted">
🔒 {t("secondaryModelForceBadge")}
</span>
)}
</label>
{canUpgrade && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleUpgrade}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("secondaryPoolUpgrade")}
</button>
<span className="text-xs text-content-muted">
{t("secondaryPoolUpgradeDesc")}
</span>
</div>
)}
</>
)}
{poolErrors.length > 0 && (
<div
role="alert"
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-sm text-red-700 dark:text-red-400 space-y-1"
>
<option value="">{t("secondaryModelUnset")}</option>
{aliasList.map((alias) => (
<option key={alias} value={alias}>
{models[alias]?.display_name
? `${alias}(${models[alias].display_name})`
: alias}
</option>
{poolErrors.map((err, i) => (
<div key={i}>{t(POOL_ERROR_KEYS[err.key], err.params)}</div>
))}
</select>
</label>
</div>
)}
<div className="border-t border-border" />
<div className="flex items-center gap-3">
@@ -248,7 +548,7 @@ export function SubagentSettingsPage({
/>
</div>
{secondary.model !== undefined && (
{effectiveDefault !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
+20 -12
View File
@@ -1,10 +1,11 @@
import { useMemo, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
import { useCurrency } from "../../hooks/useCurrency";
import { useTranslation } from "../../i18n";
import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
import { DailyBars, modelColor } from "./DailyBars";
import { DailyBars } from "./DailyBars";
import { DailyDetailModal } from "./DailyDetailModal";
import { Heatmap } from "./Heatmap";
import { TrendLineChart } from "./TrendLineChart";
@@ -62,11 +63,6 @@ export function DashboardPage() {
const [heatmapSelected, setHeatmapSelected] = useState<DailyRow | null>(null);
const daily = data?.stats.daily ?? [];
const detailDates = useMemo(() => new Set(daily.map((d) => d.date)), [daily]);
const heatmapColorMap = useMemo(() => {
const unique = [...new Set(daily.flatMap((d) => Object.keys(d.byModel || {})))];
return Object.fromEntries(unique.map((m, i) => [m, modelColor(i)]));
}, [daily]);
const RANGE_LABELS: Record<DashboardRange, string> = {
today: t("rangeToday"),
@@ -109,9 +105,21 @@ export function DashboardPage() {
const rangeTotals = data.rangeTotals;
const visibleModels = showAllModels ? models : models.slice(0, 8);
const onHeatmapCellDoubleClick = (cell: HeatmapCell) => {
const row = daily.find((d) => d.date === cell.date);
if (row) setHeatmapSelected(row);
const onHeatmapCellDoubleClick = async (cell: HeatmapCell) => {
try {
const row = await invoke<DailyRow | null>("get_day_detail", {
date: cell.date,
});
if (row) {
setHeatmapSelected(row);
} else {
setHeatmapSelected(null);
alert(t("dayDetailNoData", { date: cell.date }));
}
} catch (err) {
setHeatmapSelected(null);
alert(err instanceof Error ? err.message : String(err));
}
};
// Recent pagination
@@ -221,7 +229,7 @@ export function DashboardPage() {
{/* Heatmap — full row */}
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
<Heatmap heatmap={heatmap} detailDates={detailDates} onCellDoubleClick={onHeatmapCellDoubleClick} />
<Heatmap heatmap={heatmap} onCellDoubleClick={onHeatmapCellDoubleClick} />
</Card>
{/* Usage-trend tabs + Model table side by side */}
@@ -473,7 +481,7 @@ export function DashboardPage() {
<DailyDetailModal
day={heatmapSelected}
dimension="model"
colorMap={heatmapColorMap}
colorMap={{}}
unknownProviderLabel={t("providerUnknown")}
onClose={() => setHeatmapSelected(null)}
/>,
+3 -5
View File
@@ -7,9 +7,7 @@ import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
interface HeatmapProps {
heatmap: HeatmapData;
/** Set of dates (YYYY-MM-DD) that have detail data in the current range. */
detailDates?: Set<string>;
/** Called when a cell with detail data is double-clicked. */
/** Called when a cell with data (level > 0) is double-clicked. */
onCellDoubleClick?: (cell: HeatmapCell) => void;
}
@@ -34,7 +32,7 @@ interface HoverState {
y: number;
}
export function Heatmap({ heatmap, detailDates, onCellDoubleClick }: HeatmapProps) {
export function Heatmap({ heatmap, onCellDoubleClick }: HeatmapProps) {
const { t, lang } = useTranslation();
const cells = heatmap?.cells || [];
const monthLabels = heatmap?.monthLabels || [];
@@ -176,7 +174,7 @@ export function Heatmap({ heatmap, detailDates, onCellDoubleClick }: HeatmapProp
);
}
const level = cell.level ?? 0;
const hasDetail = detailDates ? detailDates.has(cell.date) : level > 0;
const hasDetail = level > 0;
return (
<button
key={cell.date}