library: the skills picked can be put in a group of the user's own (#791, mintonight: 能不能在选择后自己创建分组呢) — with Select on (By source), the pick bar has Group…, which asks a name in the bar (Enter makes it, Escape goes back; a group there is is offered to add them to). The group is kept in library.json (skillGroups) and shown as a card of its own above the groups by source, open, its skills taken out of their sources' cards; a skill is in one group at most. The card has Rename (in place) and Ungroup, and the pick bar Ungroup for picked skills that are in one; grouping changes only how the page shows the skills, never what the agents get. A skill taken out of the library leaves its group, and an empty group goes. zh/ja/de strings added. TestSkillGroups and skill-groups.test.cjs (WebKit, en and zh: group, rename, ungroup; it fails on the old code, which has no Group…); pick-skills, skill-parts, group-skill-chips and the other library tests pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn, TestUserPath and TestClaudeSignedOut flaked under load and pass alone).

This commit is contained in:
yetone committed 2026-10-05 01:21:05 +08:00
1 parent d1c19131a6
commit 6ac01d2835
9 files changed
+514 -6

No files matched your search

+51
View File
@@ -2822,6 +2822,23 @@ const I18N = {
"All {n}": "全部 {n} 个",
"Pick skills to turn them on or off together": "勾选技能,再用右侧图标一起开启或关闭",
"Unpick them all": "取消全部选中",
"Rename this group": "重命名这个分组",
"Ungroup": "取消分组",
"Let this group go: its skills go back to where they came from, and stay as they are": "取消这个分组:其中的技能回到原来的来源分组,技能本身不变",
"Group…": "分组…",
"Put the skills picked in a group of your own": "把选中的技能放进你自己建的分组",
"Take the skills picked out of their groups": "把选中的技能移出它们的分组",
"Group name": "分组名称",
"Group": "创建分组",
"Add to {name}": "加入 {name}",
"Group 1 skill as": "把 1 个技能分组为",
"Group {n} skills as": "把 {n} 个技能分组为",
"Add them to {name}": "把它们加入 {name}",
"{skill} is in {name}": "{skill} 已在 {name} 中",
"{n} skills are in {name}": "{n} 个技能已在 {name} 中",
"{n} skills are out of {name}": "{n} 个技能已移出 {name}",
"{name} is no longer a group": "{name} 已不再是分组",
"{old} is now {name}": "{old} 已改名为 {name}",
"{repo}'s skills are on for {agents}": "已为 {agents} 启用 {repo} 的全部技能",
"{repo}'s skills are off for {agents}": "已从 {agents} 中关闭 {repo} 的全部技能",
"Open {repo} on GitHub": "在 GitHub 上打开 {repo}",
@@ -5788,6 +5805,23 @@ const I18N = {
"All {n}": "{n} 個すべて",
"Pick skills to turn them on or off together": "スキルを選び、右のアイコンでまとめてオン・オフ",
"Unpick them all": "選択をすべて解除",
"Rename this group": "このグループの名前を変更",
"Ungroup": "グループ解除",
"Let this group go: its skills go back to where they came from, and stay as they are": "このグループを解除:スキルは元のソースのグループに戻り、スキル自体はそのまま",
"Group…": "グループ化…",
"Put the skills picked in a group of your own": "選んだスキルを自分のグループにまとめる",
"Take the skills picked out of their groups": "選んだスキルをグループから外す",
"Group name": "グループ名",
"Group": "グループ化",
"Add to {name}": "{name} に追加",
"Group 1 skill as": "1 個のスキルのグループ名",
"Group {n} skills as": "{n} 個のスキルのグループ名",
"Add them to {name}": "{name} に追加する",
"{skill} is in {name}": "{skill} は {name} に入りました",
"{n} skills are in {name}": "{n} 個のスキルが {name} に入りました",
"{n} skills are out of {name}": "{n} 個のスキルを {name} から外しました",
"{name} is no longer a group": "{name} はグループではなくなりました",
"{old} is now {name}": "{old} を {name} に変更しました",
"{repo}'s skills are on for {agents}": "{agents} で {repo} のスキルをすべてオンにしました",
"{repo}'s skills are off for {agents}": "{agents} で {repo} のスキルをすべてオフにしました",
"Open {repo} on GitHub": "GitHub で {repo} を開く",
@@ -8762,6 +8796,23 @@ const I18N = {
"All {n}": "Alle {n}",
"Pick skills to turn them on or off together": "Wählen Sie Skills aus, um sie gemeinsam ein- oder auszuschalten",
"Unpick them all": "Alle abwählen",
"Rename this group": "Diese Gruppe umbenennen",
"Ungroup": "Gruppe auflösen",
"Let this group go: its skills go back to where they came from, and stay as they are": "Diese Gruppe auflösen: ihre Skills kehren zu ihrer Quelle zurück und bleiben, wie sie sind",
"Group…": "Gruppieren…",
"Put the skills picked in a group of your own": "Die ausgewählten Skills in eine eigene Gruppe legen",
"Take the skills picked out of their groups": "Die ausgewählten Skills aus ihren Gruppen nehmen",
"Group name": "Gruppenname",
"Group": "Gruppieren",
"Add to {name}": "Zu {name} hinzufügen",
"Group 1 skill as": "1 Skill gruppieren als",
"Group {n} skills as": "{n} Skills gruppieren als",
"Add them to {name}": "Zu {name} hinzufügen",
"{skill} is in {name}": "{skill} ist in {name}",
"{n} skills are in {name}": "{n} Skills sind in {name}",
"{n} skills are out of {name}": "{n} Skills sind aus {name} heraus",
"{name} is no longer a group": "{name} ist keine Gruppe mehr",
"{old} is now {name}": "{old} heißt jetzt {name}",
"{repo}'s skills are on for {agents}": "Die Skills von {repo} sind für {agents} an",
"{repo}'s skills are off for {agents}": "Die Skills von {repo} sind für {agents} aus",
"Open {repo} on GitHub": "{repo} auf GitHub öffnen",
+2
View File
@@ -487,3 +487,5 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-pickn.none { font-weight: 400; color: var(--faint); white-space: normal; }
.lib-pickbar .lib-updall { height: 24px; padding: 0 9px; font-size: 12px; flex: none; }
.lib-subhead > .lib-agents { margin-left: auto; }
.lib-pickbar .lib-groupname { flex: 1 1 140px; min-width: 100px; max-width: 240px; margin: 0; }
.lib-grouphead .lib-grouprename-in { height: 24px; margin: 0; max-width: 260px; font-weight: 500; }
+142 -6
View File
@@ -1783,6 +1783,7 @@
// skills draws a few rows now and then instead of laying out and painting
// each as it comes into view.
let picking = false; // the skills' rows have a box each, to pick some (#791)
let naming = false; // the pick bar asks the name of a group to put them in
const picked = new Set(); // the names picked
let skillQuery = ""; // what the filter over the skills holds
let skillTimer = 0; // the filter's redraw, waiting for typing to pause
@@ -1814,19 +1815,27 @@
grouped = { lib, pick, groups: [g] };
return grouped.groups;
}
// the user's own groups first, in the order made, each skill in its
// group rather than its source's (#791)
const mine = (lib.skillGroups || []).map((u) => ({ key: "my:" + u.name, repo: "", mine: u.name, skills: [], names: u.skills }));
const inMine = new Map();
for (const g of mine) for (const n of g.names) inMine.set(n, g);
const by = new Map();
for (const s of lib.skills) {
const own = inMine.get(s.name);
if (own) { own.skills.push(s); continue; }
const repo = s.kind === "github" ? repoOf(s.source) : s.origin ? repoOf(s.origin) : "";
const key = repo ? "gh:" + repo.toLowerCase() : "local";
let g = by.get(key);
if (!g) by.set(key, (g = { key, repo, skills: [] }));
g.skills.push(s);
}
const groups = [...by.values()].sort((a, b) => (a.key === "local") - (b.key === "local") || a.repo.localeCompare(b.repo));
const groups = [...mine.filter((g) => g.skills.length),
...[...by.values()].sort((a, b) => (a.key === "local") - (b.key === "local") || a.repo.localeCompare(b.repo))];
for (const g of groups) {
g.skills.sort((a, b) => a.name.localeCompare(b.name));
g.text = new Map(g.skills.map((s) => [s, (s.name + " " + (s.description || "") + " " + g.repo).toLowerCase()]));
g.parts = partsOf(g);
g.text = new Map(g.skills.map((s) => [s, (s.name + " " + (s.description || "") + " " + (g.mine || g.repo)).toLowerCase()]));
g.parts = g.mine ? null : partsOf(g);
}
grouped = { lib, pick, groups };
return groups;
@@ -2000,7 +2009,9 @@
card.dataset.group = g.key;
const head = el("div", "row lib-row click lib-grouphead" + (folded ? "" : " open"));
const who = el("div", "who");
who.append(el("div", "name" + (g.repo ? " mono" : ""), g.repo || t("On this computer")));
const title = g.mine || g.repo || t("On this computer");
const name = el("div", "name" + (g.repo ? " mono" : ""), title);
who.append(name);
const n = g.skills.length;
who.append(el("div", "sub", filtering && hits.length !== n ? t("{n} of {total} skills", { n: hits.length, total: n })
: n === 1 ? t("1 skill") : t("{n} skills", { n })));
@@ -2015,8 +2026,17 @@
u.title = stale.length === 1 ? t("Fetch {name} from GitHub again", { name: stale[0].name }) : t("Fetch the {n} skills GitHub changed again", { n: stale.length });
tags.append(u);
}
const have = groupChips(g.skills, all, g.repo || t("On this computer"));
const have = groupChips(g.skills, all, title);
const acts = el("div", "lib-rowacts");
// a group of the user's is renamed in place, or let go: its skills go
// back to their sources' groups, and stay in the library as they are
if (g.mine) {
const rn = button(t("Rename"), "lib-updall lib-grouprename", () => renameGroup(g, name));
rn.title = t("Rename this group");
const un = button(t("Ungroup"), "lib-updall lib-ungroup", () => ungroup(g.mine, null));
un.title = t("Let this group go: its skills go back to where they came from, and stay as they are");
tags.append(rn, un);
}
if (g.repo) {
const o = button("", "lib-icon", () => browse("https://github.com/" + g.repo));
o.append(svg(GLYPH.out, 13, 1.4));
@@ -2025,7 +2045,7 @@
}
const chev = el("span", "chev");
chev.append(svg(CHEV_R, 11, 1.7));
const pic = g.repo ? mark(g.skills.find((s) => s.icon)?.icon, GLYPH.skill) : glyph(GLYPH.folder);
const pic = g.repo ? mark(g.skills.find((s) => s.icon)?.icon, GLYPH.skill) : glyph(g.mine ? GLYPH.skill : GLYPH.folder);
if (picking) head.append(pickAll(hits));
head.append(pic, who, tags, have, acts, chev);
head.title = folded ? t("Show its skills") : t("Hide its skills");
@@ -2130,6 +2150,7 @@
}
// every box drawn, and the bar, as the picks are now
function syncPicks() {
if (!picked.size) naming = false;
for (const b of page.querySelectorAll(".lib-pickbox")) paintPick(b);
const bar = page.querySelector(".lib-pickbar");
if (bar) bar.replaceWith(pickBar(skillAgents()));
@@ -2144,8 +2165,23 @@
const skills = lib.skills.filter((s) => picked.has(s.name));
const n = skills.length;
bar.append(every, el("span", "lib-pickn" + (n ? "" : " none"), n ? t("{n} selected", { n }) : t("Pick skills to turn them on or off together")), el("span", "grow"));
if (n && naming) return nameBar(bar, skills);
if (n) {
bar.append(groupChips(skills, all, null));
// a group of the user's made of them, or the ones in one let go (#791)
if (sortOf("libSkills", SKILL_SORTS()) === "source") {
const gb = button(t("Group…"), "lib-updall lib-pickgroup", () => { naming = true; syncPicks(); });
gb.title = t("Put the skills picked in a group of your own");
bar.append(gb);
const own = (lib.skillGroups || []).filter((u) => u.skills.some((x) => picked.has(x)));
if (own.length) {
const ub = button(t("Ungroup"), "lib-updall lib-pickungroup", async () => {
for (const u of own) await ungroup(u.name, u.skills.filter((x) => picked.has(x)));
});
ub.title = t("Take the skills picked out of their groups");
bar.append(ub);
}
}
const c = button(t("Clear"), "lib-updall lib-pickclear", () => { picked.clear(); syncPicks(); });
c.title = t("Unpick them all");
bar.append(c);
@@ -2154,6 +2190,106 @@
return bar;
}
// The bar asking a group's name for the skills picked: a new group's, or
// one there is, which they join. Enter makes it, Escape goes back.
function nameBar(bar, skills) {
bar.classList.add("naming");
const f = el("input", "lib-filter lib-groupname");
f.placeholder = t("Group name");
f.spellcheck = false;
f.autocomplete = "off";
const have = (lib.skillGroups || []).map((u) => u.name);
const go = button(t("Group"), "action lib-updall lib-groupgo", () => makeGroup(f.value, skills));
const sync = () => {
const v = f.value.trim();
go.disabled = !v;
go.textContent = have.includes(v) ? t("Add to {name}", { name: v }) : t("Group");
};
f.oninput = sync;
f.onkeydown = (e) => {
e.stopPropagation();
if (e.key === "Enter" && f.value.trim()) { e.preventDefault(); makeGroup(f.value, skills); }
if (e.key === "Escape") { e.preventDefault(); naming = false; syncPicks(); }
};
sync();
const back = button(t("Cancel"), "lib-updall lib-groupcancel", () => { naming = false; syncPicks(); });
bar.append(el("span", "lib-pickn", skills.length === 1 ? t("Group 1 skill as") : t("Group {n} skills as", { n: skills.length })), f);
// a group there is, to put them in with a click
for (const h of have.slice(0, 4)) {
const b = button(h, "lib-updall lib-groupto", () => makeGroup(h, skills));
b.title = t("Add them to {name}", { name: h });
bar.append(b);
}
bar.append(el("span", "grow"), back, go);
requestAnimationFrame(() => f.focus({ preventScroll: true }));
return bar;
}
async function makeGroup(name, skills) {
name = name.trim();
if (!name) return;
try {
const v = await api("library/skills/group", { name, names: skills.map((s) => s.name) });
take(v);
folds["my:" + name] = false; // a group just made is shown open
saveFolds();
picking = false;
naming = false;
picked.clear();
report(v.result, skills.length === 1 ? t("{skill} is in {name}", { skill: skills[0].name, name }) : t("{n} skills are in {name}", { n: skills.length, name }));
} catch (e) {
status(e.message, "err", 6000);
}
render();
}
// a group of the user's let go, or only the skills named out of it
async function ungroup(name, names) {
try {
const v = await api("library/skills/ungroup", { name, names: names || [] });
take(v);
if (names) for (const n of names) picked.delete(n);
report(v.result, names ? t("{n} skills are out of {name}", { n: names.length, name }) : t("{name} is no longer a group", { name }));
} catch (e) {
status(e.message, "err", 6000);
}
render();
}
// a group's name, made a box to type its new one in
function renameGroup(g, name) {
const f = el("input", "lib-filter lib-grouprename-in");
f.value = g.mine;
f.spellcheck = false;
f.autocomplete = "off";
let done = false;
const save = async () => {
if (done) return;
done = true;
const to = f.value.trim();
if (!to || to === g.mine) { render(); return; }
try {
const v = await api("library/skills/group", { old: g.mine, name: to, names: [] });
take(v);
if (g.key in folds) { folds["my:" + to] = folds[g.key]; delete folds[g.key]; saveFolds(); }
report(v.result, t("{old} is now {name}", { old: g.mine, name: to }));
} catch (e) {
status(e.message, "err", 6000);
}
render();
};
f.onclick = (e) => e.stopPropagation();
f.onkeydown = (e) => {
e.stopPropagation();
if (e.key === "Enter") { e.preventDefault(); save(); }
if (e.key === "Escape") { e.preventDefault(); done = true; render(); }
};
f.onblur = save;
name.replaceWith(f);
f.focus({ preventScroll: true });
f.select();
}
// ---------- a long list, drawn near the view first ----------
// A list of items of known heights ({ key, h, make }) drawn in two goes:
+4
View File
@@ -386,6 +386,10 @@ func libraryRoutes(mux *http.ServeMux, w Windows) {
res, err = library.RemoveSkill(in.Name)
case "skills/remove-all":
res, err = library.RemoveSkills(in.Names)
case "skills/group":
res, err = library.GroupSkills(in.Old, in.Name, in.Names)
case "skills/ungroup":
res, err = library.UngroupSkills(in.Name, in.Names)
case "skills/import":
res, err = library.ImportSkill(in.Name)
case "skills/import-all":
+140
View File
@@ -0,0 +1,140 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A group of the user's made of the skills picked (#791, mintonight: 能不能
// 在选择后自己创建分组呢): Select, pick some, Group… asks a name, and the
// group made is a card of its own above the ones by source, its skills out
// of theirs. It is renamed in place, and Ungroup lets it go. English and
// Chinese; the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const HOME = "/Users/me";
const agent = (id, name, icon) => ({ id, name, icon, skills: `${HOME}/.${id}/skills`, mcp: `${HOME}/.${id}/mcp.json` });
const local = (name) => ({ name, kind: "folder", source: `${HOME}/.agents/skills/${name}`, description: "d", agents: ["claude"] });
const gh = (name) => ({ name, kind: "github", source: `https://github.com/acme/skills/tree/main/skills/${name}`, description: "d", agents: ["claude"] });
function server(lang, state, posts) {
return async (route) => {
const req = route.request();
const url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/library") return json(state.lib);
if (url.pathname === "/api/library/skills/group") {
const b = req.postDataJSON();
posts.push({ group: b });
const gs = state.lib.skillGroups;
let g = gs.find((x) => x.name === b.name);
if (b.old && b.old !== b.name) { g = gs.find((x) => x.name === b.old); g.name = b.name; }
if (!g) gs.push((g = { name: b.name, skills: [] }));
for (const o of gs) if (o !== g) o.skills = o.skills.filter((n) => !b.names.includes(n));
for (const n of b.names) if (!g.skills.includes(n)) g.skills.push(n);
state.lib.skillGroups = gs.filter((x) => x.skills.length);
return json({ ...state.lib, result: {} });
}
if (url.pathname === "/api/library/skills/ungroup") {
const b = req.postDataJSON();
posts.push({ ungroup: b });
for (const g of state.lib.skillGroups) if (g.name === b.name) g.skills = b.names.length ? g.skills.filter((n) => !b.names.includes(n)) : [];
state.lib.skillGroups = state.lib.skillGroups.filter((x) => x.skills.length);
return json({ ...state.lib, result: {} });
}
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] });
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname.startsWith("/api/")) return json({});
if (url.host !== "magpie.test") return route.fulfill({ status: 404, body: "" });
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const L = {
en: { group: "Group…", made: "2 skills are in Office", renamed: "Office is now Docs", gone: "Docs is no longer a group", local: "On this computer", two: "2 skills", ask: "Group 2 skills as", ungroup: "Ungroup", rename: "Rename" },
zh: { group: "分组…", made: "2 个技能已在 Office 中", renamed: "Office 已改名为 Docs", gone: "Docs 已不再是分组", local: "本机", two: "2 个技能", ask: "把 2 个技能分组为", ungroup: "取消分组", rename: "重命名" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": a group of the user's made of the skills picked", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
t.after(() => browser.close());
for (const lang of ["en", "zh"]) {
await t.test(lang, async () => {
const w = L[lang];
const state = {
lib: {
dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME,
agents: [agent("claude", "Claude Code", "claudecode-color"), agent("codex", "Codex", "openai")],
instructions: { agents: [], sets: [] }, servers: [], foundServers: [], projects: [], foundSkills: [], problems: [],
skills: [...["docx", "notes", "pdf"].map(local), ...["review", "triage"].map(gh)], skillGroups: [],
},
};
const posts = [];
const ctx = await browser.newContext({ viewport: { width: 1100, height: 760 }, reducedMotion: "reduce" });
await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "skills"); } catch {} });
const page = await ctx.newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, state, posts));
await page.goto("http://magpie.test/");
await page.locator('button[data-view="library"]').click();
const v = page.locator("#view-library");
const row = (name) => v.locator(".lib-skill").filter({ has: page.locator(".name", { hasText: new RegExp("^" + name + "$") }) });
const cards = () => v.locator(".lib-group .lib-grouphead .who .name").allTextContents();
await row("pdf").waitFor();
assert.deepEqual(await cards(), ["acme/skills", w.local]);
// pick two, one from each source, and group them as Office
await v.locator(".lib-select").click();
await row("pdf").locator(".lib-pickbox").click();
await row("review").locator(".lib-pickbox").click();
await v.locator(".lib-pickgroup").filter({ hasText: w.group }).click();
await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: w.ask }).waitFor();
const name = v.locator(".lib-groupname");
assert.equal(await v.locator(".lib-groupgo").isDisabled(), true, "Group was on with no name");
await name.fill("Office");
await name.press("Enter");
await page.locator("#status").filter({ hasText: w.made }).waitFor();
assert.deepEqual(posts.shift(), { group: { name: "Office", names: ["pdf", "review"] } });
// its card comes first, open, with its two; theirs are without them
await page.waitForFunction(() => document.querySelector("#view-library .lib-group")?.dataset.group === "my:Office");
assert.deepEqual(await cards(), ["Office", "acme/skills", w.local]);
const office = v.locator('.lib-group[data-group="my:Office"]');
assert.equal(await office.locator(".lib-grouphead .sub").textContent(), w.two);
assert.deepEqual((await office.locator(".lib-skill .name").allTextContents()).sort(), ["pdf", "review"]);
assert.equal(await v.locator('.lib-group[data-group="local"] .lib-skill').filter({ hasText: /^pdf/ }).count(), 0);
assert.equal(await v.locator(".lib-pickbox").count(), 0, "still picking after the group was made");
// renamed in place: its fold isn't touched by the click
await office.locator(".lib-grouprename").filter({ hasText: w.rename }).click();
const box = office.locator(".lib-grouprename-in");
assert.equal(await box.inputValue(), "Office");
await box.fill("Docs");
await box.press("Enter");
await page.locator("#status").filter({ hasText: w.renamed }).waitFor();
assert.deepEqual(posts.shift(), { group: { old: "Office", name: "Docs", names: [] } });
const docs = v.locator('.lib-group[data-group="my:Docs"]');
await docs.locator(".lib-skill").first().waitFor();
// Ungroup lets it go, and its skills are back in their sources'
await docs.locator(".lib-ungroup").filter({ hasText: w.ungroup }).click();
await page.locator("#status").filter({ hasText: w.gone }).waitFor();
assert.deepEqual(posts.shift(), { ungroup: { name: "Docs", names: [] } });
await page.waitForFunction(() => !document.querySelector('#view-library .lib-group[data-group^="my:"]'));
assert.deepEqual(await cards(), ["acme/skills", w.local]);
assert.deepEqual(errors, []);
await ctx.close();
});
}
});
}
+3
View File
@@ -32,6 +32,9 @@ type Library struct {
// Icons are the icons of the servers added from the market, by what
// each runs, for the page to show them by
Icons map[string]string `json:"icons,omitempty"`
// SkillGroups are the groups the user made of some skills on the
// page, each shown as a group of its own (#791)
SkillGroups []*SkillGroup `json:"skillGroups,omitempty"`
// kept is where a change put what it kept aside before the sync, for
// the sync to keep the agents' files beside it
kept *backups
+113
View File
@@ -0,0 +1,113 @@
package library
import (
"fmt"
"slices"
"strings"
)
// SkillGroup is a group the user made of some skills (#791, mintonight:
// 能不能在选择后自己创建分组呢): the page shows it as a group of its own,
// above the ones by source, with its skills taken out of theirs. It is
// only how the page shows them; the agents get each skill as before.
type SkillGroup struct {
Name string `json:"name"`
Skills []string `json:"skills"`
}
// skillGroups are l's groups with the skills still in the library, and
// none left empty.
func (l *Library) skillGroups() []SkillGroup {
out := []SkillGroup{}
for _, g := range l.SkillGroups {
names := slices.DeleteFunc(slices.Clone(g.Skills), func(n string) bool { return l.skill(n) == nil })
if len(names) > 0 {
out = append(out, SkillGroup{Name: g.Name, Skills: names})
}
}
return out
}
// GroupSkills puts the skills named into the group called name, made
// when there is none, and takes them out of any other: a skill is in one
// group at most. old, when given, is a group renamed to name, and its
// skills are then those named when any are. No names and no old is
// nothing; a rename to a group there is already is refused.
func GroupSkills(old, name string, names []string) (*Result, error) {
name, old = strings.TrimSpace(name), strings.TrimSpace(old)
if name == "" {
return nil, fmt.Errorf("a group needs a name")
}
if old == "" && len(names) == 0 {
return nil, fmt.Errorf("no skills to group")
}
return change(func(l *Library) error {
for _, n := range names {
if l.skill(n) == nil {
return fmt.Errorf("no skill called %s", n)
}
}
g := l.skillGroup(name)
if old != "" && old != name {
if g != nil {
return fmt.Errorf("there is a group called %s already", name)
}
if g = l.skillGroup(old); g == nil {
return fmt.Errorf("no group called %s", old)
}
g.Name = name
}
if g == nil {
g = &SkillGroup{Name: name}
l.SkillGroups = append(l.SkillGroups, g)
}
for _, o := range l.SkillGroups {
if o != g {
o.Skills = slices.DeleteFunc(o.Skills, func(n string) bool { return slices.Contains(names, n) })
}
}
for _, n := range names {
if !slices.Contains(g.Skills, n) {
g.Skills = append(g.Skills, n)
}
}
l.pruneSkillGroups()
return nil
})
}
// UngroupSkills takes the group called name away; its skills go back to
// the groups by source. With names, only those leave it.
func UngroupSkills(name string, names []string) (*Result, error) {
return change(func(l *Library) error {
g := l.skillGroup(strings.TrimSpace(name))
if g == nil {
return fmt.Errorf("no group called %s", name)
}
if len(names) == 0 {
g.Skills = nil
} else {
g.Skills = slices.DeleteFunc(g.Skills, func(n string) bool { return slices.Contains(names, n) })
}
l.pruneSkillGroups()
return nil
})
}
func (l *Library) skillGroup(name string) *SkillGroup {
for _, g := range l.SkillGroups {
if g.Name == name {
return g
}
}
return nil
}
// pruneSkillGroups drops skills no longer in the library, and groups
// left with none.
func (l *Library) pruneSkillGroups() {
for _, g := range l.SkillGroups {
g.Skills = slices.DeleteFunc(g.Skills, func(n string) bool { return l.skill(n) == nil })
}
l.SkillGroups = slices.DeleteFunc(l.SkillGroups, func(g *SkillGroup) bool { return len(g.Skills) == 0 })
}
+57
View File
@@ -0,0 +1,57 @@
package library
import (
"path/filepath"
"reflect"
"testing"
)
// Groups the user makes of some skills (#791, mintonight: 能不能在选择后
// 自己创建分组呢): a skill is in one at most, a rename keeps its skills,
// one ungrouped is gone, and a skill taken out of the library leaves its
// group, which goes once it's empty.
func TestSkillGroups(t *testing.T) {
h := sandbox(t)
src := filepath.Join(h, "src/skills")
for _, n := range []string{"pdf", "xlsx", "docx"} {
skill(t, filepath.Join(src, n), n, n)
}
ok(t)(InstallSkills(src, []string{"pdf", "xlsx", "docx"}, []string{"claude"}))
groups := func() []SkillGroup {
v, err := Read(nil)
if err != nil {
t.Fatal(err)
}
return v.SkillGroups
}
if _, err := GroupSkills("", " ", []string{"pdf"}); err == nil {
t.Error("a group with no name was made")
}
if _, err := GroupSkills("", "office", []string{"nope"}); err == nil {
t.Error("a skill the library hasn't was grouped")
}
ok(t)(GroupSkills("", " office ", []string{"pdf", "xlsx"}))
ok(t)(GroupSkills("", "docs", []string{"docx", "pdf"}))
want := []SkillGroup{{"office", []string{"xlsx"}}, {"docs", []string{"docx", "pdf"}}}
if g := groups(); !reflect.DeepEqual(g, want) {
t.Fatalf("groups %+v, want %+v", g, want)
}
if _, err := GroupSkills("office", "docs", nil); err == nil {
t.Error("a rename onto another group was taken")
}
ok(t)(GroupSkills("office", "sheets", nil))
ok(t)(UngroupSkills("docs", []string{"pdf"}))
want = []SkillGroup{{"sheets", []string{"xlsx"}}, {"docs", []string{"docx"}}}
if g := groups(); !reflect.DeepEqual(g, want) {
t.Fatalf("after a rename and one out %+v, want %+v", g, want)
}
ok(t)(UngroupSkills("sheets", nil))
ok(t)(RemoveSkill("docx"))
if g := groups(); len(g) != 0 {
t.Errorf("left %+v", g)
}
if v, _ := Read(nil); len(v.Skills) != 2 || len(v.Skills[0].Agents) != 1 {
t.Errorf("grouping changed the skills: %+v", v.Skills)
}
}
+2
View File
@@ -214,6 +214,7 @@ type View struct {
Instructions *InstructionsView `json:"instructions"`
Dir string `json:"dir"`
Backups string `json:"backups"`
SkillGroups []SkillGroup `json:"skillGroups"`
}
// Read is the whole page: the library, and what's found in the agents.
@@ -319,6 +320,7 @@ func Read(problems []Problem) (*View, error) {
}
v.FoundSkills = foundSkills(l)
v.Projects = projectViews(l, problems)
v.SkillGroups = l.skillGroups()
return v, nil
}