agent-loft 0.2.32 → 0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,54 +2,41 @@ import { fill, h, icon, starsLoader, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { renderMarkdown } from "../lib/markdown.js";
4
4
  import { confirmDialog, modal, showMenu } from "../lib/overlay.js";
5
- import { S, ui } from "../state.js";
6
- import { WS } from "../lib/ws.js";
7
- import { closeSide, renderSidebar } from "./sidebar.js";
8
- import { renderTopbar } from "./topbar.js";
9
-
10
- // ---------- 技能页 ----------
11
- // 三块:我的技能(自己建的 + 装了的,可以停用、填变量)、技能广场(别人分享给我、公开的,点安装)、
12
- // 本机技能(机器上原有的,只有管理员看得到、用得到)。
13
- // 装的是作者那一份本身:作者改了,装了的人跟着用新的。技能有变化时服务端推 { t: "skills" },这里重新拉。
14
-
15
- const K = { data: null, tab: "mine", query: "", token: 0, reloadTimer: 0 };
16
-
17
- const VISIBILITY = { private: "仅自己", users: "指定的人", all: "所有人" };
18
-
19
- export async function openSkills() {
20
- if (S.current) WS.raw({ t: "unsub", key: S.current });
21
- S.current = null;
22
- S.view = "skills";
23
- S.items = [];
24
- S.turnAi = null;
25
- history.replaceState(null, "", "#skills");
26
- WS.presence();
27
- ui.spacer.style.height = "0px";
28
- ui.follow = false;
29
- // 和定时任务页一样:藏起输入框(css/tasks.css 的 .tasks-view)
30
- ui.main.classList.add("tasks-view");
31
- closeSide();
32
- renderTopbar();
33
- renderSidebar();
34
- fill(ui.msgInner, h("div", { class: "empty" }, starsLoader("lg")));
5
+
6
+ // ---------- 技能(设置里的一页) ----------
7
+ // 我的技能:自己建的,加上以前从别人那装的(可以停用、填变量、卸载);管理员另有「本机技能」(机器上原有的)。
8
+ // 技能有变化时服务端推 { t: "skills" }(lib/ws.js 转成 aim:skills 事件),这一页开着就重新拉。
9
+
10
+ const K = { data: null, tab: "mine", query: "", token: 0, reloadTimer: 0, box: null };
11
+
12
+ /** 以前分享出去的技能(现在不能再设了):卡片上标一下还有谁看得到 */
13
+ const SHARED = { users: "分享给了指定的人", all: "所有人可见" };
14
+
15
+ export async function skillsTab(panel) {
16
+ K.tab = "mine";
17
+ K.query = "";
18
+ K.box = h("div", { class: "skills-page" }, h("div", { class: "section" }, h("h4", {}, "技能"), h("span", { class: "spinner" })));
19
+ fill(panel, K.box);
35
20
  await reload();
36
- ui.messages.scrollTop = 0;
37
21
  }
38
22
 
23
+ /** 这一页还开着(没关设置、没切到别的标签页) */
24
+ const showing = () => !!K.box?.isConnected;
25
+
39
26
  async function reload() {
40
27
  const my = ++K.token;
41
28
  try {
42
29
  const data = await api("/api/skills");
43
- if (my !== K.token || S.view !== "skills") return;
30
+ if (my !== K.token || !showing()) return;
44
31
  K.data = data;
45
32
  render();
46
33
  } catch (err) {
47
- if (my === K.token && S.view === "skills") fill(ui.msgInner, h("div", { class: "empty" }, err.message));
34
+ if (my === K.token && showing()) fill(K.box, h("div", { class: "error-text" }, err.message));
48
35
  }
49
36
  }
50
37
 
51
38
  document.addEventListener("aim:skills", () => {
52
- if (S.view !== "skills") return;
39
+ if (!showing()) return;
53
40
  clearTimeout(K.reloadTimer);
54
41
  K.reloadTimer = setTimeout(() => void reload(), 300);
55
42
  });
@@ -58,76 +45,66 @@ function mine() {
58
45
  return K.data.skills.filter((s) => s.installed).sort((a, b) => a.name.localeCompare(b.name));
59
46
  }
60
47
 
61
- function plaza() {
62
- return K.data.skills.filter((s) => !s.mine).sort((a, b) => b.updatedAt - a.updatedAt);
63
- }
64
-
65
48
  function matches(s) {
66
49
  const q = K.query.trim().toLowerCase();
67
50
  return !q || s.name.includes(q) || s.description.toLowerCase().includes(q) || (s.ownerName ?? "").toLowerCase().includes(q);
68
51
  }
69
52
 
70
53
  function render() {
71
- const tabs = [
72
- ["mine", `我的技能 ${mine().length}`],
73
- ["plaza", `技能广场 ${plaza().length}`],
74
- ...(K.data.machine ? [["machine", `本机技能 ${K.data.machine.length}`]] : []),
75
- ];
76
- const search = h("input", { class: "skill-search", type: "search", placeholder: "搜索名字、说明、作者", value: K.query });
54
+ const tabs = K.data.machine
55
+ ? [
56
+ ["mine", `我的技能 ${mine().length}`],
57
+ ["machine", `本机技能 ${K.data.machine.length}`],
58
+ ]
59
+ : [];
60
+ const search = h("input", { class: "skill-search", type: "search", placeholder: "搜索名字、说明", value: K.query });
77
61
  search.oninput = () => {
78
62
  K.query = search.value;
79
63
  fill(listBox, renderList());
80
64
  };
81
65
  const listBox = h("div", { class: "skill-list-box" }, renderList());
82
66
  fill(
83
- ui.msgInner,
67
+ K.box,
84
68
  h(
85
69
  "div",
86
- { class: "tasks-page skills-page" },
70
+ { class: "skills-head" },
87
71
  h(
88
- "div",
89
- { class: "tasks-head" },
90
- h(
91
- "div",
92
- { class: "grow" },
93
- h("h2", {}, "技能"),
94
- h(
95
- "p",
96
- {},
97
- "技能是一份写给 agent 的做事方法(SKILL.md),可以带脚本和资料,Claude 和 Codex 都能用。工作模式下 agent 遇到对得上的事会自己用;在输入框或定时任务里输入 @ 可以点名用哪个。",
98
- ),
99
- ),
100
- h(
101
- "div",
102
- { class: "skill-head-btns" },
103
- h("button", { class: "btn", onclick: () => pickUpload() }, "上传"),
104
- h("button", { class: "btn primary", onclick: () => openEditor() }, icon("plus"), "新建"),
105
- ),
72
+ "p",
73
+ { class: "skill-note" },
74
+ "技能是一份写给 agent 的做事方法(SKILL.md),可以带脚本和资料,Claude 和 Codex 都能用。工作模式下 agent 遇到对得上的事会自己用;在输入框或定时任务里输入 @ 可以点名用哪个。",
106
75
  ),
107
76
  h(
108
77
  "div",
109
- { class: "skill-tabs" },
110
- h(
111
- "div",
112
- { class: "seg" },
113
- tabs.map(([id, label]) =>
114
- h(
115
- "button",
116
- {
117
- class: K.tab === id ? "on" : "",
118
- onclick: () => {
119
- K.tab = id;
120
- render();
78
+ { class: "skill-head-btns" },
79
+ h("button", { class: "btn", onclick: () => pickUpload() }, "上传"),
80
+ h("button", { class: "btn primary", onclick: () => openEditor() }, icon("plus"), "新建"),
81
+ ),
82
+ ),
83
+ h(
84
+ "div",
85
+ { class: "skill-tabs" },
86
+ tabs.length
87
+ ? h(
88
+ "div",
89
+ { class: "seg" },
90
+ tabs.map(([id, label]) =>
91
+ h(
92
+ "button",
93
+ {
94
+ class: K.tab === id ? "on" : "",
95
+ onclick: () => {
96
+ K.tab = id;
97
+ render();
98
+ },
121
99
  },
122
- },
123
- label,
100
+ label,
101
+ ),
124
102
  ),
125
- ),
126
- ),
127
- search,
128
- ),
129
- listBox,
103
+ )
104
+ : null,
105
+ search,
130
106
  ),
107
+ listBox,
131
108
  );
132
109
  }
133
110
 
@@ -135,16 +112,10 @@ function renderList() {
135
112
  if (K.tab === "machine") {
136
113
  const list = K.data.machine.filter((m) => matches({ ...m, ownerName: "" }));
137
114
  return [
138
- h("p", { class: "skill-note" }, "这台机器上原有的技能(~/.claude/skills、~/.agents/skills 等)。只有管理员的会话能用,其他人的会话里看不到、也调不了。要分给别人用,请复制内容新建一个 agent-loft 技能再分享。"),
115
+ h("p", { class: "skill-note" }, "这台机器上原有的技能(~/.claude/skills、~/.agents/skills 等)。只有管理员的会话能用,其他人的会话里看不到、也调不了。要给别人用,请复制内容,在他的账号里新建一个。"),
139
116
  list.length ? h("div", { class: "task-list" }, list.map(machineCard)) : h("div", { class: "skill-empty" }, "没有找到"),
140
117
  ];
141
118
  }
142
- if (K.tab === "plaza") {
143
- const list = plaza().filter(matches);
144
- return list.length
145
- ? h("div", { class: "task-list" }, list.map(plazaCard))
146
- : h("div", { class: "skill-empty" }, K.query ? "没有找到" : "还没有人分享技能给你。自己建的技能可以设成「所有人」或者分享给指定的人。");
147
- }
148
119
  const list = mine().filter(matches);
149
120
  if (list.length) return h("div", { class: "task-list" }, list.map(mineCard));
150
121
  if (K.query) return h("div", { class: "skill-empty" }, "没有找到");
@@ -152,11 +123,7 @@ function renderList() {
152
123
  "div",
153
124
  { class: "tasks-empty" },
154
125
  h("div", { class: "t" }, "还没有技能"),
155
- h(
156
- "div",
157
- { class: "s" },
158
- "点「新建」写一个,「上传」一个技能包(.zip / .skill / SKILL.md / 文件夹),去「技能广场」装别人分享的,或者在对话里让 agent「把刚才的做法存成技能」。",
159
- ),
126
+ h("div", { class: "s" }, "点「新建」写一个,「上传」一个技能包(.zip / .skill / SKILL.md / 文件夹),或者在对话里让 agent「把刚才的做法存成技能」。"),
160
127
  );
161
128
  }
162
129
 
@@ -203,7 +170,7 @@ function mineCard(s) {
203
170
  "sep",
204
171
  s.mine
205
172
  ? { label: "删除", danger: true, onclick: () => removeSkill(s) }
206
- : { label: "卸载", danger: true, onclick: () => install(s, false) },
173
+ : { label: "卸载", danger: true, onclick: () => uninstall(s) },
207
174
  ]);
208
175
  return h(
209
176
  "div",
@@ -219,7 +186,7 @@ function mineCard(s) {
219
186
  "div",
220
187
  { class: "task-meta" },
221
188
  h("span", {}, s.mine ? "我建的" : `${s.ownerName} 建的`),
222
- s.mine ? h("span", {}, `${VISIBILITY[s.visibility]}可见${s.visibility === "users" ? `(${s.sharedWith.length} 人)` : ""}`) : null,
189
+ s.mine && SHARED[s.visibility] ? h("span", {}, SHARED[s.visibility]) : null,
223
190
  s.mine && s.installs ? h("span", {}, `${s.installs} 人安装`) : null,
224
191
  s.source === "agent" ? h("span", {}, "agent 生成") : null,
225
192
  varsState(s),
@@ -231,28 +198,6 @@ function mineCard(s) {
231
198
  );
232
199
  }
233
200
 
234
- function plazaCard(s) {
235
- const btn = s.installed
236
- ? h("button", { class: "btn", onclick: () => install(s, false) }, "已安装")
237
- : h("button", { class: "btn primary", onclick: () => install(s, true) }, "安装");
238
- const more = s.canDelete ? h("button", { class: "icon-btn", title: "更多" }, icon("more")) : null;
239
- if (more) more.onclick = () => showMenu(more, [{ label: "下架(删除)", danger: true, onclick: () => removeSkill(s) }]);
240
- return h(
241
- "div",
242
- { class: "task-card skill-card" },
243
- h("div", { class: "task-top" }, h("button", { class: "task-title skill-name", title: "查看", onclick: () => openDetail(s) }, s.name), btn, more),
244
- h(
245
- "div",
246
- { class: "task-meta" },
247
- h("span", {}, `${s.ownerName} 建的`),
248
- h("span", {}, s.visibility === "all" ? "公开" : "分享给你"),
249
- h("span", {}, `更新于 ${new Date(s.updatedAt).toLocaleDateString()}`),
250
- s.vars.length ? h("span", {}, `要填 ${s.vars.length} 个变量`) : null,
251
- ),
252
- h("div", { class: "task-prompt" }, s.description),
253
- );
254
- }
255
-
256
201
  function machineCard(m) {
257
202
  return h(
258
203
  "div",
@@ -265,12 +210,19 @@ function machineCard(m) {
265
210
 
266
211
  // ---------- 操作 ----------
267
212
 
268
- async function install(s, on) {
213
+ /** 卸载以前从别人那装的技能。不再有广场,卸了就装不回来了,先问一句 */
214
+ async function uninstall(s) {
215
+ const sure = await confirmDialog({
216
+ title: `卸载「${s.name}」?`,
217
+ message: `这是 ${s.ownerName} 建的技能。卸载后就装不回来了,要用的话得请他把内容发给你,自己新建一个。`,
218
+ confirmText: "卸载",
219
+ danger: true,
220
+ });
221
+ if (!sure) return;
269
222
  try {
270
- await api(`/api/skills/${s.id}/install`, { method: "POST", body: { on } });
271
- toast(on ? `已安装 ${s.name},新消息起可用` : `已卸载 ${s.name}`);
272
- await reload();
273
- if (on && s.vars.some((v) => v.required)) openVars(K.data.skills.find((x) => x.id === s.id) ?? s);
223
+ await api(`/api/skills/${s.id}/install`, { method: "POST", body: { on: false } });
224
+ toast(`已卸载 ${s.name}`);
225
+ void reload();
274
226
  } catch (err) {
275
227
  toast(err.message);
276
228
  }
@@ -278,11 +230,9 @@ async function install(s, on) {
278
230
 
279
231
  async function removeSkill(s) {
280
232
  const sure = await confirmDialog({
281
- title: s.mine ? `删除技能「${s.name}」?` : `下架「${s.name}」?`,
282
- message: s.mine
283
- ? `删除后找不回来${s.installs ? `,装了它的 ${s.installs} 个人也用不了了` : ""}。`
284
- : `这是 ${s.ownerName} 建的技能,下架会直接删除它,装了的人都用不了了。`,
285
- confirmText: s.mine ? "删除" : "下架",
233
+ title: `删除技能「${s.name}」?`,
234
+ message: `删除后找不回来${s.installs ? `,装了它的 ${s.installs} 个人也用不了了` : ""}。`,
235
+ confirmText: "删除",
286
236
  danger: true,
287
237
  });
288
238
  if (!sure) return;
@@ -314,7 +264,7 @@ async function openDetail(s) {
314
264
  "div",
315
265
  { class: "task-meta" },
316
266
  h("span", {}, d.mine ? "我建的" : `${d.ownerName} 建的`),
317
- h("span", {}, `${VISIBILITY[d.visibility]}可见`),
267
+ d.mine && SHARED[d.visibility] ? h("span", {}, SHARED[d.visibility]) : null,
318
268
  h("span", {}, `更新于 ${new Date(d.updatedAt).toLocaleString()}`),
319
269
  ),
320
270
  h("p", { class: "skill-desc" }, d.description),
@@ -464,38 +414,6 @@ function openVars(s) {
464
414
 
465
415
  // ---------- 新建 / 编辑 ----------
466
416
 
467
- /** 谁能看到:仅自己 / 指定的人 / 所有人 */
468
- function visibilityPicker(cur) {
469
- const state = { visibility: cur?.visibility ?? "private", sharedWith: new Set(cur?.sharedWith ?? []) };
470
- const users = K.data.users;
471
- const select = h(
472
- "select",
473
- {},
474
- Object.entries(VISIBILITY).map(([v, label]) =>
475
- h("option", { value: v, selected: v === state.visibility, disabled: v === "users" && users.length === 0 }, v === "all" ? "所有人(出现在技能广场)" : label),
476
- ),
477
- );
478
- const people = h(
479
- "div",
480
- { class: "skill-people" },
481
- users.map((u) => {
482
- const box = h("input", { type: "checkbox", checked: state.sharedWith.has(u.id) });
483
- box.onchange = () => (box.checked ? state.sharedWith.add(u.id) : state.sharedWith.delete(u.id));
484
- return h("label", { class: "skill-person" }, box, u.name);
485
- }),
486
- );
487
- const sync = () => {
488
- state.visibility = select.value;
489
- people.hidden = select.value !== "users";
490
- };
491
- select.onchange = sync;
492
- sync();
493
- return {
494
- el: h("div", { class: "skill-visibility" }, select, people),
495
- value: () => ({ visibility: state.visibility, sharedWith: [...state.sharedWith] }),
496
- };
497
- }
498
-
499
417
  /** 变量声明的编辑器:一行一个 */
500
418
  function varsEditor(list) {
501
419
  const rows = [];
@@ -580,7 +498,6 @@ async function openEditor(s) {
580
498
  const body = h("textarea", { rows: 14, class: "skill-body-input", spellcheck: "false", placeholder: "写给 agent 的操作说明(Markdown)" });
581
499
  body.value = d?.body ?? (s ? "" : BODY_TEMPLATE);
582
500
  const vars = varsEditor(d?.vars);
583
- const vis = visibilityPicker(d);
584
501
  const save = h("button", { class: "btn primary" }, d ? "保存" : "创建");
585
502
  const close = modal({
586
503
  title: d ? `编辑 ${d.name}` : "新建技能",
@@ -597,21 +514,19 @@ async function openEditor(s) {
597
514
  vars.el,
598
515
  "要调的接口地址、密钥之类,每个人填自己的值。普通变量注入到他自己的会话环境里,正文里用 $变量名 引用;密钥再填上「只允许发往」的地址就受保护:agent 看不到值,正文里写成让 agent 用 skill_request 工具发请求、在地址或请求头里写 {{变量名}}。",
599
516
  ),
600
- field("谁能看到", vis.el, "看得到的人可以在技能广场安装。你以后改了,装了的人也跟着用新的。"),
601
517
  ),
602
518
  foot: h("div", { class: "modal-foot" }, save),
603
519
  });
604
520
  (d ? desc : name).focus();
605
521
  save.onclick = async () => {
606
- const payload = { description: desc.value, body: body.value, vars: vars.value(), ...vis.value() };
522
+ const payload = { description: desc.value, body: body.value, vars: vars.value() };
607
523
  save.disabled = true;
608
524
  try {
609
525
  if (d) await api(`/api/skills/${d.id}`, { method: "PATCH", body: payload });
610
526
  else await api("/api/skills", { method: "POST", body: { name: name.value.trim().toLowerCase(), ...payload } });
611
527
  close();
612
528
  toast(d ? "已保存" : "已创建");
613
- if (S.view === "skills") void reload();
614
- else void openSkills();
529
+ void reload();
615
530
  } catch (err) {
616
531
  save.disabled = false;
617
532
  toast(err.message);
@@ -634,7 +549,7 @@ function toBase64(buf) {
634
549
 
635
550
  /**
636
551
  * 上传技能:压缩包(.zip / .skill)、单个 SKILL.md,或者整个文件夹。target 给了就是替换那个技能的内容。
637
- * 新建的先设成「仅自己」,传完打开编辑框让人确认说明、变量和分享范围。
552
+ * 传完打开编辑框让人确认说明和变量。
638
553
  */
639
554
  function pickUpload(target) {
640
555
  const choose = (folder) => {
@@ -653,7 +568,7 @@ function pickUpload(target) {
653
568
  "div",
654
569
  { class: "modal-body" },
655
570
  h("p", { class: "skill-note" }, "技能包里要有 SKILL.md(开头的 frontmatter 写 name 和 description),可以带脚本、模板等文件。要声明变量的话,放一个 agent-loft.json:{ \"vars\": [{ \"name\": \"MY_API_KEY\", \"secret\": true, \"required\": true }] }。"),
656
- target ? h("p", { class: "skill-note" }, "替换后名字不变,包里写的 name 会被忽略;装了它的人会跟着用新的。") : null,
571
+ target ? h("p", { class: "skill-note" }, "替换后名字不变,包里写的 name 会被忽略。") : null,
657
572
  h(
658
573
  "div",
659
574
  { class: "skill-upload-btns" },
@@ -692,9 +607,8 @@ async function upload(files, target) {
692
607
  return;
693
608
  }
694
609
  const s = await api("/api/skills", { method: "POST", body: { upload: payload, visibility: "private" } });
695
- toast(`已导入 ${s.name},现在只有你自己看得到`);
696
- if (S.view === "skills") await reload();
697
- else await openSkills();
610
+ toast(`已导入 ${s.name}`);
611
+ void reload();
698
612
  await openEditor(s);
699
613
  } catch (err) {
700
614
  toast(err.message);