From c2a485ebc597467ec688df7087f6209091388fe1 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:15:04 +0800 Subject: [PATCH] feat(studio): add grouped preset module multiselect Problem: Preset modules could only be added one at a time, with two different actions both labeled Add module. Root cause: A native single-choice picker shared its label with manual blank-ID insertion, and catalog choices did not carry source provenance. Fix: Add a shared searchable grouped checkbox picker, authoritative catalog provenance, batch selection and parameter-safe draft updates. Keep manual IDs under an explicit advanced entry and document selection semantics. Verification: 219 frontend tests, typecheck, production build, census, owning Go packages including boot and builtin, focused Desktop preset/composition tests, vet, lint, make check and documentation checks passed. Browser checks covered themes, responsive layouts and save/reopen with MockHub. --- desktop/frontend-next/src/actions.ts | 7 + .../frontend-next/src/i18n/en_composition.ts | 15 ++ desktop/frontend-next/src/port/composition.ts | 2 +- .../src/port/mock_composition.ts | 20 +-- desktop/frontend-next/src/styles/LAYOUT.md | 3 + .../src/styles/composition-modules.css | 39 +++++ .../src/ui/AgentPresets.interaction.test.tsx | 3 +- ...ositionEditor.choices.interaction.test.tsx | 15 +- ...ionEditor.multiselect.interaction.test.tsx | 136 ++++++++++++++++++ .../src/ui/CompositionEditor.tsx | 51 +++---- .../src/ui/CompositionModulePicker.tsx | 111 ++++++++++++++ docs/AGENT_COMPOSITIONS.md | 14 ++ docs/AGENT_COMPOSITIONS.zh.md | 11 ++ .../session/control/composition_inventory.go | 22 +-- .../control/composition_inventory_test.go | 48 +++++++ internal/state/agentpreset/catalog.go | 1 + .../unreleased/preset-module-multiselect.md | 2 + 17 files changed, 442 insertions(+), 58 deletions(-) create mode 100644 desktop/frontend-next/src/styles/composition-modules.css create mode 100644 desktop/frontend-next/src/ui/CompositionEditor.multiselect.interaction.test.tsx create mode 100644 desktop/frontend-next/src/ui/CompositionModulePicker.tsx create mode 100644 release-notes/studio/unreleased/preset-module-multiselect.md diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index b5c44fd821..9f70f89a98 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -66,6 +66,13 @@ export const ACTIONS: UIAction[] = [ {id:"agent-preset.module-parameters",kind:"view",target:"entity",proof:"interaction"}, {id:"agent-preset.module-add",kind:"view",target:"entity",proof:"interaction"}, {id:"agent-preset.module-choose",kind:"view",target:"entity",proof:"interaction"}, + {id:"agent-preset.module-toggle",kind:"view",target:"entity",proof:"interaction"}, + {id:"agent-preset.module-select-all",kind:"view",target:"entity",proof:"interaction"}, + {id:"agent-preset.module-select-group",kind:"view",target:"entity",proof:"interaction"}, + {id:"agent-preset.module-clear",kind:"view",target:"entity",proof:"interaction"}, + {id:"agent-preset.module-search",kind:"view",target:"entity",proof:"interaction"}, + {id:"agent-preset.module-navigate",kind:"view",target:"none",proof:"interaction"}, + {id:"agent-preset.module-done",kind:"view",target:"none",proof:"interaction"}, {id:"agent-preset.module-remove",kind:"view",target:"entity",proof:"interaction"}, {id:"agent-preset.save",kind:"kernel-mutation",target:"none",proof:"authority-effect"}, {id:"agent-preset.default",kind:"kernel-mutation",target:"none",proof:"authority-effect"}, diff --git a/desktop/frontend-next/src/i18n/en_composition.ts b/desktop/frontend-next/src/i18n/en_composition.ts index 911f2ee94d..a7e3f276c7 100644 --- a/desktop/frontend-next/src/i18n/en_composition.ts +++ b/desktop/frontend-next/src/i18n/en_composition.ts @@ -1,4 +1,19 @@ export const EN_COMPOSITION: Record = { + "已选 {n} 项": "{n} selected", + "高级:手动输入模块 ID": "Advanced: enter a module ID", + "添加空白 ID": "Add blank ID", + "仅在模块未列出时使用;填写 ID 不会安装模块或授予权限。": "Use only for modules not listed. Entering an ID does not install a module or grant permission.", + "已选 {selected}/{total}": "{selected}/{total} selected", + "全选({n} 项)": "Select all ({n})", + "全选搜索结果({n} 项)": "Select all results ({n})", + "全选本组": "Select group", + "取消本组全选": "Deselect group", + "搜索模块": "Search modules", + "搜索模块名称或说明…": "Search module names or descriptions…", + "没有匹配的模块": "No matching modules", + "暂无可选模块": "No modules available", + "未分类": "Unclassified", + "全选仅包含当前列表;新增模块不会自动选中。": "Select all includes current results only. New modules are not selected automatically.", "显示模式选择器": "Show mode picker", "默认关闭。开启后在聊天输入框顶部显示模式选择器;关闭只隐藏入口,不改变会话模式。": "Off by default. Enable to show the mode picker above the chat input. Turning it off hides the picker without changing the conversation's mode.", "无法保存模式选择器设置,请检查本地存储权限。": "Could not save the mode picker setting. Check local storage permissions.", diff --git a/desktop/frontend-next/src/port/composition.ts b/desktop/frontend-next/src/port/composition.ts index 5033a65453..7aa66c0651 100644 --- a/desktop/frontend-next/src/port/composition.ts +++ b/desktop/frontend-next/src/port/composition.ts @@ -4,7 +4,7 @@ export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks export interface CompositionModule { id: string; parameters?: Record } // A module the editor offers: the id is what a preset stores; the description is what the module // says about itself, for the person choosing, and is never saved. -export interface CompositionChoice { id: string; description?: string } +export interface CompositionChoice { id: string; description?: string; source?: "builtin" | "custom" } export interface CompositionSelection { mode: "inherit" | "select"; members: CompositionModule[] } export interface AgentComposition { schemaVersion: number; diff --git a/desktop/frontend-next/src/port/mock_composition.ts b/desktop/frontend-next/src/port/mock_composition.ts index 0b13b6bb3e..90b3701fdc 100644 --- a/desktop/frontend-next/src/port/mock_composition.ts +++ b/desktop/frontend-next/src/port/mock_composition.ts @@ -7,16 +7,20 @@ type Library = { revision:number; user:AgentPresetVersion[]; projects:Map select { flex: 1 1 260px; } +.composition-module-trigger { display: flex; flex: 1 1 200px; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; padding: 8px 12px; min-height: 40px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); cursor: pointer; } +.composition-module-trigger > svg { width: 16px; height: 16px; flex: none; color: var(--muted); } +.composition-module-trigger[aria-expanded="true"] { border-color: var(--focus); } +.composition-capabilities .composition-manual { margin-top: 12px; } +.composition-capabilities .composition-manual > summary { min-height: 32px; color: var(--muted); font-size: var(--fs-meta); font-weight: var(--w-reg); list-style: disclosure-closed; display: list-item; } +.composition-capabilities .composition-manual[open] > summary { list-style: disclosure-open; } +.composition-module-picker { position: fixed; z-index: 1150; display: flex; flex-direction: column; width: min(480px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100dvh / var(--zoom, 1) - 24px); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface); color: var(--text); box-shadow: var(--lit); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); overflow: hidden; } +.composition-picker-tools, .composition-picker-group-heading, .composition-picker-footer { display: flex; align-items: center; gap: 10px; padding: 10px 14px; flex-shrink: 0; } +.composition-picker-tools { border-bottom: 1px solid var(--hair); } +.composition-select-all { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.composition-module-picker input[type="checkbox"] { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent-ink); cursor: pointer; } +.composition-picker-action { margin-left: auto; border: 0; background: transparent; color: var(--muted); padding: 4px; font: inherit; font-size: var(--fs-meta); cursor: pointer; } +.composition-picker-action:hover { color: var(--text); } +.composition-picker-search { display: flex; align-items: center; gap: 8px; margin: 10px 12px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); flex-shrink: 0; } +.composition-picker-search > svg { width: 16px; height: 16px; flex: none; color: var(--muted); } +.composition-picker-search input { min-width: 0; width: 100%; border: 0; background: transparent; color: var(--text); font: inherit; } +.composition-picker-list { min-height: 0; max-height: 360px; overflow-y: auto; overscroll-behavior: contain; } +.composition-picker-group + .composition-picker-group { border-top: 1px solid var(--hair); margin-top: 6px; padding-top: 6px; } +.composition-picker-group-heading { padding-block: 4px; } +.composition-picker-group-heading strong { font-weight: var(--w-med); } +.composition-picker-group-heading small { color: var(--muted); font-size: var(--fs-meta); } +.composition-choice { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 8px 14px; cursor: pointer; } +.composition-choice:hover { background: var(--overlay); } +.composition-choice > svg { width: 16px; height: 16px; flex: none; color: var(--muted); } +.composition-choice-name { flex: 0 1 auto; min-width: 0; overflow-wrap: anywhere; } +.composition-choice-description { flex: 1; min-width: 0; color: var(--muted); font-size: var(--fs-meta); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.composition-picker-footer { justify-content: space-between; border-top: 1px solid var(--hair); margin-top: 8px; } +.composition-picker-footer .btn { min-width: 76px; } +.composition-picker-note { margin: 0; padding: 8px 14px 12px; border-top: 1px solid var(--hair); color: var(--muted); font-size: var(--fs-meta); flex-shrink: 0; } +.composition-picker-empty { padding: 16px; color: var(--muted); } +.composition-module-picker :is(button, input):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.composition-module-picker :disabled, .composition-module-trigger:disabled { opacity: .55; cursor: default; } +@media (max-width: 700px) { + .composition-choice { display: grid; grid-template-columns: 16px 16px minmax(0, 1fr); gap: 4px 10px; } + .composition-choice-description { grid-column: 3; white-space: normal; } + .composition-picker-group-heading { flex-wrap: wrap; } +} diff --git a/desktop/frontend-next/src/ui/AgentPresets.interaction.test.tsx b/desktop/frontend-next/src/ui/AgentPresets.interaction.test.tsx index 2427afd576..b6a1c32c28 100644 --- a/desktop/frontend-next/src/ui/AgentPresets.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/AgentPresets.interaction.test.tsx @@ -167,7 +167,8 @@ describe("Agent preset management", () => { await user.click(screen.getByText("技能与扩展", { selector: "summary" })); await user.selectOptions(screen.getByLabelText("MCP 服务 选择方式"),"select"); const group=screen.getByRole("group",{name:"MCP 服务"}); - await user.click(group.querySelector("button")!); + await user.click(within(group).getByText("高级:手动输入模块 ID")); + await user.click(within(group).getByRole("button", { name: "添加空白 ID" })); await user.type(screen.getByLabelText("MCP 服务 ID 1"),"docs"); await user.clear(screen.getByLabelText("MCP 服务 独立参数 1")); await user.type(screen.getByLabelText("MCP 服务 独立参数 1"),"invalid"); diff --git a/desktop/frontend-next/src/ui/CompositionEditor.choices.interaction.test.tsx b/desktop/frontend-next/src/ui/CompositionEditor.choices.interaction.test.tsx index a997f60b0d..fba2bd4a68 100644 --- a/desktop/frontend-next/src/ui/CompositionEditor.choices.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/CompositionEditor.choices.interaction.test.tsx @@ -26,17 +26,16 @@ async function open() { it("shows what a module is for beside its name, and says nothing where its source says nothing", async () => { await open(); - const pick = screen.getAllByRole("combobox").find((s) => /添加模块/.test(s.getAttribute("aria-label") ?? ""))!; - const labels = [...pick.options].map((o) => o.textContent); - expect(labels).toContain("bash — Run a shell command in the workspace and return its output."); - expect(labels).toContain("task"); - const bash = [...pick.options].find((o) => o.value === "bash")!; - expect(bash.title).toBe("Run a shell command in the workspace and return its output."); + await userEvent.click(screen.getByText("工具", { selector: "summary" })); + await userEvent.click(screen.getByRole("button", { name: "工具 添加模块" })); + expect(screen.getByText("Run a shell command in the workspace and return its output.")).toBeTruthy(); + expect(screen.getByRole("checkbox", { name: "task" }).getAttribute("aria-describedby")).toBeNull(); }); it("stores only the id of the module it adds, never its description", async () => { const state = await open(); - const pick = screen.getAllByRole("combobox").find((s) => /添加模块/.test(s.getAttribute("aria-label") ?? ""))!; - await userEvent.selectOptions(pick, "bash"); + await userEvent.click(screen.getByText("工具", { selector: "summary" })); + await userEvent.click(screen.getByRole("button", { name: "工具 添加模块" })); + await userEvent.click(screen.getByRole("checkbox", { name: "bash" })); expect(state.saved.tools.members).toEqual([{ id: "bash" }]); }); diff --git a/desktop/frontend-next/src/ui/CompositionEditor.multiselect.interaction.test.tsx b/desktop/frontend-next/src/ui/CompositionEditor.multiselect.interaction.test.tsx new file mode 100644 index 0000000000..666654cc8e --- /dev/null +++ b/desktop/frontend-next/src/ui/CompositionEditor.multiselect.interaction.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import "./testkit"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it } from "vitest"; +import { CompositionEditor } from "./CompositionEditor"; +import { CompositionModulePicker } from "./CompositionModulePicker"; +import { builtinCompositions, type AgentComposition, type AgentPresetCatalog, type CompositionModule } from "../port/composition"; + +afterEach(cleanup); +const modules = { + tools: [{ id: "bash", description: "Run a shell command", source: "builtin" }, { id: "task", source: "builtin" }], + skills: [{ id: "review", description: "Review changes", source: "builtin" }, { id: "notes", description: "Tidy notes", source: "custom" }, { id: "release", source: "custom" }], + mcp: [{ id: "docs", source: "custom" }, { id: "repo", source: "custom" }], +} satisfies AgentPresetCatalog["modules"]; + +function open(initial?: (draft: AgentComposition) => void) { + let current = builtinCompositions()[0]; + current.id = "user:picker"; + current.skills = { mode: "select", members: [] }; + initial?.(current); + let valid = true; + const props = () => ({ draft: current, modules, disabled: false, creating: true, + onChange: (next: AgentComposition) => { current = next; view.rerender(); }, + onValidityChange: (next: boolean) => { valid = next; }, + }); + const view = render(); + return { get saved() { return current; }, get valid() { return valid; } }; +} + +async function picker(label = "技能 添加模块") { + await userEvent.click(screen.getByText("技能与扩展")); + await userEvent.click(screen.getByRole("button", { name: label })); + return screen.getByRole("dialog", { name: label }); +} + +it("keeps a grouped checkbox menu open for multiple selections and stores only ids", async () => { + const state = open(); + const menu = await picker(); + expect(within(menu).getByRole("group", { name: "内置" })).toBeTruthy(); + expect(within(menu).getByRole("group", { name: "自定义" })).toBeTruthy(); + expect(within(menu).getByText("Review changes")).toBeTruthy(); + await userEvent.click(within(menu).getByRole("checkbox", { name: "review" })); + await userEvent.click(within(menu).getByRole("checkbox", { name: "notes" })); + expect(state.saved.skills.members).toEqual([{ id: "review" }, { id: "notes" }]); + expect(within(menu).getByRole("checkbox", { name: "全选(3 项)" }).indeterminate).toBe(true); + await userEvent.click(within(menu).getByRole("button", { name: "完成" })); + expect(screen.queryByRole("dialog", { name: "技能 添加模块" })).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "技能 添加模块" })); +}); + +it("selects groups and filtered results without removing hidden selections", async () => { + const state = open(); + const menu = await picker(); + const custom = within(menu).getByRole("group", { name: "自定义" }); + await userEvent.click(within(custom).getByRole("button", { name: "全选本组" })); + expect(state.saved.skills.members.map(m => m.id)).toEqual(["notes", "release"]); + await userEvent.click(within(menu).getByRole("checkbox", { name: "全选(3 项)" })); + expect(state.saved.skills.members).toHaveLength(3); + await userEvent.type(within(menu).getByRole("searchbox"), "Review"); + await userEvent.click(within(menu).getByRole("checkbox", { name: "全选搜索结果(1 项)" })); + expect(state.saved.skills.members.map(m => m.id)).toEqual(["notes", "release"]); + await userEvent.clear(within(menu).getByRole("searchbox")); + await userEvent.type(within(menu).getByRole("searchbox"), "missing"); + expect(within(menu).getByText("没有匹配的模块")).toBeTruthy(); + expect(within(menu).getByRole("checkbox").disabled).toBe(true); + await userEvent.click(within(menu).getByRole("button", { name: "清空" })); + expect(state.saved.skills).toEqual({ mode: "select", members: [] }); +}); + +it("preserves parameters and invalid JSON when earlier selections are removed", async () => { + const state = open(d => { d.mcp = { mode: "select", members: [{ id: "docs", parameters: { args: ["--docs"] } }, { id: "repo", parameters: { args: ["--repo"] } }] }; }); + await userEvent.click(screen.getByText("技能与扩展")); + const editor = screen.getByRole("textbox", { name: "MCP 服务 独立参数 2" }); + await userEvent.clear(editor); + await userEvent.type(editor, "invalid"); + expect(state.valid).toBe(false); + await userEvent.click(screen.getByRole("button", { name: "MCP 服务 添加模块" })); + const menu = screen.getByRole("dialog", { name: "MCP 服务 添加模块" }); + await userEvent.click(within(menu).getByRole("checkbox", { name: "docs" })); + await userEvent.click(within(menu).getByRole("button", { name: "完成" })); + expect(screen.getByRole("textbox", { name: "MCP 服务 独立参数 1" }).value).toBe("invalid"); + expect(state.saved.mcp.members).toEqual([{ id: "repo", parameters: { args: ["--repo"] } }]); + expect(state.valid).toBe(false); +}); + +it("supports Escape, keyboard selection, outside dismissal and unchanged inheritance", async () => { + const state = open(); + let menu = await picker(); + await userEvent.keyboard("{ArrowDown} "); + expect(state.saved.skills.members).toEqual([{ id: "review" }]); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "技能 添加模块" })).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "技能 添加模块" })); + menu = screen.getByRole("dialog", { name: "技能 添加模块" }); + await userEvent.click(screen.getByRole("textbox", { name: "预设名称" })); + expect(menu.isConnected).toBe(false); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "技能 选择方式" }), "inherit"); + expect(state.saved.skills).toEqual({ mode: "inherit", members: [] }); + expect(screen.queryByRole("button", { name: "技能 添加模块" })).toBeNull(); +}); + +it("has one add-module picker and keeps manual IDs behind an explicit advanced entry", async () => { + const state = open(); + await userEvent.click(screen.getByText("技能与扩展")); + const group = screen.getByRole("group", { name: "技能" }); + expect(within(group).getAllByRole("button", { name: /添加模块/ })).toHaveLength(1); + expect(within(group).getByRole("button", { name: "添加空白 ID" }).closest("details")?.open).toBe(false); + await userEvent.click(within(group).getByText("高级:手动输入模块 ID")); + await userEvent.click(within(group).getByRole("button", { name: "添加空白 ID" })); + await userEvent.type(screen.getByRole("textbox", { name: "技能 ID 1" }), "private-skill"); + expect(state.saved.skills.members).toEqual([{ id: "private-skill" }]); +}); + +it("keeps legacy catalog choices unclassified, deduplicates ids and closes when disabled", async () => { + let members: CompositionModule[] = [{ id: "saved-but-missing", parameters: { args: ["--keep"] } }]; + const onChange = (next: typeof members) => { members = next; }; + const props = { kind: "skills" as const, label: "技能", choices: [{ id: "old" }, { id: "old" }], members, disabled: false, onChange }; + const view = render(); + await userEvent.click(screen.getByRole("button", { name: "技能 添加模块" })); + const menu = screen.getByRole("dialog", { name: "技能 添加模块" }); + expect(within(menu).getByRole("group", { name: "未分类" })).toBeTruthy(); + expect(within(menu).getAllByRole("checkbox", { name: "old" })).toHaveLength(1); + await userEvent.click(within(menu).getByRole("checkbox", { name: "全选(1 项)" })); + expect(members).toEqual([{ id: "saved-but-missing", parameters: { args: ["--keep"] } }, { id: "old" }]); + view.rerender(); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("shows empty catalogs without removing saved choices", async () => { + render( {}} />); + await userEvent.click(screen.getByRole("button", { name: "技能 添加模块" })); + expect(screen.getByText("暂无可选模块")).toBeTruthy(); + expect(screen.getByRole("checkbox").disabled).toBe(true); + expect(screen.getByRole("button", { name: "清空" })).toBeTruthy(); +}); diff --git a/desktop/frontend-next/src/ui/CompositionEditor.tsx b/desktop/frontend-next/src/ui/CompositionEditor.tsx index eb6461abf0..c1d8e0f654 100644 --- a/desktop/frontend-next/src/ui/CompositionEditor.tsx +++ b/desktop/frontend-next/src/ui/CompositionEditor.tsx @@ -6,10 +6,12 @@ import type { CompositionChoice, CompositionKind, CompositionSelection, + CompositionModule, } from "../port/composition"; import { COMPOSITION_KINDS } from "../port/composition"; import { Switch } from "./Switch"; import { StudioIcon } from "./StudioIcon"; +import { CompositionModulePicker } from "./CompositionModulePicker"; const labels: Record = { tools: "工具", @@ -163,16 +165,17 @@ function ModuleEditor({ setJsonErrors(errors); onValidityChange(Object.values(errors).every((v) => !v)); }; - const remove = (index: number) => { - const shift = (source: Record): Record => + const changeMembers = (members: CompositionModule[]) => { + const remap = (source: Record): Record => Object.fromEntries( - Object.entries(source) - .filter(([key]) => Number(key) !== index) - .map(([key, v]) => [Number(key) > index ? Number(key) - 1 : Number(key), v]), + members.flatMap((member, index) => { + const old = value.members.indexOf(member); + return old in source ? [[index, source[old]]] : []; + }), ); - validate(shift(jsonErrors)); - setRawParameters(shift(rawParameters)); - onChange({ ...value, members: value.members.filter((_, i) => i !== index) }); + validate(remap(jsonErrors)); + setRawParameters(remap(rawParameters)); + onChange({ ...value, members }); }; return (
@@ -180,6 +183,7 @@ function ModuleEditor({ {kind === "hooks" && (

{t("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")}

)} +
+ {value.mode === "select" && } +
{value.mode === "select" && ( <> - {choices.length > 0 && ( - - )} {value.members.map((module, index) => (
@@ -238,7 +223,7 @@ function ModuleEditor({ className="act" data-action="agent-preset.module-remove" data-target={kind} - onClick={() => remove(index)} + onClick={() => changeMembers(value.members.filter((_, i) => i !== index))} > {t("移除")} @@ -275,6 +260,9 @@ function ModuleEditor({ )}
))} +
+ {t("高级:手动输入模块 ID")} +

{t("仅在模块未列出时使用;填写 ID 不会安装模块或授予权限。")}

+
)}
diff --git a/desktop/frontend-next/src/ui/CompositionModulePicker.tsx b/desktop/frontend-next/src/ui/CompositionModulePicker.tsx new file mode 100644 index 0000000000..1e989a9fce --- /dev/null +++ b/desktop/frontend-next/src/ui/CompositionModulePicker.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; +import { t } from "../i18n"; +import type { CompositionChoice, CompositionKind, CompositionModule } from "../port/composition"; +import { StudioIcon } from "./StudioIcon"; +import { useDismiss } from "./dismiss"; +import { beside, pinToViewport, useFollow, zoom } from "./place"; +import "../styles/composition-modules.css"; + +interface Props { + kind: CompositionKind; + label: string; + choices: CompositionChoice[]; + members: CompositionModule[]; + disabled: boolean; + onChange: (members: CompositionModule[]) => void; +} + +export function CompositionModulePicker({ kind, label, choices, members, disabled, onChange }: Props) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const trigger = useRef(null); + const panel = useRef(null); + const search = useRef(null); + const all = useRef(null); + const id = useId(); + const close = useCallback(() => { setOpen(false); trigger.current?.focus(); }, []); + const visible = open && !disabled; + const place = useCallback(() => { + if (!panel.current || !trigger.current) return; + const rect = trigger.current.getBoundingClientRect(); + pinToViewport(panel.current, rect.left, beside(rect, panel.current.offsetHeight * zoom(), innerHeight, "below", 6), 12); + }, []); + useFollow(visible, place, query, { anchor: trigger, container: panel }); + useDismiss(visible, panel, close, trigger); + useEffect(() => { if (disabled) setOpen(false); }, [disabled]); + useEffect(() => { if (visible) search.current?.focus(); }, [visible]); + useEffect(() => { + if (!visible || !panel.current) return; + const observer = new ResizeObserver(place); + observer.observe(panel.current); + return () => observer.disconnect(); + }, [visible, place]); + const selected = new Set(members.map(m => m.id)); + const unique = [...new Map(choices.map(choice => [choice.id, choice])).values()]; + const needle = query.trim().toLocaleLowerCase(); + const shown = unique.filter(choice => `${choice.id} ${choice.description ?? ""}`.toLocaleLowerCase().includes(needle)); + const count = shown.filter(choice => selected.has(choice.id)).length; + useEffect(() => { if (all.current) all.current.indeterminate = count > 0 && count < shown.length; }, [count, shown.length, visible]); + const toggle = (items: CompositionChoice[], checked: boolean) => { + const ids = new Set(items.map(item => item.id)); + onChange(checked ? [...members, ...items.filter(item => !selected.has(item.id)).map(item => ({ id: item.id }))] : members.filter(member => !ids.has(member.id))); + }; + const navigate = (event: KeyboardEvent) => { + const stops = Array.from(panel.current?.querySelectorAll("button:not(:disabled), input:not(:disabled)") ?? []); + if (event.key === "Tab") { + const at = stops.indexOf(document.activeElement as HTMLElement); + if (event.shiftKey && at === 0 || !event.shiftKey && at === stops.length - 1) { + event.preventDefault(); (event.shiftKey ? stops.at(-1) : stops[0])?.focus(); + } + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const rows = Array.from(panel.current?.querySelectorAll(".composition-choice input") ?? []); + if (!rows.length) return; + const at = rows.indexOf(document.activeElement as HTMLInputElement); + event.preventDefault(); + rows[at < 0 ? event.key === "ArrowDown" ? 0 : rows.length - 1 : (at + (event.key === "ArrowDown" ? 1 : -1) + rows.length) % rows.length]?.focus(); + }; + const name = `${label} ${t("添加模块")}`; + return <> + + {visible && createPortal(, document.body)} + ; +} diff --git a/docs/AGENT_COMPOSITIONS.md b/docs/AGENT_COMPOSITIONS.md index 2bd5764c24..4b77c36f3d 100644 --- a/docs/AGENT_COMPOSITIONS.md +++ b/docs/AGENT_COMPOSITIONS.md @@ -21,6 +21,20 @@ to bind a version. ## Choosing and editing +For **Selected modules only**, **Add modules** opens a checkbox menu. Select several +items without reopening it, search by ID or description, or select a whole source +group. Built-in and custom modules are grouped using catalog provenance; older +hosts without that metadata show an unclassified group. + +**Select all** affects the current search results only. It preserves selections +outside the results and never selects future additions. **Clear** removes every +selected ID. **Done**, Escape and outside clicks close the menu while keeping the +draft; **Save preset** persists it. Inheritance remains a separate mode. + +Existing module parameters survive bulk selection changes. Unlisted IDs remain +editable, and **Advanced: enter a module ID** retains manual entry without implying +that an ID installs a resource or grants authorization. + The mode picker is hidden by default. Enable **Show mode picker** on the Persona management page to add it above the chat input in every pane. Settings search also leads directly to this switch. Hiding it leaves the conversation's mode intact. diff --git a/docs/AGENT_COMPOSITIONS.zh.md b/docs/AGENT_COMPOSITIONS.zh.md index 80101f34b5..28d3b7c006 100644 --- a/docs/AGENT_COMPOSITIONS.zh.md +++ b/docs/AGENT_COMPOSITIONS.zh.md @@ -13,6 +13,17 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook ## 选择和修改 +选择「仅使用所选项」后,「添加模块」打开多选下拉菜单。可连续勾选多项、按 ID +或说明搜索,也可按来源分组全选。内置与自定义分组使用目录提供的来源信息; +旧服务端没有来源信息时显示「未分类」。 + +「全选」只作用于当前搜索结果,保留搜索结果之外的已选项,不会自动选择以后新增 +的模块。「清空」移除全部已选 ID。「完成」、Escape 和点击外部都会关闭菜单并保留 +草稿;点击「保存预设」才持久化。「继承当前配置」仍是独立模式。 + +批量修改保留已有模块的参数。目录中缺失的 ID 仍可编辑;「高级:手动输入模块 ID」 +保留手动入口,填写 ID 不会安装资源或授予权限。 + 模式选择器默认隐藏。在「人设」管理页开启「显示模式选择器」后,所有窗格的聊天 输入框顶部显示选择器;设置搜索可直接定位此开关。关闭只隐藏入口,不改变会话模式。 diff --git a/internal/session/control/composition_inventory.go b/internal/session/control/composition_inventory.go index a4b34ecf18..7b9bd04c84 100644 --- a/internal/session/control/composition_inventory.go +++ b/internal/session/control/composition_inventory.go @@ -36,38 +36,42 @@ func (c *Controller) compositionInventory() (map[string][]agentpreset.Choice, er return nil, err } out := map[string][]agentpreset.Choice{} - add := func(kind, id, description string) { - out[kind] = append(out[kind], agentpreset.Choice{ID: id, Description: choiceDescription(description)}) + add := func(kind, id, description, source string) { + out[kind] = append(out[kind], agentpreset.Choice{ID: id, Description: choiceDescription(description), Source: source}) } for _, kind := range []string{"tools", "skills", "mcp", "extensions", "hooks", "commands"} { out[kind] = []agentpreset.Choice{} } for _, t := range tool.Builtins() { - add("tools", t.Name(), t.Description()) + add("tools", t.Name(), t.Description(), "builtin") } for _, name := range []string{"task", "run_skill", "read_only_skill", "use_capability"} { - add("tools", name, "") + add("tools", name, "", "builtin") } store := skill.New(skill.Options{ProjectRoot: s.Workspace, ReasonixHomeDir: s.Home, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), SuppressWarnings: true}) for _, sk := range store.CompositionSkills() { - add("skills", sk.SlashName(), sk.Description) + source := "custom" + if sk.Scope == skill.ScopeBuiltin { + source = "builtin" + } + add("skills", sk.SlashName(), sk.Description, source) } for _, m := range cfg.EnabledPlugins(s.Workspace, config.DefaultActivationStore()) { - add("mcp", m.Name, "") + add("mcp", m.Name, "", "custom") } installed, _ := pluginpkg.LoadInstalled(s.Home) for _, p := range installed { - add("extensions", p.Installed.Name, "") + add("extensions", p.Installed.Name, "", "custom") } cmds, err := command.LoadRoots(cfg.Roots().CommandRootsForRoot(s.Workspace)...) if err != nil { return nil, err } for _, cmd := range cmds { - add("commands", cmd.Name, cmd.Description) + add("commands", cmd.Name, cmd.Description, "custom") } for i, h := range hook.Load(hook.LoadOptions{ProjectRoot: s.Workspace, ReasonixHomeDir: s.Home}) { - add("hooks", fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i), "") + add("hooks", fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i), "", "custom") } for _, items := range out { sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) diff --git a/internal/session/control/composition_inventory_test.go b/internal/session/control/composition_inventory_test.go index 1fe504122c..623a8bbcf4 100644 --- a/internal/session/control/composition_inventory_test.go +++ b/internal/session/control/composition_inventory_test.go @@ -1,14 +1,62 @@ package control import ( + "encoding/json" "os" "path/filepath" "strings" "testing" + "reasonix/internal/ext/skill" "reasonix/internal/runtime/agent/testutil" ) +func TestModuleChoicesDeclareTheirSourceWithoutGuessingNames(t *testing.T) { + c, _, _ := newOneTurnController(t, testutil.Turn{Text: "answer"}) + testComposition(t, c) + s := c.PresetStore() + store := skill.New(skill.Options{ProjectRoot: s.Workspace, ReasonixHomeDir: s.Home, SuppressWarnings: true}) + var builtin string + for _, sk := range store.CompositionSkills() { + if sk.Scope == skill.ScopeBuiltin { + builtin = sk.SlashName() + break + } + } + if builtin == "" { + t.Fatal("missing built-in skill fixture") + } + check := func(id, want string) { + t.Helper() + catalog, err := c.AgentPresets() + if err != nil { + t.Fatal(err) + } + for _, choice := range catalog.Modules["skills"] { + if choice.ID != id { + continue + } + raw, _ := json.Marshal(choice) + var fields map[string]any + _ = json.Unmarshal(raw, &fields) + if fields["source"] != want { + t.Fatalf("%s source = %v, want %s", id, fields["source"], want) + } + return + } + t.Fatalf("missing choice %s", id) + } + check(builtin, "builtin") + dir := filepath.Join(s.Home, "skills", builtin) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: "+builtin+"\ndescription: Personal override\n---\nBody"), 0o600); err != nil { + t.Fatal(err) + } + check(builtin, "custom") +} + func choiceByID(t *testing.T, kind string, c *Controller, id string) (string, bool) { t.Helper() catalog, err := c.AgentPresets() diff --git a/internal/state/agentpreset/catalog.go b/internal/state/agentpreset/catalog.go index f58024b940..2cf7757c89 100644 --- a/internal/state/agentpreset/catalog.go +++ b/internal/state/agentpreset/catalog.go @@ -16,6 +16,7 @@ import ( type Choice struct { ID string `json:"id"` Description string `json:"description,omitempty"` + Source string `json:"source,omitempty"` } type Catalog struct { diff --git a/release-notes/studio/unreleased/preset-module-multiselect.md b/release-notes/studio/unreleased/preset-module-multiselect.md new file mode 100644 index 0000000000..8706b61374 --- /dev/null +++ b/release-notes/studio/unreleased/preset-module-multiselect.md @@ -0,0 +1,2 @@ +- 预设模块选择支持多选、搜索、全选与内置/自定义分组;手动 ID 入口移至高级选项,避免两个“添加模块”含义不同。Refs #13988. +- Preset modules support grouped multi-selection, search and select all. Manual ID entry is explicitly labeled as advanced; existing parameters are preserved. Refs #13988.