Files
KimiCodeSwitch/src/components/SubagentSettingsPage.tsx
T
KimiSwitch Dev 9ce1ed65b0 feat(settings): 适配 kimi-code 0.42.0 —— 实验 flag 镜像 9→6
- 删除已转正/移除的 4 个 flag:secondary-model(模型池恒开)、
  persistence_minidb_readmodel(改由 [database] 配置节)、remote-control、
  search_worker;env 探测清单同步 11→8
- 新增 notify_user flag(KIMI_CODE_EXPERIMENTAL_NOTIFY_USER,实验性
  Updates 面板,默认关闭)
- 设置页移除 secondary-model 专属卡片与「默认开启」徽章,6 个 flag 统一渲染;
  [secondary_model] 模型池配置逻辑不变
- 残留旧键上游 schema 宽容忽略,无需迁移
2026-09-09 19:48:41 +08:00

592 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import type { ExperimentalEnvStatus, Model } from "../types";
import {
EXPERIMENTAL_FLAGS,
clearSecondaryModel,
forcedEnvValue,
getExperimentalFlags,
getSubagentModelPool,
isExperimentalFlagSet,
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";
interface SubagentSettingsPageProps {
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
rawOther: unknown;
/** All configured models (alias → entry) for the secondary-model picker. */
models: Record<string, Model>;
onChange: (nextRawOther: unknown) => void;
onBack: () => void;
}
// Upstream removed the "max" effort tier (auto-migrates to "high").
const EFFORTS = ["low", "medium", "high"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
auto_session_title: {
name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc",
},
};
/** 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,
onChange,
onBack,
}: 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("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
}, []);
const flags = getExperimentalFlags(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 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 = effectiveDefault ? models[effectiveDefault] : undefined;
const inheritedContext = selectedEntry?.max_context_size;
const modelEffort =
selectedEntry &&
typeof (selectedEntry.raw_other as Record<string, unknown> | undefined)
?.default_effort === "string"
? ((selectedEntry.raw_other as Record<string, unknown>).default_effort as string)
: undefined;
const thinkingEffort = (() => {
const ro = rawOther as Record<string, unknown> | undefined;
const thinking = ro?.thinking as Record<string, unknown> | undefined;
return thinking && typeof thinking.effort === "string" && thinking.effort
? thinking.effort
: undefined;
})();
const inheritedEffort = modelEffort ?? thinkingEffort;
const fmtContext = (n: number): string =>
n >= 1_000_000
? `${(n / 1_000_000).toFixed(1)}M`
: n >= 1000
? `${Math.round(n / 1000)}K`
: String(n);
const effortLabel = (e: string): string => {
// Stored "max" tiers from old configs are shown as "high" (upstream
// removed the tier; it auto-migrates to "high").
const key = EFFORT_LABELS[e === "max" ? "high" : (e as (typeof EFFORTS)[number])];
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];
// Only the flag's own env var locks the toggle. The master env force-ons
// undefined flags but an explicit config entry outranks it, so the user
// can still flip values here while it is set.
const locked = isFlagLockedByEnv(env, flag);
// Resolution order mirrors kimi-code 0.40.1 flagService:
// single-flag env > explicit [experimental] value (true *or* false) >
// master env (on-only) > upstream default (`defaultEnabled`).
const explicitlySet = isExperimentalFlagSet(rawOther, flag.id);
const on = locked
? forcedEnvValue(env, flag)
: explicitlySet
? flags[flag.id] === true
: masterOn
? true
: (flag.defaultEnabled ?? false);
// Turning the flag off only sticks via an explicit `false` when the
// fallback (master env or the upstream default) would otherwise keep it on.
const explicitFalse = masterOn || flag.defaultEnabled === true;
return (
<div key={flag.id} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm text-content-primary">
{t(labels.name)}
{!locked && !explicitlySet && flag.defaultEnabled && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("flagDefaultOn")}
</span>
)}
</div>
<div className="text-xs text-content-muted">
{t(labels.desc)}
<code className="ml-2">{flag.envVar}</code>
</div>
</div>
{locked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={on}
disabled={locked}
ariaLabel={t(labels.name)}
onChange={(checked) =>
onChange(
setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse })
)
}
/>
</div>
);
};
return (
<div className="h-full flex flex-col bg-app">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-panel">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
← {t("back")}
</button>
<h2 className="font-semibold text-lg">{t("subagentSettings")}</h2>
</div>
<div className="flex-1 overflow-auto p-6 space-y-4">
{masterOn && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-4 py-2 text-sm text-green-700 dark:text-green-400">
{t("masterEnvOnHint")}
</div>
)}
{/* Kimi Code WebUI quick open */}
<Card title={t("webuiSection")}>
<p className="text-xs text-content-muted">{t("webuiDesc")}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("embedded");
try {
await invoke("open_kimi_web_embedded");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{webuiBusy === "embedded" ? t("webuiOpening") : t("openWebUIEmbedded")}
</button>
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("browser");
try {
await invoke("open_kimi_web");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{webuiBusy === "browser" ? t("webuiOpening") : t("openWebUIBrowser")}
</button>
</div>
</Card>
{/* Subagent: secondary model picker / pool + inherited settings */}
<Card title={t("secondaryModelSection")}>
{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"
>
{poolErrors.map((err, i) => (
<div key={i}>{t(POOL_ERROR_KEYS[err.key], err.params)}</div>
))}
</div>
)}
<div className="border-t border-border" />
{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">
{t("secondaryModelInheritedContext")}
</span>
<span className="font-medium text-content-primary">
{inheritedContext ? fmtContext(inheritedContext) : "—"}
</span>
</div>
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedEffort")}
</span>
<span className="font-medium text-content-primary">
{inheritedEffort ? effortLabel(inheritedEffort) : "—"}
</span>
</div>
<p className="text-xs text-content-muted">
{t("secondaryModelInheritedHint")}
</p>
</div>
)}
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
</Card>
{/* Experimental feature flags — one unified list */}
<Card title={t("experimentalFlags")}>
{EXPERIMENTAL_FLAGS.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
</div>
</div>
);
}