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:
1 parent
d1c19131a6
commit
6ac01d2835
9 files changed
+514
-6
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user