diff --git a/apps/desktop/src/components/settings/ModelConfigPage.tsx b/apps/desktop/src/components/settings/ModelConfigPage.tsx index ac41c47bb2..c8a670be9f 100644 --- a/apps/desktop/src/components/settings/ModelConfigPage.tsx +++ b/apps/desktop/src/components/settings/ModelConfigPage.tsx @@ -32,6 +32,13 @@ 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 { SubagentModelsDialog } from "./SubagentModelsDialog"; import { OAuthLoginDialog } from "./OAuthLoginDialog"; import { ProviderSetupDialog } from "./ProviderSetupDialog"; import { ServiceList } from "./ServiceList"; @@ -110,6 +117,8 @@ export function ModelConfigPage() { const rowBusy = useRowBusy(); const testingRows = useRowBusy(); const [changingImageModel, setChangingImageModel] = useState(false); + const [changingSubagentModel, setChangingSubagentModel] = useState(false); + const [subagentEditorOpen, setSubagentEditorOpen] = useState(false); const [refreshingCatalog, setRefreshingCatalog] = useState(false); const [catalogStatus, setCatalogStatus] = useState(null); const [importOpen, setImportOpen] = useState(false); @@ -148,6 +157,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); @@ -289,9 +301,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) => { if (!rowBusy.begin(provider.id)) return; @@ -559,6 +615,32 @@ export function ModelConfigPage() { ) : null} + {subagentModels.length > 0 ? ( +
+
+ setSubagentEditorOpen(true)} + /> +
+
+ ) : null} + + {subagentEditorOpen ? ( + + void setSubagentDelegation(candidate, selected) + } + onEdit={(providerId) => { + setSubagentEditorOpen(false); + setSetupFor(providerId); + }} + onClose={() => setSubagentEditorOpen(false)} + /> + ) : null} + {setupFor !== null ? ( void; +}) { + const { t } = useTranslation(); + const selected = selectedSubagentModels(candidates); + const names = selected.map((candidate) => candidate.label); + const identity = names.join("\u0000"); + const measureRef = useRef(null); + const [fit, setFit] = useState({ visible: names.length, hidden: 0 }); + + useLayoutEffect(() => { + if (typeof ResizeObserver === "undefined") return; + const element = measureRef.current; + if (!element) return; + const compute = () => { + const chips = Array.from(element.children) as HTMLElement[]; + if (chips.length < 2) return; + const moreChip = chips[chips.length - 1]; + const widths = chips.slice(0, -1).map((chip) => chip.getBoundingClientRect().width); + const rowWidth = element.parentElement?.getBoundingClientRect().width ?? 0; + setFit( + fitSubagentChips({ + widths, + gap: CHIP_GAP, + available: Math.max(0, rowWidth * CHIP_SHARE - CHIP_SAFETY), + moreWidth: moreChip.getBoundingClientRect().width, + }), + ); + }; + compute(); + const observer = new ResizeObserver(compute); + if (element.parentElement) observer.observe(element.parentElement); + return () => observer.disconnect(); + }, [identity]); + + // Nothing configured yet: the page hides this row rather than offering an + // editor with no models in it. + if (candidates.length === 0) return null; + + return ( +
+
+
+ {t("settings.subagentModels")} +
+
+ {selected.length === 0 ? ( + + {t("settings.subagentModelsUnset")} + + ) : ( + + {names.slice(0, fit.visible).map((name) => ( + + {name} + + ))} + {fit.hidden > 0 ? ( + + {`+${fit.hidden}`} + + ) : null} + + )} + {/* Off-screen twin: the only way to know how wide each chip is. */} + +
+
+
+ +
+
+ ); +} diff --git a/apps/desktop/src/components/settings/SubagentModelsDialog.tsx b/apps/desktop/src/components/settings/SubagentModelsDialog.tsx new file mode 100644 index 0000000000..92d4b8a604 --- /dev/null +++ b/apps/desktop/src/components/settings/SubagentModelsDialog.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { formatTokenCount } from "@pi-desktop/shared"; +import { useBlockingOverlay } from "../../lib/blocking-overlay"; +import { portalToBody } from "../../lib/portal-visibility"; +import { IconClose, IconSearch } from "../icons"; +import { Button, OverlayBackdrop, TooltipButton } from "../ui"; +import { selectedSubagentModels, type SubagentModelCandidate } from "./subagent-delegation"; + +/** + * The subagent delegation editor: the models the AI may pick when it delegates, + * listed the way a service lists its added models — same search field, same row, + * same Advanced button, same remove control. + * + * Adding is not this dialog's job: a model joins the list by ticking **Available + * for AI delegation** in that model's own editor, which is exactly where Advanced + * goes. Both surfaces read and write `availableForSubagents` on the binding, so + * neither can drift from the other. + */ +export function SubagentModelsDialog({ + candidates, + busy = false, + onChange, + onEdit, + onClose, +}: { + candidates: SubagentModelCandidate[]; + busy?: boolean; + onChange: (candidate: SubagentModelCandidate, selected: boolean) => void; + /** Opens the owning service's model editor, where the opt-in is ticked. */ + onEdit: (providerId: string) => void; + onClose: () => void; +}) { + useBlockingOverlay(); + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const selected = selectedSubagentModels(candidates); + const needle = query.trim().toLowerCase(); + const visible = needle + ? selected.filter((candidate) => + `${candidate.providerName} ${candidate.label}`.toLowerCase().includes(needle), + ) + : selected; + const name = (candidate: SubagentModelCandidate) => + `${candidate.providerName} · ${candidate.label}`; + + const dialog = ( + { + if (event.target === event.currentTarget) onClose(); + }} + > +
event.stopPropagation()} + > +
+
+

+ {t("settings.subagentModels")} +

+

+ {t("settings.subagentModelAdd")} +

+
+ + + +
+ +
+
+

{t("settings.subagentModels")}

+ {selected.length} +
+ + setQuery(event.target.value)} + /> +
+
+ {selected.length === 0 ? ( +
{t("settings.subagentModelsUnset")}
+ ) : visible.length === 0 ? ( +
{t("settings.noChosenModelMatches")}
+ ) : ( +
    + {visible.map((candidate) => ( +
  • +
    + + {candidate.label} + + {candidate.providerName} + + {formatTokenCount(candidate.contextWindow)} ·{" "} + {formatTokenCount(candidate.maxTokens)} + + {candidate.blocked ? ( + + {t( + candidate.blocked === "provider-disabled" + ? "settings.subagentModelProviderDisabled" + : "settings.subagentModelCredentialMissing", + )} + + ) : null} + + {candidate.editable ? ( + onChange(candidate, false)} + > + + + ) : ( + + {t("settings.subagentModelPluginManaged")} + + )} +
    +
  • + ))} +
+ )} +
+ +
+ +
+
+
+ ); + + return typeof document === "undefined" ? dialog : portalToBody(dialog); +} 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..0885346088 --- /dev/null +++ b/apps/desktop/src/components/settings/subagent-delegation.ts @@ -0,0 +1,198 @@ +/** + * 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 list therefore reads and + * clears that flag on the binding itself — the same field a service's model + * editor writes — so the page and the catalog cannot disagree, and a change made + * in either place is visible in both. + * + * 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; + /** The binding's limits, shown the way a service's model list shows them. */ + contextWindow: number; + maxTokens: number; + /** 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; +}; + +/** 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") + ); +} + +/** + * Why an opt-in cannot reach the catalog yet. + * + * The rule is credential *presence*, not auth-kind identity: `hasSecret` is the + * host's own "this row holds a usable credential" flag (an API key or a vendor + * account), so an auth flow added later still counts here without teaching this + * page about it. Only the keyless local endpoint is named explicitly, because + * that is the one case where no credential is expected — which is why a row that + * reports no credential at all really is unusable, rather than a guess at why. + */ +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), + contextWindow: binding.contextWindow, + maxTokens: binding.maxTokens, + 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); +} + +/** + * 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, + ); +} + +/** How many chips fit on one line, and how many collapse into `+N`. */ +export type ChipFit = { visible: number; hidden: number }; + +/** + * Fit chips into a bounded line, reserving room for the `+N` chip when not + * everything fits. + * + * Widths are measured by the caller (fonts and zoom decide them), so the rule + * itself stays pure and testable: fill in order, then give back trailing chips + * until `+N` fits beside them. One chip always survives, so a very narrow row + * still says what it is about instead of showing only `+6`. + */ +export function fitSubagentChips(input: { + widths: readonly number[]; + gap: number; + available: number; + moreWidth: number; +}): ChipFit { + const { widths, gap, available, moreWidth } = input; + if (widths.length === 0) return { visible: 0, hidden: 0 }; + + let used = 0; + let visible = 0; + for (let index = 0; index < widths.length; index += 1) { + const next = used + (index > 0 ? gap : 0) + widths[index]; + if (next > available) break; + used = next; + visible += 1; + } + if (visible === widths.length) return { visible, hidden: 0 }; + + let hidden = widths.length - visible; + while (visible > 1 && used + gap + moreWidth > available) { + visible -= 1; + used -= widths[visible] + gap; + hidden += 1; + } + // A very narrow row still names one model; the container clips the overflow + // rather than showing a row that only says `+6`. + if (visible === 0) return { visible: 1, hidden: widths.length - 1 }; + return { visible, hidden }; +} 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 d98a2962cf..bab1fc9b99 100644 --- a/apps/desktop/src/styles/model-config.css +++ b/apps/desktop/src/styles/model-config.css @@ -1921,3 +1921,88 @@ @media (prefers-reduced-motion: reduce) { .model-provider-row { transition: none; } } + +/* --------------------------------------- subagent delegation model list */ + +/* + The Models page carries one quiet line: the delegated model names and an Edit + action. The full list — search, limits, Advanced, remove — lives in the dialog + that Edit opens, reusing the service model list's own parts (`.provider-chosen-*`). +*/ +.model-subagent-chips { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 60%; + flex-wrap: nowrap; + overflow: hidden; +} + +.model-subagent-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-lg); + background: var(--ds-bg-chip); + color: var(--ds-text-secondary); + font-size: var(--text-2xs); + line-height: var(--leading-normal); + white-space: nowrap; +} + +.model-subagent-chip-more { + color: var(--ds-text-muted); +} + +/* Off-screen twin used to measure chips at the current font and zoom. */ +.model-subagent-measure { + position: absolute; + display: inline-flex; + gap: 5px; + visibility: hidden; + pointer-events: none; + white-space: nowrap; +} + +.model-subagent-dialog { + display: flex; + flex-direction: column; + gap: 12px; + width: min(720px, calc(100vw - 48px)); + max-height: min(720px, calc(100vh - 64px)); + padding: 16px; +} + +.model-subagent-panel { + min-height: 0; + overflow: hidden; + border-radius: var(--radius-md); + background: var(--ds-tile); + padding: 4px 0 6px; +} + +.model-subagent-dialog .provider-chosen-list { + max-height: 420px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.model-subagent-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.model-subagent-provider { + color: var(--ds-text-muted); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + line-height: var(--leading-normal); +} + +.model-subagent-note { + 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..acd1697ad8 --- /dev/null +++ b/apps/desktop/test/subagent-delegation.test.mjs @@ -0,0 +1,272 @@ +/** + * 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 { + fitSubagentChips, + 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", contextWindow: 128_000, maxTokens: 8_192, selected: true, editable: true }, + { providerId: "p1", providerName: "Anthropic", modelId: "b", label: "b", contextWindow: 128_000, maxTokens: 8_192, selected: false, editable: true }, + { providerId: "p2", providerName: "Local", modelId: "c", label: "c", contextWindow: 128_000, maxTokens: 8_192, 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("an unfamiliar auth kind is judged by the credential it holds, not by its name", () => { + // Review note on #1758: a future auth flow must not be labelled "no + // credential" merely because this page does not know its name. The rule reads + // credential presence, so a stored grant still counts. + const stored = subagentModelCandidates([ + provider({ + authKind: "device_code", + hasSecret: true, + hasOauth: false, + models: [binding("a")], + }), + ]); + assert.equal("blocked" in stored[0], false); + + // Nothing stored: the runtime cannot obtain a key for it either, so the label + // states the missing credential instead of guessing at the flow. + const empty = subagentModelCandidates([ + provider({ + authKind: "device_code", + hasSecret: false, + hasOauth: false, + models: [binding("a")], + }), + ]); + assert.equal(empty[0].blocked, "credential-missing"); + assert.equal(empty[0].editable, true); +}); + +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 list carries the limits a service's own model row shows", () => { + const [candidate] = subagentModelCandidates([ + provider({ + models: [ + binding("a", { + contextWindow: 1_000_000, + maxTokens: 128_000, + availableForSubagents: true, + }), + ], + }), + ]); + assert.equal(candidate.contextWindow, 1_000_000); + assert.equal(candidate.maxTokens, 128_000); + assert.equal(candidate.selected, true); + assert.deepEqual( + selectedSubagentModels([candidate]).map((entry) => entry.modelId), + ["a"], + ); +}); + + +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, / { + // All five fit: 60+5+60+5+60+5+60+5+60 = 320 within 400. + assert.deepEqual( + fitSubagentChips({ widths: [60, 60, 60, 60, 60], gap: 5, available: 400, moreWidth: 30 }), + { visible: 5, hidden: 0 }, + ); + // Three fit (190), but `+N` needs 30 more than the 210 available, so the + // last two collapse: two chips plus `+3`. + assert.deepEqual( + fitSubagentChips({ widths: [60, 60, 60, 60, 60], gap: 5, available: 210, moreWidth: 30 }), + { visible: 2, hidden: 3 }, + ); + // A row too narrow for even one chip still names one model. + assert.deepEqual( + fitSubagentChips({ widths: [60, 60], gap: 5, available: 40, moreWidth: 30 }), + { visible: 1, hidden: 1 }, + ); + assert.deepEqual( + fitSubagentChips({ widths: [], gap: 5, available: 100, moreWidth: 30 }), + { visible: 0, hidden: 0 }, + ); +}); diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index aaa0bc144a..e00c5ad32d 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -947,8 +947,9 @@ core set rather than the on-demand catalog of §7.1: session model. The parent agent sees a model summary in the system prompt listing all models marked `availableForSubagents` in provider settings. The empty-catalog system summary, Task description and rejected-override error - point to Settings → Models → edit service/account → model Advanced → - "Available for AI delegation" → save, and require an exact catalog key + point to the **Subagent models** row on Settings → Models — or to the same flag + under Settings → Models → edit service/account → model Advanced → "Available + for AI delegation" → save — and require an exact catalog key rather than guessed provider/model keys. If the delegation catalog is empty, the prompt tells the model to omit `model` and use the definition pin, or inherit the session model when unpinned; an diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index 8f4402699a..f97e64ed0e 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -422,6 +422,15 @@ opted-in account uses its exact provider ID as the override key. Selection prior for delegated work, not only `Task.model`: a `session/collaboration/spawn` `modelKey` naming a model without it is refused with `PERMISSION_DENIED`, while omitting the key, or naming the default model's own key, still inherits. +The flag's editing surface is not tied to the provider dialog. Settings → Models +lists the opted-in bindings in one **Subagent models** row — each row's Advanced +action opens the service editor where the opt-in is ticked — and reads the same +field and rewrites only the touched binding through `providers.update` +(`apps/desktop/src/components/settings/subagent-delegation.ts`). That row stores +no second selection, so the delegation catalog and the page cannot disagree, and +a service that is off, credential-less or plugin-owned is listed with its reason +instead of disappearing. + ## 8. Secrets - API keys stored via secure storage (`SECRET_*` APIs) diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index 5c31eb36aa..94296e1d52 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -445,9 +445,18 @@ a usage tab. ### Model configuration (`agent` tab) - The model configuration page groups its sections in this order: **AI - services** (including model-catalog status and refresh), **Jev**, then the - image-generation model selector. The sections use one consistent vertical - spacing rhythm. + services** (including model-catalog status and refresh), **Jev**, the +- **Subagent models** is one settings row, below the image-generation selector. + It lists only the models that carry the `availableForSubagents` opt-in — the + delegation catalog the parent agent sees — as `provider · model` entries with a + remove action each, or an empty-state line when none does. Adding is the row's + own control: a menu beside the list offers exactly the configured models that + as `provider · model` entries with the same Advanced action and remove control + a service's own model list has, or an empty-state line when none does. + Advanced opens that service's model editor — where the opt-in is ticked — so + adding a model happens in the model editor, never from this row. It reads and + writes `ModelBinding.availableForSubagents`, never a second + The row is hidden while no service has a configured model. - **Defaults** card: a compact settings row shows the provider name and exact model ID beneath the Default model label. A quiet Change action opens the picker without duplicating the current value. The picker groups model-level diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 3de443c3fc..e9ec0a87fb 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10639,6 +10639,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | Acceptance | Scenarios | |---|---| +| C / E / Quality — Subagent delegation catalog on the Models page | [E2E-268](#e2e-268-models-page-states-and-edits-the-subagent-delegation-catalog) | | C / E / Security / Quality — Gemini work setup | E2E-LIVE-VOICE-gemini-work-schema | | C / Quality — Continue after malformed tool output | E2E-RUNTIME-malformed-tool-call-continue-recovery | | B / Security / Quality — Optional provider client certificate requests | E2E-PROVIDER-optional-client-certificate | @@ -18951,6 +18952,47 @@ host-created files. The full app's file-preview viewer is covered separately. `apps/desktop/test/main-wiring-contract.test.mjs`; the interactive Electron scenario remains a manual validation run. +### E2E-268: Models page states and edits the subagent delegation catalog + +- **Preconditions**: Two AI services are configured and enabled with + credentials, the first with two models and the second with one; exactly one + model carries "Available for AI delegation". A parent Agent-mode session can + delegate a task. +- **Steps**: + 1. Open Settings → Models and read the **Subagent models** row at the bottom. + 2. Open the second service's model editor from the row's Advanced action, tick + "Available for AI delegation", save, and re-read the list. + 3. Start a parent turn and inspect the delegation model summary the Task tool + receives. + 4. Return to the row, remove that model with its × action, and re-read the + list. + 5. Turn the second service off and re-read the list. + 6. Toggle "Available for AI delegation" in the first service's own model + dialog and return to the Models page. +- **Expected**: The row lists exactly the opted-in models as `provider · model` + and shows its empty-state line when none is opted in; a model joins the list + only through that editor's tick. Adding writes + `availableForSubagents` on that service's binding and the next parent turn + lists the model in the delegation catalog; removing it drops the model from the + catalog and leaves no `false` behind on the stored binding. A service that is + off or holds no credential keeps its listed models visible and removable, + annotated with the reason they are idle, while its models stay out of the + catalog. The provider dialog and the row show the same flag in both + directions, so neither surface can drift. A plugin-owned service row renders + read-only. The row is hidden while no service has a configured model, and a + refused save reports the error instead of silently keeping the old value. +- **Specs linked**: `03-runtime/02-agent-runtime.md` §5f, + `03-runtime/11-provider-model-system.md` §7, `04-ux/06-settings-ia.md` + (Model configuration), D278, ADR subagent-model-opt-in, issue #1736. +- **Acceptance**: C / E / Quality — the catalog the parent agent may pick from is + reviewable and editable in one place on the Models page without adding a + second stored selection, and every row states why an opted-in model is not in + effect. +- **Milestone**: Post-MVP model configuration. +- **Status**: Unit coverage in `apps/desktop/test/subagent-delegation.test.mjs` + (candidate construction, grouping, read-only and blocked states, and the exact + provider write). The interactive save → next-turn catalog path remains Draft + and is run only when requested in a capable environment. ### E2E-FAST-MODE: `/fast` asks for the priority tier only on an allowed model - **Preconditions**: A ChatGPT subscription (Codex) account and a Responses diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md index a8694c9f99..27539006d8 100644 --- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md +++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md @@ -684,8 +684,9 @@ Frontmatter 新增 `permission: inherit | ask | accept-edits | auto`(默认 运行中覆盖该委托的模型。解析优先级:Task.model 参数 → 定义 frontmatter 的 引脚 → 会话模型。父 agent 会在系统提示中看到一份模型摘要,列出提供商设置里 所有标记为 `availableForSubagents` 的模型。空目录下的系统摘要、Task 说明和 - 覆盖请求错误均指向「设置 → 模型 → 编辑服务或账号 → 模型高级设置 → - 可供 AI 自动调度 → 保存」,要求使用目录中的准确键,避免猜测 provider/model。 + 覆盖请求错误均指向设置 → 模型页的 **子代理模型** 行(或同一开关的 + 「设置 → 模型 → 编辑服务或账号 → 模型高级设置 → 可供 AI 自动调度 → 保存」), + 要求使用目录中的准确键,避免猜测 provider/model。 若委托目录为空,提示会告诉模型 省略 `model`,使用定义的固定模型,无固定模型时继承会话模型;显式给出的键如果正好就是当前会话的 provider/model,同样按继承处理。其他显式模型键必须已配置并已为委托启用。 diff --git a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md index d7001b5f24..37168a4fc2 100644 --- a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md +++ b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md @@ -351,6 +351,13 @@ provider id/vendor/name 查找,不得用另一账号凭据覆盖固定模型 而不只是 `Task.model`:`session/collaboration/spawn` 的 `modelKey` 指向未勾选的模型时 以 `PERMISSION_DENIED` 拒绝,省略该键或写出默认模型自己的键仍按继承处理。 +该标志的编辑入口不限于提供商对话框。设置 → 模型在同一处用一列 **子代理模型** +列出已加入的绑定(每行的“高级”打开勾选该选项的服务模型编辑器),读取同一个字段,并只通过 +`providers.update` 重写被改动的 +那一条绑定(`apps/desktop/src/components/settings/subagent-delegation.ts`)。 +该行不保存第二份选择,因此委托目录与页面不会互相矛盾;服务已关闭、缺少凭据或由 +插件拥有时,该行会一并说明原因,而不是直接消失。 + ## 8. 秘密 - 通过安全存储存储的 API 密钥(`SECRET_*` API) diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md index a2fa800a03..1b24b16240 100644 --- a/docs/zh-CN/spec/04-ux/06-settings-ia.md +++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md @@ -195,11 +195,19 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 `Cmd/Ctrl + Shift + W` 呼出组合键已移除 —— 并且避开 `Cmd/Ctrl + W`,因为 macOS 把它用于自己的关闭窗口命令;读取配置映射时,已存储的 `closeWindow` / `summonWindow` 覆盖项会并入它(D438、D439) - -### 模型配置(`agent` 选项卡) -- **Studio Hero**:提供商计数、就绪计数和当前默认 provider/model 摘要 -- **默认**卡:默认提供商/模型选择器。全局运行模式、命令 Shell - 和回车发送由全局 AI 目的地负责。 +- **子代理模型**行(位于生图模型选择器之后)只列出带 `availableForSubagents` 选项的 + 模型 —— 即父 agent 看到的委托目录 —— 每条以 `provider · model` 呈现并带删除操作; + 模型 —— 即父 agent 看到的委托目录 —— 每条以 `provider · model` 呈现,并带与服务 + 自身模型列表相同的“高级”和删除操作;一个都没有时显示空状态说明。“高级”打开该服务 + 的模型编辑器(勾选项就在那里),因此添加始终发生在模型编辑器里,而不是这一行。它读写 + `ModelBinding.availableForSubagents`,不另存第二份选择;保存前重新读取该服务,只 + 没有任何服务配置模型时整行隐藏。 + `availableForSubagents` 选项的模型 —— 即父 agent 看到的委托目录;一个都没有时显示 + 空状态说明。其折叠区按服务分组列出全部已配置绑定,每个模型一个复选框,因此不必逐个 + 打开服务的模型对话框。它读写 `ModelBinding.availableForSubagents`,不另存第二份选择; + 保存前重新读取该服务,只通过 `providers.update` 重写被改动的那条绑定,并发编辑不会被 + 覆盖。服务已关闭或缺少凭据时,已勾选的模型仍然可见可改,并注明为何暂不生效;插件拥有 + 的服务行只读(RPC 会拒绝)。没有任何服务配置模型时整行隐藏。 - **厂商账户**卡片(D237),位于默认值与提供商之间: - 卡片列出的是账户而非厂商:已登录的厂商各占一行,一个也没有时用一句 说明取代列表。运行时目录不提供任何 OAuth 厂商时整个分区隐藏 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 93bbba382f..c639a5f231 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5920,6 +5920,7 @@ eleven-tool-round desktop paths are verified by ## 8. 可追溯性矩阵 | 验收 | 应用场景 | |---|---| +| C / E / Quality — 模型页的子代理委托目录 | [E2E-268](#e2e-268模型页显示并编辑子代理委托目录) | | C / E / Security / Quality — Gemini work setup | E2E-LIVE-VOICE-gemini-work-schema | | C / Quality — Continue after malformed tool output | E2E-RUNTIME-malformed-tool-call-continue-recovery | | B / Security / Quality — Optional provider client certificate requests | E2E-PROVIDER-optional-client-certificate | @@ -10032,6 +10033,32 @@ host-created files. The full app's file-preview viewer is covered separately. - **验收**:项目折叠时仍能回答“这里是否有正在运行的工作”,且两种提示可从持久化设置独立控制。 - **状态**:草稿。现有 `apps/desktop/test/sidebar-project-runs.test.mjs` 覆盖数量分类、总开关与子开关门控、两种渲染入口,以及主题令牌和减少动态效果保证;它不覆盖设置 API 缺省值规范化或完整交互,相关场景仍需在可用环境手动验证。 +### E2E-268:模型页显示并编辑子代理委托目录 + +- **前提条件**:两个 AI 服务已配置、已启用并持有凭据,第一个有两个模型、第二个有一个; + 仅其中一个模型勾选了“可供 AI 自动调度”。可委派任务的父 Agent 会话可用。 +- **步骤**: + 1. 打开设置 → 模型,阅读底部的 **子代理模型** 行。 + 2. 用该行的“高级”打开第二个服务的模型编辑器,勾选“可供 AI 自动调度”并保存,再看这一行。 + 3. 发起一次父会话回合,检查 Task 工具收到的委托模型摘要。 + 4. 回到该行用 × 移除该模型,再看这一行。 + 5. 关闭第二个服务,再看这一行。 + 6. 在第一个服务自己的模型对话框中切换“可供 AI 自动调度”,返回模型页。 +- **预期**:该行列出的正是已加入的模型(`provider · model`),一个都没有时显示空状态说明; + 模型只能通过在该模型编辑器里勾选来加入。加入会把 `availableForSubagents` 写入该服务的 + 绑定,下一次父会话回合即可在委托目录中看到该模型;移除会把它从目录移出,且不在已存绑定上 + 留下 `false`。服务已关闭或缺少凭据时,已加入的模型仍然列出并可移除,并注明为何暂不生效, + 同时其模型不进入目录。服务对话框与这一行双向显示同一开关,两个界面不会各说各话。插件拥有的 + 行只读。没有任何服务配置模型时整行隐藏;保存被拒绝时报告错误,而不是静默保留旧值。 +- **关联规格**:`03-runtime/02-agent-runtime.md` §5f、 + `03-runtime/11-provider-model-system.md` §7、`04-ux/06-settings-ia.md`(模型配置)、 + D278、ADR subagent-model-opt-in、issue #1736。 +- **验收**:C / E / Quality —— 父 agent 可挑选的委托目录能在模型页一处查看并修改,且不新增 + 第二份保存的选择;每一行都说明某个已勾选模型为何暂不生效。 +- **里程碑**:Post-MVP 模型配置。 +- **状态**:`apps/desktop/test/subagent-delegation.test.mjs` 有单元覆盖(候选构造、分组、 + 只读与受阻状态、以及精确的提供商写入)。交互式的“保存 → 下一回合目录”路径仍为草稿, + 仅在明确请求且环境可用时运行。 ### E2E-FAST-MODE:`/fast` 只在允许的模型上请求优先档位 - **前置条件**:一个 ChatGPT 订阅(Codex)账号和一个 Responses API key 提供商,各配置两个模型。 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 8ecf62369c..f2f9b0db36 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1796,6 +1796,18 @@ sklm: { "contextWindowCatalogHint": "Folgt models.dev; eine Änderung fixiert Ihren Wert.", "availableForSubagents": "Verfügbar für AI-Delegation", "availableForSubagentsHint": "Ermöglichen Sie AI, dieses Modell zu verwenden, wenn Aufgaben an Subagenten delegiert werden.", + "subagentModels": "Subagenten-Modelle", + "subagentModelsUnset": "Noch kein Modell für Subagenten freigegeben", + "subagentModelAdd": "Modelle in den Modelleinstellungen des Dienstes hinzufügen — Erweitert → Verfügbar für AI-Delegation", + "subagentModelEdit": "Bearbeiten", + "subagentModelsMore": "+{{count}} weitere", + "subagentModelRemove": "{{model}} aus den Subagenten-Modellen entfernen", + "subagentModelSelected": "Modell für Delegation verfügbar", + "subagentModelUnselected": "Modell aus der Delegation entfernt", + "subagentModelSaveFailed": "Auswahl für die Delegation konnte nicht gespeichert werden", + "subagentModelPluginManaged": "Von einem Plugin verwaltet", + "subagentModelProviderDisabled": "Dienst ist ausgeschaltet", + "subagentModelCredentialMissing": "Kein Zugangsschlüssel", "nativeWebSearch": "Native Websuche", "nativeWebSearchHint": "Hängt das vom Anbieter gehostete Websuche-Tool an Anfragen für dieses Modell an. Erfordert einen Endpunkt, der dies unterstützt; die Abrechnung erfolgt pro Suche beim Anbieter.", nativeWebSearchUnsupported: "Diese Verbindung unterstützt in der App noch keine native Suche. Die Website oder eine andere API kann Suche anbieten.", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 54eb8cbf3c..548f07dda0 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1816,6 +1816,18 @@ sklm: { contextWindowCatalogHint: "Follows models.dev; editing pins your value.", availableForSubagents: "Available for AI delegation", availableForSubagentsHint: "Allow AI to use this model when delegating tasks to subagents", + subagentModels: "Subagent models", + subagentModelsUnset: "No model is open to subagents yet", + subagentModelAdd: "Add models from a service's model settings — Advanced → Available for AI delegation", + subagentModelEdit: "Edit", + subagentModelsMore: "+{{count}} more", + subagentModelRemove: "Remove {{model}} from subagent models", + subagentModelSelected: "Model available for delegation", + subagentModelUnselected: "Model removed from delegation", + subagentModelSaveFailed: "Could not save the delegation choice", + subagentModelPluginManaged: "Managed by a plugin", + subagentModelProviderDisabled: "Service is off", + subagentModelCredentialMissing: "No credential", nativeWebSearch: "Native web search", nativeWebSearchHint: "Attach the provider-hosted web search tool to requests for this model. Requires an endpoint that supports it; billed by the provider per search.", nativeWebSearchUnsupported: "This app has not integrated native search for this connection. Website search or another API may still be available.", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 40c01094a7..67a5817782 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1796,6 +1796,18 @@ sklm: { "contextWindowCatalogHint": "Sigue a models.dev; al editarlo se fija tu valor.", "availableForSubagents": "Disponible para delegación de AI", "availableForSubagentsHint": "Permitir que AI use este modelo al delegar tareas a subagentes", + "subagentModels": "Modelos de subagentes", + "subagentModelsUnset": "Aún no hay ningún modelo abierto a subagentes", + "subagentModelAdd": "Añade modelos en los ajustes de modelos del servicio — Avanzado → Disponible para delegación de AI", + "subagentModelEdit": "Editar", + "subagentModelsMore": "+{{count}} más", + "subagentModelRemove": "Quitar {{model}} de los modelos de subagente", + "subagentModelSelected": "Modelo disponible para delegación", + "subagentModelUnselected": "Modelo retirado de la delegación", + "subagentModelSaveFailed": "No se pudo guardar la elección de delegación", + "subagentModelPluginManaged": "Gestionado por un complemento", + "subagentModelProviderDisabled": "El servicio está desactivado", + "subagentModelCredentialMissing": "Sin credencial", "nativeWebSearch": "Búsqueda web nativa", "nativeWebSearchHint": "Adjunta la herramienta de búsqueda web alojada por el proveedor a las solicitudes de este modelo. Requiere un punto de conexión que la admita; el proveedor la factura por búsqueda.", nativeWebSearchUnsupported: "La aplicación aún no integra búsqueda nativa para esta conexión. El sitio web u otra API pueden ofrecerla.", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 723e5ba265..e2304efb8d 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1796,6 +1796,18 @@ sklm: { "contextWindowCatalogHint": "Suit models.dev ; une modification fixe votre valeur.", "availableForSubagents": "Disponible pour la délégation de l'IA", "availableForSubagentsHint": "Autoriser l'IA à utiliser ce modèle lors de la délégation de tâches à des sous-agents", + "subagentModels": "Modèles de sous-agents", + "subagentModelsUnset": "Aucun modèle ouvert aux sous-agents pour l'instant", + "subagentModelAdd": "Ajoutez des modèles dans les réglages du service — Avancé → Disponible pour la délégation de l'IA", + "subagentModelEdit": "Modifier", + "subagentModelsMore": "+{{count}} de plus", + "subagentModelRemove": "Retirer {{model}} des modèles de sous-agent", + "subagentModelSelected": "Modèle disponible pour la délégation", + "subagentModelUnselected": "Modèle retiré de la délégation", + "subagentModelSaveFailed": "Impossible d'enregistrer le choix de délégation", + "subagentModelPluginManaged": "Géré par un plugin", + "subagentModelProviderDisabled": "Le service est désactivé", + "subagentModelCredentialMissing": "Aucun justificatif", "nativeWebSearch": "Recherche web native", "nativeWebSearchHint": "Attache l'outil de recherche web hébergé par le fournisseur aux requêtes pour ce modèle. Nécessite un point de terminaison qui le prend en charge ; facturé par le fournisseur à chaque recherche.", nativeWebSearchUnsupported: "La recherche native n’est pas encore intégrée pour cette connexion. Le site web ou une autre API peut la proposer.", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index fd95c8096b..d9c908f202 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1812,6 +1812,18 @@ sklm: { contextWindowCatalogHint: "models.dev를 따릅니다. 수정하면 내 값으로 고정됩니다.", availableForSubagents: "AI 위임에 사용 가능", availableForSubagentsHint: "서브에이전트에 작업을 위임할 때 AI가 이 모델을 사용하도록 허용", + subagentModels: "서브에이전트 모델", + subagentModelsUnset: "아직 서브에이전트에 열린 모델이 없습니다", + subagentModelAdd: "서비스 모델 설정에서 추가하세요 — 고급 → AI 위임에 사용 가능", + subagentModelEdit: "편집", + subagentModelsMore: "외 {{count}}개", + subagentModelRemove: "{{model}}을(를) 서브에이전트 모델에서 제거", + subagentModelSelected: "위임에 사용할 수 있는 모델", + subagentModelUnselected: "위임 목록에서 제거된 모델", + subagentModelSaveFailed: "위임 선택을 저장하지 못했습니다", + subagentModelPluginManaged: "플러그인이 관리함", + subagentModelProviderDisabled: "서비스가 꺼져 있음", + subagentModelCredentialMissing: "자격 증명 없음", nativeWebSearch: "네이티브 웹 검색", nativeWebSearchHint: "이 모델에 대한 요청에 공급자 호스팅 웹 검색 도구를 첨부합니다. 이를 지원하는 엔드포인트가 필요하며, 검색 건수로 공급자에게 과금됩니다.", nativeWebSearchUnsupported: "이 연결의 네이티브 검색은 아직 앱에 통합되지 않았습니다. 웹사이트나 다른 API에서는 검색을 지원할 수 있습니다.", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 12a50fe18a..8b7a19a7b9 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -1766,6 +1766,18 @@ export const ptBR = { contextWindowCatalogHint: "Segue models.dev; editar fixa o seu valor.", availableForSubagents: "Disponível para subagentes", availableForSubagentsHint: "Permite que a IA use este modelo ao delegar tarefas a subagentes", + subagentModels: "Modelos de subagentes", + subagentModelsUnset: "Nenhum modelo aberto para subagentes ainda", + subagentModelAdd: "Adicione modelos nas configurações de modelos do serviço — Avançado → Disponível para delegação", + subagentModelEdit: "Editar", + subagentModelsMore: "+{{count}} mais", + subagentModelRemove: "Remover {{model}} dos modelos de subagente", + subagentModelSelected: "Modelo disponível para delegação", + subagentModelUnselected: "Modelo removido da delegação", + subagentModelSaveFailed: "Não foi possível salvar a escolha de delegação", + subagentModelPluginManaged: "Gerenciado por um plugin", + subagentModelProviderDisabled: "O serviço está desligado", + subagentModelCredentialMissing: "Sem credencial", nativeWebSearch: "Pesquisa nativa na web", nativeWebSearchHint: "Anexa às solicitações deste modelo a ferramenta de pesquisa na web do provedor. Exige um endpoint compatível; o provedor cobra por pesquisa.", nativeWebSearchUnsupported: "O aplicativo ainda não integra pesquisa nativa para esta conexão. O site ou outra API ainda pode oferecer pesquisa.", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 8f89672c7d..abbe7c5a3c 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1806,6 +1806,18 @@ sklm: { contextWindowCatalogHint: "models.dev'i takip eder; düzenlerseniz değeriniz sabitlenir.", availableForSubagents: "AI devri için kullanılabilir", availableForSubagentsHint: "Görevler alt ajanlara devredilirken AI’nin bu modeli kullanmasına izin ver", + subagentModels: "Alt ajan modelleri", + subagentModelsUnset: "Henüz alt ajanlara açılmış model yok", + subagentModelAdd: "Modelleri hizmetin model ayarlarından ekleyin — Gelişmiş → AI devri için kullanılabilir", + subagentModelEdit: "Düzenle", + subagentModelsMore: "+{{count}} tane daha", + subagentModelRemove: "{{model}} öğesini alt ajan modellerinden çıkar", + subagentModelSelected: "Model devretmeye açık", + subagentModelUnselected: "Model devretme listesinden çıkarıldı", + subagentModelSaveFailed: "Devretme seçimi kaydedilemedi", + subagentModelPluginManaged: "Bir eklenti tarafından yönetiliyor", + subagentModelProviderDisabled: "Hizmet kapalı", + subagentModelCredentialMissing: "Kimlik bilgisi yok", nativeWebSearch: "Yerel web araması", nativeWebSearchHint: "Bu modele yapılan isteklere sağlayıcının barındırdığı web arama aracını ekler. Bunu destekleyen bir uç nokta gerekir; arama başına sağlayıcı tarafından faturalandırılır.", nativeWebSearchUnsupported: "Bu bağlantı için yerel arama henüz uygulamaya entegre edilmedi. Web sitesi veya başka bir API aramayı destekleyebilir.", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index b8602e172a..33b61c4919 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1791,6 +1791,18 @@ sklm: { contextWindowCatalogHint: "跟随 models.dev;手动修改后会固定为你的值", availableForSubagents: "可供 AI 自动调度", availableForSubagentsHint: "允许 AI 在委派子任务时自动选用此模型", + subagentModels: "子代理模型", + subagentModelsUnset: "还没有可供子代理调用的模型", + subagentModelAdd: "在服务的模型设置里添加:高级 → 可供 AI 自动调度", + subagentModelEdit: "编辑", + subagentModelsMore: "另有 {{count}} 个", + subagentModelRemove: "将 {{model}} 从子代理模型移除", + subagentModelSelected: "该模型可供 AI 调度", + subagentModelUnselected: "该模型已从调度列表移除", + subagentModelSaveFailed: "保存子代理模型选择失败", + subagentModelPluginManaged: "由插件管理", + subagentModelProviderDisabled: "服务已关闭", + subagentModelCredentialMissing: "尚未配置凭据", nativeWebSearch: "原生联网搜索", nativeWebSearchHint: "为该模型的请求附加提供商托管的联网搜索工具。需要端点实际支持;按次计费由提供商收取。", nativeWebSearchUnsupported: "应用尚未适配此连接的原生搜索;不代表厂商官网或其他 API 不支持搜索。", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 9483adbce6..d3460c34d3 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1791,6 +1791,18 @@ sklm: { contextWindowCatalogHint: "跟隨 models.dev;手動修改後會固定為你的值", availableForSubagents: "可供 AI 自動排程", availableForSubagentsHint: "允許 AI 在委派子任務時自動選用此模型", + subagentModels: "子代理模型", + subagentModelsUnset: "尚無可提供子代理呼叫的模型", + subagentModelAdd: "在服務的模型設定裡新增:進階 → 可供 AI 自動調度", + subagentModelEdit: "編輯", + subagentModelsMore: "另有 {{count}} 個", + subagentModelRemove: "將 {{model}} 從子代理模型移除", + subagentModelSelected: "此模型可供 AI 調度", + subagentModelUnselected: "此模型已從調度清單移除", + subagentModelSaveFailed: "儲存子代理模型選擇失敗", + subagentModelPluginManaged: "由外掛管理", + subagentModelProviderDisabled: "服務已關閉", + subagentModelCredentialMissing: "尚未設定憑證", nativeWebSearch: "原生網路搜尋", nativeWebSearchHint: "為該模型的請求附加提供商託管的網路搜尋工具。需要端點實際支援;按次計費由提供商收取。", nativeWebSearchUnsupported: "應用程式尚未整合此連線的原生搜尋;不代表廠商官網或其他 API 不支援搜尋。",