mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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.
This commit is contained in:
1 parent
596c3ada3c
commit
c2a485ebc5
17 files changed
+442
-58
No files matched your search
@@ -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"},
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
export const EN_COMPOSITION: Record<string,string> = {
|
||||
"已选 {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.",
|
||||
|
||||
@@ -4,7 +4,7 @@ export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks
|
||||
export interface CompositionModule { id: string; parameters?: Record<string, unknown> }
|
||||
// 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;
|
||||
|
||||
@@ -7,16 +7,20 @@ type Library = { revision:number; user:AgentPresetVersion[]; projects:Map<string
|
||||
|
||||
const MOCK_MODULES: AgentPresetCatalog["modules"] = {
|
||||
tools: [
|
||||
{ id: "bash", description: "Run a shell command in the workspace and return its output." },
|
||||
{ id: "bash_output", description: "Read new output from a shell command that is still running." },
|
||||
{ id: "browser_act", description: "Click, type or scroll on the page open in the built-in browser." },
|
||||
{ id: "edit_file", description: "Replace an exact piece of text in a file." },
|
||||
{ id: "task" },
|
||||
{ id: "view_image", description: "Read an image file." },
|
||||
{ source: "builtin", id: "bash", description: "Run a shell command in the workspace and return its output." },
|
||||
{ source: "builtin", id: "bash_output", description: "Read new output from a shell command that is still running." },
|
||||
{ source: "builtin", id: "browser_act", description: "Click, type or scroll on the page open in the built-in browser." },
|
||||
{ source: "builtin", id: "edit_file", description: "Replace an exact piece of text in a file." },
|
||||
{ source: "builtin", id: "task" },
|
||||
{ source: "builtin", id: "view_image", description: "Read an image file." },
|
||||
],
|
||||
skills: [
|
||||
{ id: "review", description: "Review the changes on this branch and report problems by severity." },
|
||||
{ id: "tidy-notes", description: "Tidy the notes folder" },
|
||||
{ source: "builtin", id: "review", description: "Review the changes on this branch and report problems by severity." },
|
||||
{ source: "builtin", id: "explore", description: "Explore the codebase and trace how a feature works." },
|
||||
{ source: "builtin", id: "reasonix-guide", description: "Explain Reasonix configuration and workflows." },
|
||||
{ source: "custom", id: "tidy-notes", description: "Tidy the notes folder" },
|
||||
{ source: "custom", id: "api-check", description: "Validate project endpoints and response contracts." },
|
||||
{ source: "custom", id: "release-check", description: "Run the team's release checklist." },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1675,6 +1675,9 @@ A sub-agent row, by the list's own width:
|
||||
- Preset management separates two built-in mode cards from custom presets. Card selection inspects a definition; setting a default affects new conversations and never switches the active binding.
|
||||
- Open floating controls follow responsive fold changes and recheck their anchor after layout transitions finish; window resize alone precedes the body observer's fold publication.
|
||||
|
||||
- Preset module selection uses one anchored, scroll-bounded checkbox popover with source groups, search, group selection and current-result selection. It stays open for repeated choices and returns focus on dismissal.
|
||||
- Source groups come from the module catalog; older hosts without provenance use an unclassified group. Selection edits retain existing parameter drafts. Selecting all does not change the inheritance mode.
|
||||
|
||||
## Side question
|
||||
|
||||
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.composition-module-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.composition-module-controls > 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; }
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<HTMLSelectElement>("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<HTMLSelectElement>("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" }]);
|
||||
});
|
||||
@@ -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(<CompositionEditor {...props()} />); },
|
||||
onValidityChange: (next: boolean) => { valid = next; },
|
||||
});
|
||||
const view = render(<CompositionEditor {...props()} />);
|
||||
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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLTextAreaElement>("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(<CompositionModulePicker {...props} />);
|
||||
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(<CompositionModulePicker {...props} disabled />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty catalogs without removing saved choices", async () => {
|
||||
render(<CompositionModulePicker kind="skills" label="技能" choices={[]} members={[{ id: "saved" }]} disabled={false} onChange={() => {}} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "技能 添加模块" }));
|
||||
expect(screen.getByText("暂无可选模块")).toBeTruthy();
|
||||
expect(screen.getByRole<HTMLInputElement>("checkbox").disabled).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "清空" })).toBeTruthy();
|
||||
});
|
||||
@@ -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<CompositionKind, string> = {
|
||||
tools: "工具",
|
||||
@@ -163,16 +165,17 @@ function ModuleEditor({
|
||||
setJsonErrors(errors);
|
||||
onValidityChange(Object.values(errors).every((v) => !v));
|
||||
};
|
||||
const remove = (index: number) => {
|
||||
const shift = <T,>(source: Record<number, T>): Record<number, T> =>
|
||||
const changeMembers = (members: CompositionModule[]) => {
|
||||
const remap = <T,>(source: Record<number, T>): Record<number, T> =>
|
||||
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 (
|
||||
<fieldset className="composition-modules" disabled={disabled}>
|
||||
@@ -180,6 +183,7 @@ function ModuleEditor({
|
||||
{kind === "hooks" && (
|
||||
<p className="ds">{t("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")}</p>
|
||||
)}
|
||||
<div className="composition-module-controls">
|
||||
<select
|
||||
aria-label={`${t(labels[kind])} ${t("选择方式")}`}
|
||||
data-action-change="agent-preset.module-mode"
|
||||
@@ -194,29 +198,10 @@ function ModuleEditor({
|
||||
<option value="inherit">{t("跟随当前配置,包含之后启用的资源")}</option>
|
||||
<option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
|
||||
</select>
|
||||
{value.mode === "select" && <CompositionModulePicker kind={kind} label={t(labels[kind])} choices={choices} members={value.members} disabled={disabled} onChange={changeMembers} />}
|
||||
</div>
|
||||
{value.mode === "select" && (
|
||||
<>
|
||||
{choices.length > 0 && (
|
||||
<select
|
||||
aria-label={`${t(labels[kind])} ${t("添加模块")}`}
|
||||
data-action-change="agent-preset.module-choose"
|
||||
data-target={kind}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value)
|
||||
onChange({ ...value, members: [...value.members, { id: e.target.value }] });
|
||||
}}
|
||||
>
|
||||
<option value="">{t("添加模块")}</option>
|
||||
{choices
|
||||
.filter((m) => !value.members.some((s) => s.id === m.id))
|
||||
.map((m) => (
|
||||
<option key={m.id} value={m.id} title={m.description || undefined}>
|
||||
{m.description ? `${m.id} — ${m.description}` : m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{value.members.map((module, index) => (
|
||||
<div className="composition-module" key={index}>
|
||||
<div className="composition-toolbar">
|
||||
@@ -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("移除")}
|
||||
</button>
|
||||
@@ -275,6 +260,9 @@ function ModuleEditor({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<details className="composition-manual">
|
||||
<summary>{t("高级:手动输入模块 ID")}</summary>
|
||||
<p className="ds">{t("仅在模块未列出时使用;填写 ID 不会安装模块或授予权限。")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="act"
|
||||
@@ -282,8 +270,9 @@ function ModuleEditor({
|
||||
data-target={kind}
|
||||
onClick={() => onChange({ ...value, members: [...value.members, { id: "" }] })}
|
||||
>
|
||||
{t("添加模块")}
|
||||
{t("添加空白 ID")}
|
||||
</button>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const all = useRef<HTMLInputElement>(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<HTMLDivElement>) => {
|
||||
const stops = Array.from(panel.current?.querySelectorAll<HTMLElement>("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<HTMLInputElement>(".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 <>
|
||||
<button ref={trigger} type="button" className="composition-module-trigger" data-action="agent-preset.module-choose" data-target={kind}
|
||||
aria-label={name} aria-haspopup="dialog" aria-expanded={visible} aria-controls={visible ? id : undefined} disabled={disabled}
|
||||
onClick={() => { if (visible) close(); else { setQuery(""); setOpen(true); } }}>
|
||||
<span>{t("添加模块")} · {t("已选 {n} 项", { n: members.length })}</span><StudioIcon name="down" />
|
||||
</button>
|
||||
{visible && createPortal(<div id={id} ref={panel} className="composition-module-picker" role="dialog" aria-label={name} aria-modal="true"
|
||||
data-action-keydown="agent-preset.module-navigate" onKeyDown={navigate}>
|
||||
<div className="composition-picker-tools">
|
||||
<label className="composition-select-all"><input ref={all} type="checkbox" checked={shown.length > 0 && count === shown.length} disabled={!shown.length}
|
||||
data-action-change="agent-preset.module-select-all" data-target={kind} onChange={event => toggle(shown, event.target.checked)} />
|
||||
{t(needle ? "全选搜索结果({n} 项)" : "全选({n} 项)", { n: shown.length })}</label>
|
||||
<button type="button" className="composition-picker-action" disabled={!members.length} data-action="agent-preset.module-clear" data-target={kind} onClick={() => onChange([])}>{t("清空")}</button>
|
||||
</div>
|
||||
<div className="composition-picker-search"><StudioIcon name="search" /><input ref={search} type="search" aria-label={t("搜索模块")} placeholder={t("搜索模块名称或说明…")}
|
||||
data-action-change="agent-preset.module-search" data-target={kind} value={query} onChange={event => setQuery(event.target.value)} /></div>
|
||||
<div className="composition-picker-list">
|
||||
{(["builtin", "custom", "unknown"] as const).map(source => {
|
||||
const items = shown.filter(choice => (choice.source === "builtin" || choice.source === "custom" ? choice.source : "unknown") === source);
|
||||
if (!items.length) return null;
|
||||
const chosen = items.filter(item => selected.has(item.id)).length;
|
||||
const group = t(source === "builtin" ? "内置" : source === "custom" ? "自定义" : "未分类");
|
||||
return <div role="group" aria-label={group} className="composition-picker-group" key={source}>
|
||||
<div className="composition-picker-group-heading"><strong>{group}</strong><small>{t("已选 {selected}/{total}", { selected: chosen, total: items.length })}</small>
|
||||
<button type="button" className="composition-picker-action" data-action="agent-preset.module-select-group" data-target={`${kind}:${source}`}
|
||||
onClick={() => toggle(items, chosen !== items.length)}>{t(chosen === items.length ? "取消本组全选" : "全选本组")}</button></div>
|
||||
{items.map((item, index) => <label key={item.id} className="composition-choice">
|
||||
<input type="checkbox" aria-label={item.id} aria-describedby={item.description ? `${id}-${source}-${index}` : undefined} checked={selected.has(item.id)}
|
||||
data-action-change="agent-preset.module-toggle" data-target={`${kind}:${item.id}`} onChange={event => toggle([item], event.target.checked)} />
|
||||
<StudioIcon name={source === "builtin" ? "code" : "layers"} /><span className="composition-choice-name">{item.id}</span>
|
||||
{item.description && <span className="composition-choice-description" id={`${id}-${source}-${index}`} title={item.description}>{item.description}</span>}
|
||||
</label>)}
|
||||
</div>;
|
||||
})}
|
||||
{!shown.length && <p className="composition-picker-empty">{t(unique.length ? "没有匹配的模块" : "暂无可选模块")}</p>}
|
||||
</div>
|
||||
<div className="composition-picker-footer"><span aria-live="polite">{t("已选 {n} 项", { n: members.length })}</span>
|
||||
<button type="button" className="btn" data-primary="" data-action="agent-preset.module-done" onClick={close}>{t("完成")}</button></div>
|
||||
<p className="composition-picker-note">{t("全选仅包含当前列表;新增模块不会自动选中。")}</p>
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -13,6 +13,17 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
|
||||
|
||||
## 选择和修改
|
||||
|
||||
选择「仅使用所选项」后,「添加模块」打开多选下拉菜单。可连续勾选多项、按 ID
|
||||
或说明搜索,也可按来源分组全选。内置与自定义分组使用目录提供的来源信息;
|
||||
旧服务端没有来源信息时显示「未分类」。
|
||||
|
||||
「全选」只作用于当前搜索结果,保留搜索结果之外的已选项,不会自动选择以后新增
|
||||
的模块。「清空」移除全部已选 ID。「完成」、Escape 和点击外部都会关闭菜单并保留
|
||||
草稿;点击「保存预设」才持久化。「继承当前配置」仍是独立模式。
|
||||
|
||||
批量修改保留已有模块的参数。目录中缺失的 ID 仍可编辑;「高级:手动输入模块 ID」
|
||||
保留手动入口,填写 ID 不会安装资源或授予权限。
|
||||
|
||||
模式选择器默认隐藏。在「人设」管理页开启「显示模式选择器」后,所有窗格的聊天
|
||||
输入框顶部显示选择器;设置搜索可直接定位此开关。关闭只隐藏入口,不改变会话模式。
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user