From 63afaa12327626c66eb95035c284d9c0ec9563d2 Mon Sep 17 00:00:00 2001 From: 736368217 <736368217@qq.com> Date: Sun, 18 Oct 2026 01:31:12 +0800 Subject: [PATCH 1/4] feat(settings): show and edit subagent delegation models on the Models page The delegation catalog the parent agent may pick from was only editable one service at a time, inside that service's own model dialog, so there was no way to review it. The Models page now states which configured models carry the `availableForSubagents` opt-in and edits that same flag across every service in one list, next to the image-model row. The list reuses the per-binding flag the runtime already reads instead of adding a second stored selection, so the page and the delegation catalog cannot disagree. A save re-reads the service and rewrites only the touched binding, which keeps a concurrent model edit intact, and a plugin-owned row stays read-only because `providers.update` refuses it. Specs, their zh-CN mirrors and E2E-267 describe the new surface. Refs #1736 --- .../components/settings/ModelConfigPage.tsx | 68 ++++++ .../components/settings/SubagentModelRow.tsx | 110 ++++++++++ .../settings/subagent-delegation.ts | 165 ++++++++++++++ .../components/settings/subagent-models.ts | 6 +- apps/desktop/src/styles/model-config.css | 68 ++++++ .../desktop/test/subagent-delegation.test.mjs | 201 ++++++++++++++++++ docs/spec/03-runtime/02-agent-runtime.md | 5 +- .../03-runtime/11-provider-model-system.md | 8 + docs/spec/04-ux/06-settings-ia.md | 18 +- docs/spec/06-delivery/04-e2e-test-plan.md | 40 ++++ .../zh-CN/spec/03-runtime/02-agent-runtime.md | 5 +- .../03-runtime/11-provider-model-system.md | 6 + docs/zh-CN/spec/04-ux/06-settings-ia.md | 7 + .../spec/06-delivery/04-e2e-test-plan.md | 28 +++ packages/i18n/src/locales/de/index.ts | 10 + packages/i18n/src/locales/en/index.ts | 10 + packages/i18n/src/locales/es/index.ts | 10 + packages/i18n/src/locales/fr/index.ts | 10 + packages/i18n/src/locales/ko/index.ts | 10 + packages/i18n/src/locales/pt-BR/index.ts | 10 + packages/i18n/src/locales/tr/index.ts | 10 + packages/i18n/src/locales/zh-CN/index.ts | 10 + packages/i18n/src/locales/zh-TW/index.ts | 10 + 23 files changed, 814 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/settings/SubagentModelRow.tsx create mode 100644 apps/desktop/src/components/settings/subagent-delegation.ts create mode 100644 apps/desktop/test/subagent-delegation.test.mjs diff --git a/apps/desktop/src/components/settings/ModelConfigPage.tsx b/apps/desktop/src/components/settings/ModelConfigPage.tsx index f061d73c32..043b3f6d20 100644 --- a/apps/desktop/src/components/settings/ModelConfigPage.tsx +++ b/apps/desktop/src/components/settings/ModelConfigPage.tsx @@ -31,6 +31,12 @@ import { } from "./image-generation-default"; import { copyProviderConfiguration, type ProviderCopyDraft } from "./provider-copy"; import { ImageGenerationModelRow } from "./ImageGenerationModelRow"; +import { + subagentModelCandidates, + subagentModelOptInDraft, + type SubagentModelCandidate, +} from "./subagent-delegation"; +import { SubagentModelRow } from "./SubagentModelRow"; import { OAuthLoginDialog } from "./OAuthLoginDialog"; import { ProviderSetupDialog } from "./ProviderSetupDialog"; import { ServiceList } from "./ServiceList"; @@ -106,6 +112,7 @@ export function ModelConfigPage() { const [busyId, setBusyId] = useState(null); const [testingId, setTestingId] = useState(null); const [changingImageModel, setChangingImageModel] = useState(false); + const [changingSubagentModel, setChangingSubagentModel] = useState(false); const [refreshingCatalog, setRefreshingCatalog] = useState(false); const [catalogStatus, setCatalogStatus] = useState(null); const [importOpen, setImportOpen] = useState(false); @@ -144,6 +151,9 @@ export function ModelConfigPage() { ], null), [settings?.imageGenerationModels, settings?.imageGeneration, providers], ); + + // Every configured model the page can offer for delegation, in service order. + const subagentModels = useMemo(() => subagentModelCandidates(providers), [providers]); const providerReady = (provider: ProviderPublic) => providerServesChatModels(provider, imageGenerationCandidates); @@ -285,9 +295,53 @@ export function ModelConfigPage() { variant: "error", }); } finally { + setChangingImageModel(false); } }; + /** + * Persist one delegation opt-in. + * + * The flag lives on the service's own model binding, so this is the same write + * the provider dialog performs. It must start from the stored service rather + * than this page's snapshot: `providers.update` replaces the whole model list, + * and a stale list would drop a model another window added in the meantime. + */ + const setSubagentDelegation = async ( + candidate: SubagentModelCandidate, + selected: boolean, + ) => { + setChangingSubagentModel(true); + try { + const { providers: stored } = await api.listProviders(); + const provider = stored.find((entry) => entry.id === candidate.providerId); + // A plugin-owned row is refused by the RPC, and a model that disappeared + // from the stored bindings has no flag left to write. + const writable = + !!provider && + !provider.ownerPluginId && + provider.models.some((model) => model.id === candidate.modelId); + if (!provider || !writable) { + showToast(t("settings.subagentModelSaveFailed"), { variant: "error" }); + return; + } + await api.updateProvider({ + id: provider.id, + models: subagentModelOptInDraft(provider.models, candidate.modelId, selected), + }); + await refreshProviders(); + showToast( + t(selected ? "settings.subagentModelSelected" : "settings.subagentModelUnselected"), + { variant: "success" }, + ); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), { + variant: "error", + }); + } finally { + setChangingSubagentModel(false); + } + }; const toggleEnabled = async (provider: ProviderPublic) => { setBusyId(provider.id); @@ -555,6 +609,20 @@ export function ModelConfigPage() { ) : null} + {subagentModels.length > 0 ? ( +
+
+ + void setSubagentDelegation(candidate, selected) + } + /> +
+
+ ) : null} + {setupFor !== null ? ( void; +}) { + const { t } = useTranslation(); + const selected = selectedSubagentModels(candidates); + // Nothing configured yet: the page hides this row rather than offering an + // editor with no rows in it. + if (candidates.length === 0) return null; + const groups = groupSubagentModels(candidates); + + return ( + <> +
+
+
+ {t("settings.subagentModels")} +
+
+ {selected.length > 0 ? ( + selected.map((candidate) => ( + + {candidate.providerName} + + · + + {candidate.label} + + )) + ) : ( + + {t("settings.subagentModelsUnset")} + + )} +
+
+
+ +

{t("settings.subagentModelsHint")}

+
+ {groups.map((group) => ( +
+
{group.providerName}
+ {group.candidates.map((candidate) => ( + + + {candidate.label} + + {!candidate.editable ? ( + + {t("settings.subagentModelPluginManaged")} + + ) : candidate.selected && candidate.blocked ? ( + + {t( + candidate.blocked === "provider-disabled" + ? "settings.subagentModelProviderDisabled" + : "settings.subagentModelCredentialMissing", + )} + + ) : null} + + } + onChange={(event) => onToggle(candidate, event.target.checked)} + /> + ))} +
+ ))} +
+
+ + ); +} diff --git a/apps/desktop/src/components/settings/subagent-delegation.ts b/apps/desktop/src/components/settings/subagent-delegation.ts new file mode 100644 index 0000000000..1ee4b50ef5 --- /dev/null +++ b/apps/desktop/src/components/settings/subagent-delegation.ts @@ -0,0 +1,165 @@ +/** + * The Models page's subagent-delegation list. + * + * `ModelBinding.availableForSubagents` is the only source of truth for the + * delegation catalog: the runtime builds that catalog from it at launch + * (`packages/host-runtime/src/launch-resolver.ts`) and authorizes every + * `Task.model` override against the same catalog. This page therefore edits + * those flags themselves — one list across every configured service — instead + * of keeping a second selection the runtime would have to merge, so what the + * page shows is exactly what the AI may pick. + * + * The helpers stay free of runtime imports so the catalog rules can be tested + * without the application bundle. + */ +import type { ModelBinding, ProviderPublic } from "@pi-desktop/shared"; + +/** Why an opted-in model cannot reach the catalog today. */ +export type SubagentModelBlockedReason = "provider-disabled" | "credential-missing"; + +/** One configured model, with the delegation state the page renders. */ +export type SubagentModelCandidate = { + providerId: string; + providerName: string; + modelId: string; + /** The alias the rest of Settings shows, else the wire id. */ + label: string; + /** True when this binding carries the delegation opt-in. */ + selected: boolean; + /** + * False only when a plugin owns the service row: the plugin refreshes it on + * every load and `providers.update` refuses it. A service that is merely off + * stays editable so its opt-in can be prepared before it is switched on. + */ + editable: boolean; + /** Set when the opt-in exists but the service cannot serve it yet. */ + blocked?: SubagentModelBlockedReason; +}; + +export type SubagentModelGroup = { + providerId: string; + providerName: string; + candidates: SubagentModelCandidate[]; +}; + +/** The provider facts the delegation rules read; anything else is ignored. */ +export type SubagentProviderFacts = { + enabled: boolean; + hasSecret?: boolean; + hasOauth?: boolean; + authKind?: string; +}; + +/** + * Whether this service can serve a delegated run at all. + * + * Same rule the subagent editor's own model picker applies to the Composer's + * runnable services: the row must be enabled and hold a usable credential (an + * API key, a vendor-account login, or a keyless local endpoint). + */ +export function providerServesSubagentDelegation(provider: SubagentProviderFacts): boolean { + return ( + provider.enabled && + (provider.hasSecret === true || + provider.hasOauth === true || + provider.authKind === "none") + ); +} + +function blockedReason(provider: SubagentProviderFacts): SubagentModelBlockedReason | undefined { + if (!provider.enabled) return "provider-disabled"; + return providerServesSubagentDelegation(provider) ? undefined : "credential-missing"; +} + +function candidateKey(providerId: string, modelId: string): string { + // Wire ids travel the request path, so identity is case-insensitive here the + // same way `sameWireId` treats them elsewhere in Settings. + return `${providerId}\u0000${modelId.trim().toLowerCase()}`; +} + +function modelLabel(binding: ModelBinding): string { + const alias = binding.alias?.trim(); + return alias && alias.length > 0 ? alias : binding.id; +} + +/** + * Every configured model, in service order, with its delegation state. + * + * A model is listed even when its service is off or missing a credential: the + * flag is a durable choice, and hiding it would leave an opt-in the user cannot + * see or withdraw. + */ +export function subagentModelCandidates( + providers: readonly ProviderPublic[], +): SubagentModelCandidate[] { + const candidates: SubagentModelCandidate[] = []; + const seen = new Set(); + for (const provider of providers) { + const blocked = blockedReason(provider); + for (const binding of provider.models ?? []) { + const modelId = binding.id.trim(); + if (!modelId) continue; + const key = candidateKey(provider.id, modelId); + if (seen.has(key)) continue; + seen.add(key); + candidates.push({ + providerId: provider.id, + providerName: provider.name, + modelId, + label: modelLabel(binding), + selected: binding.availableForSubagents === true, + editable: !provider.ownerPluginId, + ...(blocked ? { blocked } : {}), + }); + } + } + return candidates; +} + +/** The opted-in rows, which are the delegation catalog's own membership. */ +export function selectedSubagentModels( + candidates: readonly SubagentModelCandidate[], +): SubagentModelCandidate[] { + return candidates.filter((candidate) => candidate.selected); +} + +/** Group in service order so one service's models read as one block. */ +export function groupSubagentModels( + candidates: readonly SubagentModelCandidate[], +): SubagentModelGroup[] { + const groups: SubagentModelGroup[] = []; + for (const candidate of candidates) { + const last = groups[groups.length - 1]; + if (last && last.providerId === candidate.providerId) last.candidates.push(candidate); + else { + groups.push({ + providerId: candidate.providerId, + providerName: candidate.providerName, + candidates: [candidate], + }); + } + } + return groups; +} + +/** + * The service's bindings with one delegation opt-in changed. + * + * A cleared opt-in drops the key instead of storing `false`, matching the + * provider dialog: the field is absent on every binding that never wanted + * delegation, and `availableForSubagents: false` never accumulates on records. + * Every other field is carried over untouched, because the RPC replaces the + * whole model list. + */ +export function subagentModelOptInDraft( + models: readonly ModelBinding[], + modelId: string, + selected: boolean, +): ModelBinding[] { + const target = modelId.trim().toLowerCase(); + return models.map((model) => + model.id.trim().toLowerCase() === target + ? { ...model, availableForSubagents: selected ? true : undefined } + : model, + ); +} diff --git a/apps/desktop/src/components/settings/subagent-models.ts b/apps/desktop/src/components/settings/subagent-models.ts index c27515aa68..495da4e616 100644 --- a/apps/desktop/src/components/settings/subagent-models.ts +++ b/apps/desktop/src/components/settings/subagent-models.ts @@ -7,6 +7,7 @@ */ import { modelWireIdsEqual as sameComposerModelId, type ProviderPublic } from "@pi-desktop/shared"; import { defaultModelOptions } from "./default-model"; +import { providerServesSubagentDelegation } from "./subagent-delegation"; export type SubagentModelChoice = { /** Frontmatter `model:` value. */ @@ -25,10 +26,7 @@ export type SubagentModelChoiceGroup = { /** True when the Composer would offer this provider's configured models. */ export function isSubagentModelProvider(provider: ProviderPublic): boolean { - return ( - provider.enabled && - (provider.hasSecret || Boolean(provider.hasOauth) || provider.authKind === "none") - ); + return providerServesSubagentDelegation(provider); } /** diff --git a/apps/desktop/src/styles/model-config.css b/apps/desktop/src/styles/model-config.css index 0817ac04b4..3ca7701dac 100644 --- a/apps/desktop/src/styles/model-config.css +++ b/apps/desktop/src/styles/model-config.css @@ -1903,3 +1903,71 @@ @media (prefers-reduced-motion: reduce) { .model-provider-row { transition: none; } } + +/* ------------------------------------------- subagent delegation list */ + +/* + One durable opt-in list across every service, edited in place. The summary row + on the Models page states today's answer; this tile is how it changes. The list + scrolls instead of growing the page, because a configured service can publish + dozens of models. +*/ +.model-subagent-chip { + display: inline-flex; + min-width: 0; + align-items: baseline; + gap: 7px; +} + +.model-subagent-editor { + /* The summary's hover fill must not square off the tile's rounded corners. */ + border-radius: var(--radius-md-plus); + background: var(--ds-tile); + overflow: hidden; +} + +.model-subagent-hint { + margin: 0; + padding: 0 16px 10px; + color: var(--ds-text-secondary); + font-size: var(--text-sm); + line-height: var(--leading-body); +} + +.model-subagent-options { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 320px; + padding: 0 16px 14px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.model-subagent-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.model-subagent-group-title { + color: var(--ds-text-muted); + font-size: var(--text-2xs); + font-weight: var(--font-weight-medium); + letter-spacing: var(--tracking-wide); + line-height: var(--leading-normal); +} + +.model-subagent-option-label { + color: var(--ds-text-primary); + font-size: var(--text-sm); +} + +/* A note rides beside the label: why a row is fixed, or why its opt-in is idle. */ +.model-subagent-option-note { + margin-left: 6px; + color: var(--ds-text-muted); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + line-height: var(--leading-normal); +} diff --git a/apps/desktop/test/subagent-delegation.test.mjs b/apps/desktop/test/subagent-delegation.test.mjs new file mode 100644 index 0000000000..f003ed111c --- /dev/null +++ b/apps/desktop/test/subagent-delegation.test.mjs @@ -0,0 +1,201 @@ +/** + * The Models page states which models the AI may pick when it delegates, and + * edits the same `availableForSubagents` opt-in the runtime reads. These tests + * pin that mapping: the list must never invent a delegation candidate, hide an + * opt-in the user cannot withdraw, or write a model list that loses another + * window's edit. + */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { register } from "node:module"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); +const { + groupSubagentModels, + providerServesSubagentDelegation, + selectedSubagentModels, + subagentModelCandidates, + subagentModelOptInDraft, +} = await import("../src/components/settings/subagent-delegation.ts"); + +const binding = (id, over = {}) => ({ + id, + contextWindow: 128_000, + maxTokens: 8_192, + thinkingLevels: [], + defaultThinkingLevel: null, + ...over, +}); + +const provider = (over = {}) => ({ + id: "p1", + name: "Anthropic", + vendorKey: "anthropic", + enabled: true, + hasSecret: true, + hasOauth: false, + authKind: "api_key_and_base_url", + models: [binding("claude-haiku-4-5"), binding("claude-sonnet-4-6")], + ...over, +}); + +test("a service serves delegation exactly when it is enabled with a usable credential", () => { + assert.equal(providerServesSubagentDelegation(provider()), true); + assert.equal(providerServesSubagentDelegation(provider({ enabled: false })), false); + assert.equal( + providerServesSubagentDelegation(provider({ hasSecret: false, hasOauth: false })), + false, + ); + assert.equal( + providerServesSubagentDelegation(provider({ hasSecret: false, hasOauth: true })), + true, + ); + assert.equal( + providerServesSubagentDelegation( + provider({ hasSecret: false, hasOauth: false, authKind: "none", baseUrl: "http://127.0.0.1:11434" }), + ), + true, + ); +}); + +test("every configured model is listed in service order with its opt-in state", () => { + const providers = [ + provider({ models: [binding("a", { availableForSubagents: true }), binding("b")] }), + provider({ id: "p2", name: "Local", models: [binding("c", { availableForSubagents: true })] }), + ]; + assert.deepEqual(subagentModelCandidates(providers), [ + { providerId: "p1", providerName: "Anthropic", modelId: "a", label: "a", selected: true, editable: true }, + { providerId: "p1", providerName: "Anthropic", modelId: "b", label: "b", selected: false, editable: true }, + { providerId: "p2", providerName: "Local", modelId: "c", label: "c", selected: true, editable: true }, + ]); +}); + +test("the alias is the label, and the wire id stays the identity", () => { + const [candidate] = subagentModelCandidates([ + provider({ models: [binding("claude-haiku-4-5", { alias: " Quick review " })] }), + ]); + assert.equal(candidate.label, "Quick review"); + assert.equal(candidate.modelId, "claude-haiku-4-5"); + // A blank alias falls back to the id rather than rendering an empty row. + const [blank] = subagentModelCandidates([ + provider({ models: [binding("model-x", { alias: " " })] }), + ]); + assert.equal(blank.label, "model-x"); +}); + +test("an opt-in on a service that is off or has no credential stays visible and withdrawable", () => { + const disabled = subagentModelCandidates([ + provider({ enabled: false, models: [binding("a", { availableForSubagents: true })] }), + ]); + assert.equal(disabled[0].selected, true); + assert.equal(disabled[0].editable, true); + assert.equal(disabled[0].blocked, "provider-disabled"); + + const keyless = subagentModelCandidates([ + provider({ hasSecret: false, models: [binding("a", { availableForSubagents: true })] }), + ]); + assert.equal(keyless[0].editable, true); + assert.equal(keyless[0].blocked, "credential-missing"); + + // A service that is ready carries no reason at all, so the editor adds no note. + const [ready] = subagentModelCandidates([provider()]); + assert.equal("blocked" in ready, false); +}); + +test("a plugin-owned service is read-only because the RPC refuses it", () => { + const [candidate] = subagentModelCandidates([ + provider({ ownerPluginId: "acme.catalogue", models: [binding("a", { availableForSubagents: true })] }), + ]); + assert.equal(candidate.selected, true); + assert.equal(candidate.editable, false); +}); + +test("duplicate and blank ids never become two rows", () => { + const candidates = subagentModelCandidates([ + provider({ + models: [binding("a", { availableForSubagents: true }), binding("A"), binding(" ")], + }), + ]); + assert.equal(candidates[0].selected, true); +}); + +test("the section groups rows by service in the order the page renders", () => { + const candidates = subagentModelCandidates([ + provider({ models: [binding("a"), binding("b")] }), + provider({ id: "p2", name: "Local", models: [binding("c")] }), + ]); + assert.deepEqual( + groupSubagentModels(candidates).map((group) => ({ + providerName: group.providerName, + models: group.candidates.map((candidate) => candidate.modelId), + })), + [ + { providerName: "Anthropic", models: ["a", "b"] }, + { providerName: "Local", models: ["c"] }, + ], + ); + assert.deepEqual(selectedSubagentModels(candidates), []); +}); + +test("the write sets the opt-in and clears it by dropping the key", () => { + const stored = [ + binding("a", { alias: "Fast", contextWindowSource: "user", supportsImages: false }), + binding("b", { availableForSubagents: false }), + ]; + const optedIn = subagentModelOptInDraft(stored, "a", true); + assert.deepEqual(optedIn[0], { + id: "a", + contextWindow: 128_000, + maxTokens: 8_192, + thinkingLevels: [], + defaultThinkingLevel: null, + alias: "Fast", + contextWindowSource: "user", + supportsImages: false, + availableForSubagents: true, + }); + // The untouched binding keeps its identity, so the RPC does not rewrite it. + assert.equal(optedIn[1], stored[1]); + + const cleared = subagentModelOptInDraft(optedIn, "a", false); + // Cleared means absent on the wire, which is what keeps `false` from piling up + // on records that never wanted delegation. + assert.equal(cleared[0].availableForSubagents, undefined); + assert.equal(JSON.stringify(cleared[0]).includes("availableForSubagents"), false); + assert.equal(cleared[0].alias, "Fast"); +}); + +test("the write matches the wire id case-insensitively and leaves an absent id alone", () => { + const stored = [binding("Claude-Haiku-4-5")]; + assert.equal(subagentModelOptInDraft(stored, "claude-haiku-4-5", true)[0].availableForSubagents, true); + const untouched = subagentModelOptInDraft(stored, "not-configured", true); + assert.equal(untouched[0], stored[0]); +}); + +test("the page mounts the row and writes the flag through the provider, not a second list", async () => { + const page = await readFile( + join(here, "../src/components/settings/ModelConfigPage.tsx"), + "utf8", + ); + assert.match(page, /