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"; import { applyModelsDevSnapshot, reloadModelsDev } from "../lib/models-dev"; /** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */ interface ModelsDevStatus { source: "synced" | "builtin"; lastUpdated: string; modelCount: number; providerCount: number; } 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; onChange: (nextRawOther: unknown) => void; onBack: () => void; } // Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and // per-model support comes from `[models.""] support_efforts`. const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = { low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", max: "thinkingMax", xhigh: "thinkingXHigh", }; const FLAG_LABELS: Record = { 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" }, }; /** Validation-error i18n key per engine rule, for the pre-write self-check. */ const POOL_ERROR_KEYS: Record = { 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 (
onSetDefault(alias)} className="shrink-0 accent-blue-600 focus:ring-blue-500" /> {alias} {displayName && ( {displayName} )} {isDefault && ( {t("default")} )} 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 && ( )}
); } export function SubagentSettingsPage({ rawOther, models, onChange, onBack, }: SubagentSettingsPageProps) { const { t } = useTranslation(); const [env, setEnv] = useState(null); /** Pre-write validation failures (fallback guard; normal flows stay clear). */ const [poolErrors, setPoolErrors] = useState([]); /** 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); /** models.dev snapshot provenance (synced copy vs bundled asset). */ const [modelsDevStatus, setModelsDevStatus] = useState(null); /** Sync/restore button in flight; blocks the card's buttons. */ const [modelsDevBusy, setModelsDevBusy] = useState(false); /** Last sync error (inline) and success flag (auto-cleared on next action). */ const [syncError, setSyncError] = useState(null); const [syncOk, setSyncOk] = useState(false); useEffect(() => { invoke("get_experimental_env_status") .then(setEnv) .catch(() => setEnv({})); invoke("get_models_dev_status") .then(setModelsDevStatus) .catch(() => setModelsDevStatus(null)); }, []); const refreshModelsDevStatus = () => { invoke("get_models_dev_status") .then(setModelsDevStatus) .catch(() => {}); }; const handleModelsDevSync = async () => { setModelsDevBusy(true); setSyncError(null); setSyncOk(false); try { const st = await invoke("sync_models_dev"); setModelsDevStatus(st); // Hot-swap the frontend index; the dashboard price index rebuilds on // its next query (mtime-triggered on the Rust side). const raw = await invoke("get_models_dev_snapshot"); if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record); setSyncOk(true); } catch (err) { setSyncError(err instanceof Error ? err.message : String(err)); } finally { setModelsDevBusy(false); } }; const handleModelsDevRestore = async () => { if (!confirm(t("modelsDataRestoreConfirm"))) return; setModelsDevBusy(true); setSyncError(null); setSyncOk(false); try { const st = await invoke("reset_models_dev"); setModelsDevStatus(st); await reloadModelsDev(); } catch (err) { setSyncError(err instanceof Error ? err.message : String(err)); refreshModelsDevStatus(); } finally { setModelsDevBusy(false); } }; 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 | undefined) ?.default_effort === "string" ? ((selectedEntry.raw_other as Record).default_effort as string) : undefined; const thinkingEffort = (() => { const ro = rawOther as Record | undefined; const thinking = ro?.thinking as Record | 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 => { // Unknown values (hand-written config) are shown verbatim. const key = EFFORT_LABELS[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 (
{t(labels.name)} {!locked && !explicitlySet && flag.defaultEnabled && ( {t("flagDefaultOn")} )}
{t(labels.desc)} {flag.envVar}
{locked && ( 🔒 {t("lockedByEnv")} )} onChange( setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse }) ) } />
); }; return (
{/* Header */}

{t("subagentSettings")}

{masterOn && (
{t("masterEnvOnHint")}
)} {/* Kimi Code WebUI quick open */}

{t("webuiDesc")}

{/* models.dev reference data: online sync / restore bundled */}

{t("modelsDataDesc")}

{modelsDevStatus && (
{modelsDevStatus.source === "synced" ? t("modelsDataSourceSynced") : t("modelsDataSourceBuiltin")} {t("modelsDataStats", { date: modelsDevStatus.lastUpdated, models: modelsDevStatus.modelCount.toLocaleString(), providers: modelsDevStatus.providerCount.toLocaleString(), })}
)} {syncOk && (
{t("modelsDataSyncOk")}
)} {syncError && (
{t("modelsDataSyncFailed")}: {syncError}
)}
{modelsDevStatus?.source === "synced" && ( )}
{/* Subagent: secondary model picker / pool + inherited settings */} {isPoolView && pool ? ( <> {pool.force ? ( /* Force on: the `models` table was dropped — only the pinned default is shown, entries and "add" are hidden. */
🔒{" "} {t("secondaryPoolForceOn", { alias: aliasLabel(effectiveDefault ?? ""), })}
) : ( <>
{poolEntries.map((alias) => ( ))}

{t("secondaryPoolDescHint")}

)}
{t("secondaryPoolForce")}
{t("secondaryPoolForceDesc")}
) : ( <> {canUpgrade && (
{t("secondaryPoolUpgradeDesc")}
)} )} {poolErrors.length > 0 && (
{poolErrors.map((err, i) => (
{t(POOL_ERROR_KEYS[err.key], err.params)}
))}
)}
{effectiveDefault !== undefined && (
{t("secondaryModelInheritedContext")} {inheritedContext ? fmtContext(inheritedContext) : "—"}
{t("secondaryModelInheritedEffort")} {inheritedEffort ? effortLabel(inheritedEffort) : "—"}

{t("secondaryModelInheritedHint")}

)}

{t("secondaryModelPriorityHint")}

{/* Experimental feature flags — one unified list */} {EXPERIMENTAL_FLAGS.map(renderFlagRow)}

{t("experimentalFlagsHint")}

); }