From 6ac01d2835d4ffe6e7eff30e893c4f88488ecf9d Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 01:21:01 +0800 Subject: [PATCH] =?UTF-8?q?library:=20the=20skills=20picked=20can=20be=20p?= =?UTF-8?q?ut=20in=20a=20group=20of=20the=20user's=20own=20(#791,=20minton?= =?UTF-8?q?ight:=20=E8=83=BD=E4=B8=8D=E8=83=BD=E5=9C=A8=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=90=8E=E8=87=AA=E5=B7=B1=E5=88=9B=E5=BB=BA=E5=88=86=E7=BB=84?= =?UTF-8?q?=E5=91=A2)=20=E2=80=94=20with=20Select=20on=20(By=20source),=20?= =?UTF-8?q?the=20pick=20bar=20has=20Group=E2=80=A6,=20which=20asks=20a=20n?= =?UTF-8?q?ame=20in=20the=20bar=20(Enter=20makes=20it,=20Escape=20goes=20b?= =?UTF-8?q?ack;=20a=20group=20there=20is=20is=20offered=20to=20add=20them?= =?UTF-8?q?=20to).=20The=20group=20is=20kept=20in=20library.json=20(skillG?= =?UTF-8?q?roups)=20and=20shown=20as=20a=20card=20of=20its=20own=20above?= =?UTF-8?q?=20the=20groups=20by=20source,=20open,=20its=20skills=20taken?= =?UTF-8?q?=20out=20of=20their=20sources'=20cards;=20a=20skill=20is=20in?= =?UTF-8?q?=20one=20group=20at=20most.=20The=20card=20has=20Rename=20(in?= =?UTF-8?q?=20place)=20and=20Ungroup,=20and=20the=20pick=20bar=20Ungroup?= =?UTF-8?q?=20for=20picked=20skills=20that=20are=20in=20one;=20grouping=20?= =?UTF-8?q?changes=20only=20how=20the=20page=20shows=20the=20skills,=20nev?= =?UTF-8?q?er=20what=20the=20agents=20get.=20A=20skill=20taken=20out=20of?= =?UTF-8?q?=20the=20library=20leaves=20its=20group,=20and=20an=20empty=20g?= =?UTF-8?q?roup=20goes.=20zh/ja/de=20strings=20added.=20TestSkillGroups=20?= =?UTF-8?q?and=20skill-groups.test.cjs=20(WebKit,=20en=20and=20zh:=20group?= =?UTF-8?q?,=20rename,=20ungroup;=20it=20fails=20on=20the=20old=20code,=20?= =?UTF-8?q?which=20has=20no=20Group=E2=80=A6);=20pick-skills,=20skill-part?= =?UTF-8?q?s,=20group-skill-chips=20and=20the=20other=20library=20tests=20?= =?UTF-8?q?pass=20on=20WebKit.=20go=20vet,=20builds=20(darwin,=20GOOS=3Dli?= =?UTF-8?q?nux=20nogui,=20GOOS=3Dwindows)=20and=20go=20test=20-tags=20nogu?= =?UTF-8?q?i=20-count=3D1=20./...=20under=20a=20temp=20HOME=20pass=20(Test?= =?UTF-8?q?ProvidersAnswerWhileListsComeIn,=20TestUserPath=20and=20TestCla?= =?UTF-8?q?udeSignedOut=20flaked=20under=20load=20and=20pass=20alone).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/assets/i18n.js | 51 ++++++++ internal/gui/assets/library.css | 2 + internal/gui/assets/library.js | 148 ++++++++++++++++++++++- internal/gui/library.go | 4 + internal/gui/tests/skill-groups.test.cjs | 140 +++++++++++++++++++++ internal/library/library.go | 3 + internal/library/skill_groups.go | 113 +++++++++++++++++ internal/library/skill_groups_test.go | 57 +++++++++ internal/library/sync.go | 2 + 9 files changed, 514 insertions(+), 6 deletions(-) create mode 100644 internal/gui/tests/skill-groups.test.cjs create mode 100644 internal/library/skill_groups.go create mode 100644 internal/library/skill_groups_test.go diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 19ffe7a6..eeb864dd 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index ca9b1a4e..d4beebb2 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -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; } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index abfe73d2..eb572158 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -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: diff --git a/internal/gui/library.go b/internal/gui/library.go index f7126ae1..d95f1bf2 100644 --- a/internal/gui/library.go +++ b/internal/gui/library.go @@ -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": diff --git a/internal/gui/tests/skill-groups.test.cjs b/internal/gui/tests/skill-groups.test.cjs new file mode 100644 index 00000000..ea2b4d40 --- /dev/null +++ b/internal/gui/tests/skill-groups.test.cjs @@ -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(); + }); + } + }); +} diff --git a/internal/library/library.go b/internal/library/library.go index 630a42d6..5c3e5f20 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -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 diff --git a/internal/library/skill_groups.go b/internal/library/skill_groups.go new file mode 100644 index 00000000..a4302687 --- /dev/null +++ b/internal/library/skill_groups.go @@ -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 }) +} diff --git a/internal/library/skill_groups_test.go b/internal/library/skill_groups_test.go new file mode 100644 index 00000000..cf5279df --- /dev/null +++ b/internal/library/skill_groups_test.go @@ -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) + } +} diff --git a/internal/library/sync.go b/internal/library/sync.go index c25b07ba..f1c480c6 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -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 }