agent-loft 0.2.31 → 0.2.33

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.
Files changed (48) hide show
  1. package/README.md +14 -5
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +90 -8
  4. package/src/agents/claude/session.ts +81 -12
  5. package/src/agents/codex/session.ts +70 -13
  6. package/src/channels/feishu/bot.ts +2 -2
  7. package/src/channels/web/api.ts +72 -2
  8. package/src/channels/web/conv-pages.ts +68 -0
  9. package/src/channels/web/http.ts +2 -2
  10. package/src/channels/web/server.ts +20 -3
  11. package/src/channels/web/skills-api.ts +225 -0
  12. package/src/core/chat-mode.ts +3 -3
  13. package/src/core/command-catalog.ts +8 -1
  14. package/src/core/commands.ts +3 -0
  15. package/src/core/hub.ts +168 -18
  16. package/src/core/personal.ts +3 -1
  17. package/src/core/skill-runtime.ts +359 -0
  18. package/src/core/skills-bridge.ts +288 -0
  19. package/src/core/skills.ts +676 -0
  20. package/src/core/tasks-bridge.ts +5 -1
  21. package/src/core/tasks-mcp.ts +11 -0
  22. package/src/core/types.ts +44 -3
  23. package/src/core/usage.ts +70 -0
  24. package/src/core/zip.ts +98 -0
  25. package/src/main.ts +4 -0
  26. package/src/mcp/tasks-server.ts +125 -4
  27. package/web/css/composer.css +0 -25
  28. package/web/css/messages.css +169 -0
  29. package/web/css/sidebar.css +5 -5
  30. package/web/css/skills.css +385 -0
  31. package/web/css/topbar.css +131 -0
  32. package/web/index.html +1 -0
  33. package/web/lib/dom.js +3 -1
  34. package/web/lib/markdown.js +84 -14
  35. package/web/lib/ws.js +49 -5
  36. package/web/settings/index.js +7 -3
  37. package/web/settings/skills.js +617 -0
  38. package/web/state.js +4 -3
  39. package/web/views/composer.js +16 -3
  40. package/web/views/interaction.js +12 -4
  41. package/web/views/layout.js +22 -15
  42. package/web/views/mention.js +193 -0
  43. package/web/views/messages.js +239 -81
  44. package/web/views/msg-nav.js +120 -0
  45. package/web/views/quota.js +1 -0
  46. package/web/views/sidebar.js +114 -16
  47. package/web/views/tasks.js +11 -2
  48. package/web/views/topbar.js +168 -40
@@ -0,0 +1,617 @@
1
+ import { fill, h, icon, starsLoader, toast } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { renderMarkdown } from "../lib/markdown.js";
4
+ import { confirmDialog, modal, showMenu } from "../lib/overlay.js";
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);
20
+ await reload();
21
+ }
22
+
23
+ /** 这一页还开着(没关设置、没切到别的标签页) */
24
+ const showing = () => !!K.box?.isConnected;
25
+
26
+ async function reload() {
27
+ const my = ++K.token;
28
+ try {
29
+ const data = await api("/api/skills");
30
+ if (my !== K.token || !showing()) return;
31
+ K.data = data;
32
+ render();
33
+ } catch (err) {
34
+ if (my === K.token && showing()) fill(K.box, h("div", { class: "error-text" }, err.message));
35
+ }
36
+ }
37
+
38
+ document.addEventListener("aim:skills", () => {
39
+ if (!showing()) return;
40
+ clearTimeout(K.reloadTimer);
41
+ K.reloadTimer = setTimeout(() => void reload(), 300);
42
+ });
43
+
44
+ function mine() {
45
+ return K.data.skills.filter((s) => s.installed).sort((a, b) => a.name.localeCompare(b.name));
46
+ }
47
+
48
+ function matches(s) {
49
+ const q = K.query.trim().toLowerCase();
50
+ return !q || s.name.includes(q) || s.description.toLowerCase().includes(q) || (s.ownerName ?? "").toLowerCase().includes(q);
51
+ }
52
+
53
+ function render() {
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 });
61
+ search.oninput = () => {
62
+ K.query = search.value;
63
+ fill(listBox, renderList());
64
+ };
65
+ const listBox = h("div", { class: "skill-list-box" }, renderList());
66
+ fill(
67
+ K.box,
68
+ h(
69
+ "div",
70
+ { class: "skills-head" },
71
+ h(
72
+ "p",
73
+ { class: "skill-note" },
74
+ "技能是一份写给 agent 的做事方法(SKILL.md),可以带脚本和资料,Claude 和 Codex 都能用。工作模式下 agent 遇到对得上的事会自己用;在输入框或定时任务里输入 @ 可以点名用哪个。",
75
+ ),
76
+ h(
77
+ "div",
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
+ },
99
+ },
100
+ label,
101
+ ),
102
+ ),
103
+ )
104
+ : null,
105
+ search,
106
+ ),
107
+ listBox,
108
+ );
109
+ }
110
+
111
+ function renderList() {
112
+ if (K.tab === "machine") {
113
+ const list = K.data.machine.filter((m) => matches({ ...m, ownerName: "" }));
114
+ return [
115
+ h("p", { class: "skill-note" }, "这台机器上原有的技能(~/.claude/skills、~/.agents/skills 等)。只有管理员的会话能用,其他人的会话里看不到、也调不了。要给别人用,请复制内容,在他的账号里新建一个。"),
116
+ list.length ? h("div", { class: "task-list" }, list.map(machineCard)) : h("div", { class: "skill-empty" }, "没有找到"),
117
+ ];
118
+ }
119
+ const list = mine().filter(matches);
120
+ if (list.length) return h("div", { class: "task-list" }, list.map(mineCard));
121
+ if (K.query) return h("div", { class: "skill-empty" }, "没有找到");
122
+ return h(
123
+ "div",
124
+ { class: "tasks-empty" },
125
+ h("div", { class: "t" }, "还没有技能"),
126
+ h("div", { class: "s" }, "点「新建」写一个,「上传」一个技能包(.zip / .skill / SKILL.md / 文件夹),或者在对话里让 agent「把刚才的做法存成技能」。"),
127
+ );
128
+ }
129
+
130
+ // ---------- 卡片 ----------
131
+
132
+ /** 变量:几个、缺几个必填的 */
133
+ function varsState(s) {
134
+ if (!s.vars.length) return null;
135
+ const missing = s.vars.filter((v) => v.required && !K.data.vars[v.name]?.set);
136
+ const pending = s.vars.filter((v) => K.data.vars[v.name]?.pending);
137
+ if (missing.length) return h("span", { class: "skill-warn" }, `${missing.length} 个变量没填`);
138
+ if (pending.length) return h("span", { class: "skill-warn" }, "密钥的发送地址待确认");
139
+ return h("span", {}, `${s.vars.length} 个变量`);
140
+ }
141
+
142
+ /** 要去「填写变量」处理的:必填的没填,或者受保护密钥的地址待确认 */
143
+ function varsNeedAttention(s) {
144
+ return s.vars.some((v) => (v.required && !K.data.vars[v.name]?.set) || K.data.vars[v.name]?.pending);
145
+ }
146
+
147
+ function mineCard(s) {
148
+ const toggle = h("input", { type: "checkbox", checked: s.enabled });
149
+ toggle.onchange = async () => {
150
+ try {
151
+ await api(`/api/skills/${s.id}/enable`, { method: "POST", body: { on: toggle.checked } });
152
+ toast(toggle.checked ? "已启用" : "已停用");
153
+ void reload();
154
+ } catch (err) {
155
+ toggle.checked = !toggle.checked;
156
+ toast(err.message);
157
+ }
158
+ };
159
+ const more = h("button", { class: "icon-btn", title: "更多" }, icon("more"));
160
+ more.onclick = () =>
161
+ showMenu(more, [
162
+ { label: "查看", onclick: () => openDetail(s) },
163
+ ...(s.vars.length ? [{ label: "填写变量…", onclick: () => openVars(s) }] : []),
164
+ ...(s.mine
165
+ ? [
166
+ { label: "编辑…", onclick: () => openEditor(s) },
167
+ { label: "用文件替换…", onclick: () => pickUpload(s) },
168
+ ]
169
+ : []),
170
+ "sep",
171
+ s.mine
172
+ ? { label: "删除", danger: true, onclick: () => removeSkill(s) }
173
+ : { label: "卸载", danger: true, onclick: () => uninstall(s) },
174
+ ]);
175
+ return h(
176
+ "div",
177
+ { class: `task-card skill-card${s.enabled ? "" : " off"}` },
178
+ h(
179
+ "div",
180
+ { class: "task-top" },
181
+ h("button", { class: "task-title skill-name", title: "查看", onclick: () => openDetail(s) }, s.name),
182
+ h("label", { class: "switch", title: s.enabled ? "已启用,点击停用" : "已停用,点击启用" }, toggle, h("span", { class: "slider" })),
183
+ more,
184
+ ),
185
+ h(
186
+ "div",
187
+ { class: "task-meta" },
188
+ h("span", {}, s.mine ? "我建的" : `${s.ownerName} 建的`),
189
+ s.mine && SHARED[s.visibility] ? h("span", {}, SHARED[s.visibility]) : null,
190
+ s.mine && s.installs ? h("span", {}, `${s.installs} 人安装`) : null,
191
+ s.source === "agent" ? h("span", {}, "agent 生成") : null,
192
+ varsState(s),
193
+ ),
194
+ h("div", { class: "task-prompt" }, s.description),
195
+ varsNeedAttention(s)
196
+ ? h("button", { class: "task-run", onclick: () => openVars(s) }, s.vars.some((v) => v.required && !K.data.vars[v.name]?.set) ? "填写变量后才能正常使用 →" : "确认密钥的发送地址 →")
197
+ : null,
198
+ );
199
+ }
200
+
201
+ function machineCard(m) {
202
+ return h(
203
+ "div",
204
+ { class: "task-card skill-card" },
205
+ h("div", { class: "task-top" }, h("button", { class: "task-title skill-name", onclick: () => openMachine(m) }, m.name), h("span", { class: "badge" }, m.agents.map((a) => (a === "claude" ? "Claude" : "Codex")).join(" · "))),
206
+ h("div", { class: "task-meta" }, h("span", { title: m.dir }, m.dir)),
207
+ h("div", { class: "task-prompt" }, m.description || "(没有说明)"),
208
+ );
209
+ }
210
+
211
+ // ---------- 操作 ----------
212
+
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;
222
+ try {
223
+ await api(`/api/skills/${s.id}/install`, { method: "POST", body: { on: false } });
224
+ toast(`已卸载 ${s.name}`);
225
+ void reload();
226
+ } catch (err) {
227
+ toast(err.message);
228
+ }
229
+ }
230
+
231
+ async function removeSkill(s) {
232
+ const sure = await confirmDialog({
233
+ title: `删除技能「${s.name}」?`,
234
+ message: `删除后找不回来${s.installs ? `,装了它的 ${s.installs} 个人也用不了了` : ""}。`,
235
+ confirmText: "删除",
236
+ danger: true,
237
+ });
238
+ if (!sure) return;
239
+ try {
240
+ await api(`/api/skills/${s.id}`, { method: "DELETE" });
241
+ toast("已删除");
242
+ void reload();
243
+ } catch (err) {
244
+ toast(err.message);
245
+ }
246
+ }
247
+
248
+ // ---------- 查看 ----------
249
+
250
+ async function openDetail(s) {
251
+ const body = h("div", { class: "modal-body skill-detail" }, starsLoader());
252
+ modal({ title: s.name, body, wide: true });
253
+ let d;
254
+ try {
255
+ d = await api(`/api/skills/${s.id}`);
256
+ } catch (err) {
257
+ fill(body, h("div", { class: "error-text" }, err.message));
258
+ return;
259
+ }
260
+ const filePreview = h("div", { class: "skill-file-preview" });
261
+ fill(
262
+ body,
263
+ h(
264
+ "div",
265
+ { class: "task-meta" },
266
+ h("span", {}, d.mine ? "我建的" : `${d.ownerName} 建的`),
267
+ d.mine && SHARED[d.visibility] ? h("span", {}, SHARED[d.visibility]) : null,
268
+ h("span", {}, `更新于 ${new Date(d.updatedAt).toLocaleString()}`),
269
+ ),
270
+ h("p", { class: "skill-desc" }, d.description),
271
+ d.vars.length
272
+ ? h(
273
+ "div",
274
+ { class: "skill-section" },
275
+ h("h4", {}, "变量"),
276
+ h(
277
+ "ul",
278
+ { class: "skill-vars" },
279
+ d.vars.map((v) =>
280
+ h(
281
+ "li",
282
+ {},
283
+ h("code", {}, v.name),
284
+ v.required ? h("span", { class: "badge" }, "必填") : null,
285
+ v.secret ? h("span", { class: "badge" }, v.hosts?.length ? `受保护:只发往 ${v.hosts.join("、")}` : "密钥") : null,
286
+ K.data.vars[v.name]?.set ? h("span", { class: "badge ok" }, "已填") : null,
287
+ v.description ? h("span", { class: "skill-var-desc" }, v.description) : null,
288
+ ),
289
+ ),
290
+ ),
291
+ d.installed ? h("button", { class: "btn", onclick: () => openVars(d) }, "填写变量…") : null,
292
+ )
293
+ : null,
294
+ h("div", { class: "skill-section" }, h("h4", {}, "SKILL.md"), h("div", { class: "skill-md" }, renderMarkdown(d.body))),
295
+ d.files.length
296
+ ? h(
297
+ "div",
298
+ { class: "skill-section" },
299
+ h("h4", {}, `附带文件(${d.files.length})`),
300
+ h(
301
+ "div",
302
+ { class: "skill-files" },
303
+ d.files.map((f) =>
304
+ h(
305
+ "button",
306
+ {
307
+ class: "skill-file",
308
+ onclick: async () => {
309
+ try {
310
+ const r = await api(`/api/skills/${s.id}/file?path=${encodeURIComponent(f.path)}`);
311
+ fill(filePreview, h("div", { class: "skill-file-name" }, f.path), r.text === null ? h("div", { class: "skill-note" }, "二进制文件,不预览") : h("pre", {}, r.text));
312
+ } catch (err) {
313
+ toast(err.message);
314
+ }
315
+ },
316
+ },
317
+ h("code", {}, f.path),
318
+ h("span", {}, sizeText(f.size)),
319
+ ),
320
+ ),
321
+ ),
322
+ filePreview,
323
+ )
324
+ : null,
325
+ );
326
+ }
327
+
328
+ async function openMachine(m) {
329
+ const body = h("div", { class: "modal-body skill-detail" }, starsLoader());
330
+ modal({ title: m.name, body, wide: true });
331
+ try {
332
+ const d = await api(`/api/skills/machine?name=${encodeURIComponent(m.name)}`);
333
+ fill(body, h("div", { class: "task-meta" }, h("span", {}, d.dir)), h("pre", { class: "skill-raw" }, d.skillMd));
334
+ } catch (err) {
335
+ fill(body, h("div", { class: "error-text" }, err.message));
336
+ }
337
+ }
338
+
339
+ function sizeText(n) {
340
+ return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
341
+ }
342
+
343
+ // ---------- 变量 ----------
344
+
345
+ /** 填这个技能要用的变量。值按名字存,几个技能用同一个名字就共用一个值 */
346
+ function openVars(s) {
347
+ const rows = s.vars.map((v) => {
348
+ const cur = K.data.vars[v.name];
349
+ const input = h("input", {
350
+ type: v.secret ? "password" : "text",
351
+ autocomplete: "off",
352
+ spellcheck: "false",
353
+ placeholder: cur?.set ? (v.secret ? "已填写(不显示),留空不改" : "") : v.required ? "必填" : "可不填",
354
+ value: cur?.value ?? "",
355
+ });
356
+ const clear = cur?.set ? h("button", { type: "button", class: "btn ghost", onclick: () => ((input.value = ""), (input.dataset.clear = "1"), (clear.hidden = true)) }, "清除") : null;
357
+ // 受保护的密钥:agent 看不到值,只能经 agent-loft 发往这些地址;作者改了地址要这里再确认
358
+ const guard = v.secret && v.hosts?.length
359
+ ? h("small", { class: "skill-guard" }, `受保护:agent 看不到这个值,只能通过 agent-loft 代发请求,发往 ${(cur?.hosts?.length ? cur.hosts : v.hosts).join("、")}。填写即表示同意发往这些地址。`)
360
+ : null;
361
+ const r = { v, input, approve: false };
362
+ const pending = cur?.pending?.length
363
+ ? h(
364
+ "div",
365
+ { class: "skill-pending" },
366
+ `作者新加了发送地址:${cur.pending.join("、")}。确认之前,这个密钥不会发往新地址。`,
367
+ h("button", { type: "button", class: "btn small", onclick: (e) => ((r.approve = true), (e.currentTarget.textContent = "保存后生效"), (e.currentTarget.disabled = true)) }, "确认"),
368
+ )
369
+ : null;
370
+ r.el = h("div", { class: "field" }, h("span", {}, h("code", {}, v.name), v.required ? " *" : ""), h("div", { class: "skill-var-row" }, input, clear), v.description ? h("small", {}, v.description) : null, guard, pending);
371
+ return r;
372
+ });
373
+ const save = h("button", { class: "btn primary" }, "保存");
374
+ const close = modal({
375
+ title: `${s.name} 的变量`,
376
+ body: h(
377
+ "div",
378
+ { class: "modal-body task-form" },
379
+ h(
380
+ "p",
381
+ { class: "skill-note" },
382
+ "只给你自己的会话用。普通变量在工作模式下作为环境变量注入,agent 能读到值;标了「受保护」的密钥 agent 看不到,只能让 agent-loft 代发到指定地址。几个技能用同一个变量名时共用一个值。",
383
+ ),
384
+ rows.map((r) => r.el),
385
+ ),
386
+ foot: h("div", { class: "modal-foot" }, save),
387
+ });
388
+ rows[0]?.input.focus();
389
+ save.onclick = async () => {
390
+ const values = {};
391
+ for (const r of rows) {
392
+ const val = r.input.value;
393
+ if (r.input.dataset.clear === "1" && !val) values[r.v.name] = null;
394
+ else if (val || !r.v.secret) {
395
+ // 密钥留空 = 不改;普通变量留空 = 删掉
396
+ if (val !== (K.data.vars[r.v.name]?.value ?? "")) values[r.v.name] = val || null;
397
+ }
398
+ }
399
+ const approve = rows.filter((r) => r.approve).map((r) => r.v.name);
400
+ // 什么都没改:别去存(存一次会让会话重新加载)
401
+ if (!Object.keys(values).length && !approve.length) return close();
402
+ save.disabled = true;
403
+ try {
404
+ await api("/api/skills/vars", { method: "PUT", body: { values, approve } });
405
+ close();
406
+ toast("已保存,新消息起生效");
407
+ void reload();
408
+ } catch (err) {
409
+ save.disabled = false;
410
+ toast(err.message);
411
+ }
412
+ };
413
+ }
414
+
415
+ // ---------- 新建 / 编辑 ----------
416
+
417
+ /** 变量声明的编辑器:一行一个 */
418
+ function varsEditor(list) {
419
+ const rows = [];
420
+ const box = h("div", { class: "skill-var-edit" });
421
+ const add = (v = {}) => {
422
+ const name = h("input", { value: v.name ?? "", placeholder: "变量名,如 WEATHER_API_KEY", spellcheck: "false" });
423
+ name.oninput = () => (name.value = name.value.toUpperCase().replace(/[^A-Z0-9_]/g, "_"));
424
+ const desc = h("input", { value: v.description ?? "", placeholder: "说明:是什么、去哪拿" });
425
+ const secret = h("input", { type: "checkbox", checked: !!v.secret });
426
+ const required = h("input", { type: "checkbox", checked: !!v.required });
427
+ // 密钥才有:填了允许的地址就是受保护的密钥
428
+ const hosts = h("input", {
429
+ value: (v.hosts ?? []).join(", "),
430
+ placeholder: "可不填。填了就受保护,比如 api.example.com, 192.168.8.2:8080",
431
+ spellcheck: "false",
432
+ title: "填了就受保护:不进环境变量,agent 看不到值,只能让 agent-loft 代发到这些地址",
433
+ });
434
+ const hostsLine = h("label", { class: "skill-var-hosts" }, h("span", {}, "只允许发往"), hosts);
435
+ const syncHosts = () => (hostsLine.hidden = !secret.checked);
436
+ secret.onchange = syncHosts;
437
+ syncHosts();
438
+ const row = h(
439
+ "div",
440
+ { class: "skill-var-item" },
441
+ h(
442
+ "div",
443
+ { class: "skill-var-line" },
444
+ name,
445
+ desc,
446
+ h("label", {}, secret, "密钥"),
447
+ h("label", {}, required, "必填"),
448
+ h("button", { type: "button", class: "icon-btn", title: "删掉", onclick: () => (row.remove(), rows.splice(rows.indexOf(r), 1)) }, icon("x")),
449
+ ),
450
+ hostsLine,
451
+ );
452
+ const r = { name, desc, secret, required, hosts };
453
+ rows.push(r);
454
+ box.append(row);
455
+ };
456
+ for (const v of list ?? []) add(v);
457
+ return {
458
+ el: h("div", {}, box, h("button", { type: "button", class: "btn ghost", onclick: () => add() }, icon("plus"), "加一个变量")),
459
+ value: () =>
460
+ rows
461
+ .filter((r) => r.name.value.trim())
462
+ .map((r) => ({
463
+ name: r.name.value.trim(),
464
+ description: r.desc.value.trim(),
465
+ secret: r.secret.checked,
466
+ required: r.required.checked,
467
+ ...(r.secret.checked && r.hosts.value.trim() ? { hosts: r.hosts.value.split(/[\s,,、]+/).filter(Boolean) } : {}),
468
+ })),
469
+ };
470
+ }
471
+
472
+ const BODY_TEMPLATE = `## 什么时候用
473
+
474
+ (用户要做什么的时候用这个技能)
475
+
476
+ ## 怎么做
477
+
478
+ 1. 第一步……
479
+ 2. 调接口的例子:\`curl -H "Authorization: Bearer $MY_API_KEY" https://example.com/api\`
480
+
481
+ ## 注意
482
+
483
+ - 不要把密钥打印出来`;
484
+
485
+ /** s:编辑这个(自己的);不给就是新建。preset:上传后预填 */
486
+ async function openEditor(s) {
487
+ let d = null;
488
+ if (s) {
489
+ try {
490
+ d = await api(`/api/skills/${s.id}`);
491
+ } catch (err) {
492
+ return toast(err.message);
493
+ }
494
+ }
495
+ const name = h("input", { value: d?.name ?? "", placeholder: "小写字母、数字和 -,比如 weather-report", spellcheck: "false", disabled: !!d });
496
+ const desc = h("textarea", { rows: 2, maxlength: "1024", placeholder: "一两句话说清什么时候用它。agent 靠这句话判断要不要用这个技能。" });
497
+ desc.value = d?.description ?? "";
498
+ const body = h("textarea", { rows: 14, class: "skill-body-input", spellcheck: "false", placeholder: "写给 agent 的操作说明(Markdown)" });
499
+ body.value = d?.body ?? (s ? "" : BODY_TEMPLATE);
500
+ const vars = varsEditor(d?.vars);
501
+ const save = h("button", { class: "btn primary" }, d ? "保存" : "创建");
502
+ const close = modal({
503
+ title: d ? `编辑 ${d.name}` : "新建技能",
504
+ wide: true,
505
+ tall: true,
506
+ body: h(
507
+ "div",
508
+ { class: "modal-body task-form" },
509
+ field("名字", name, d ? "建好后不能改。输入框里用 @名字 点名使用。" : "全站唯一,建好后不能改。输入框里用 @名字 点名使用。"),
510
+ field("说明", desc),
511
+ field("正文(SKILL.md)", body, d?.files?.length ? `另有 ${d.files.length} 个附带文件,要改的话用「用文件替换」上传整个包。` : "可以引用同目录下的脚本、资料(上传技能包时一起带上)。"),
512
+ field(
513
+ "变量",
514
+ vars.el,
515
+ "要调的接口地址、密钥之类,每个人填自己的值。普通变量注入到他自己的会话环境里,正文里用 $变量名 引用;密钥再填上「只允许发往」的地址就受保护:agent 看不到值,正文里写成让 agent 用 skill_request 工具发请求、在地址或请求头里写 {{变量名}}。",
516
+ ),
517
+ ),
518
+ foot: h("div", { class: "modal-foot" }, save),
519
+ });
520
+ (d ? desc : name).focus();
521
+ save.onclick = async () => {
522
+ const payload = { description: desc.value, body: body.value, vars: vars.value() };
523
+ save.disabled = true;
524
+ try {
525
+ if (d) await api(`/api/skills/${d.id}`, { method: "PATCH", body: payload });
526
+ else await api("/api/skills", { method: "POST", body: { name: name.value.trim().toLowerCase(), ...payload } });
527
+ close();
528
+ toast(d ? "已保存" : "已创建");
529
+ void reload();
530
+ } catch (err) {
531
+ save.disabled = false;
532
+ toast(err.message);
533
+ }
534
+ };
535
+ }
536
+
537
+ function field(label, input, hint) {
538
+ return h("div", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
539
+ }
540
+
541
+ // ---------- 上传 ----------
542
+
543
+ function toBase64(buf) {
544
+ const bytes = new Uint8Array(buf);
545
+ let bin = "";
546
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
547
+ return btoa(bin);
548
+ }
549
+
550
+ /**
551
+ * 上传技能:压缩包(.zip / .skill)、单个 SKILL.md,或者整个文件夹。target 给了就是替换那个技能的内容。
552
+ * 传完打开编辑框让人确认说明和变量。
553
+ */
554
+ function pickUpload(target) {
555
+ const choose = (folder) => {
556
+ const input = h("input", { type: "file", style: { display: "none" }, ...(folder ? { webkitdirectory: true, multiple: true } : { accept: ".zip,.skill,.md" }) });
557
+ input.onchange = () => {
558
+ const files = [...input.files];
559
+ input.remove();
560
+ if (files.length) void upload(files, target);
561
+ };
562
+ document.body.append(input);
563
+ input.click();
564
+ };
565
+ const close = modal({
566
+ title: target ? `用文件替换 ${target.name}` : "上传技能",
567
+ body: h(
568
+ "div",
569
+ { class: "modal-body" },
570
+ h("p", { class: "skill-note" }, "技能包里要有 SKILL.md(开头的 frontmatter 写 name 和 description),可以带脚本、模板等文件。要声明变量的话,放一个 agent-loft.json:{ \"vars\": [{ \"name\": \"MY_API_KEY\", \"secret\": true, \"required\": true }] }。"),
571
+ target ? h("p", { class: "skill-note" }, "替换后名字不变,包里写的 name 会被忽略。") : null,
572
+ h(
573
+ "div",
574
+ { class: "skill-upload-btns" },
575
+ h("button", { class: "btn primary", onclick: () => (close(), choose(false)) }, "选压缩包或 SKILL.md"),
576
+ h("button", { class: "btn", onclick: () => (close(), choose(true)) }, "选文件夹"),
577
+ ),
578
+ ),
579
+ });
580
+ }
581
+
582
+ async function upload(files, target) {
583
+ let payload;
584
+ try {
585
+ const total = files.reduce((n, f) => n + f.size, 0);
586
+ if (total > 10 * 1024 * 1024) throw new Error("太大了(最多 10MB)");
587
+ const one = files.length === 1 ? files[0] : null;
588
+ if (one && /\.(zip|skill)$/i.test(one.name)) payload = { zip: toBase64(await one.arrayBuffer()) };
589
+ else {
590
+ const list = [];
591
+ for (const f of files) {
592
+ // 选文件夹时带相对路径;单个 SKILL.md 就叫 SKILL.md
593
+ const rel = f.webkitRelativePath || (one ? "SKILL.md" : f.name);
594
+ list.push({ path: rel, data: toBase64(await f.arrayBuffer()) });
595
+ }
596
+ payload = { files: list };
597
+ }
598
+ } catch (err) {
599
+ return toast(err.message);
600
+ }
601
+ toast("上传中…", 1500);
602
+ try {
603
+ if (target) {
604
+ await api(`/api/skills/${target.id}`, { method: "PATCH", body: { upload: payload } });
605
+ toast(`已替换 ${target.name}`);
606
+ void reload();
607
+ return;
608
+ }
609
+ const s = await api("/api/skills", { method: "POST", body: { upload: payload, visibility: "private" } });
610
+ toast(`已导入 ${s.name}`);
611
+ void reload();
612
+ await openEditor(s);
613
+ } catch (err) {
614
+ toast(err.message);
615
+ }
616
+ }
617
+
package/web/state.js CHANGED
@@ -39,8 +39,9 @@ export const desktopQuota = typeof desktop?.onQuota === "function";
39
39
  // 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 css/topbar.css 的 .in-desktop)
40
40
  if (desktop) document.documentElement.classList.add("in-desktop");
41
41
  /**
42
- * 新版桌面端顶栏的「更多」里能打开每台服务器的设置:侧栏底部的设置按钮就不显示了(额度照常显示,数据由桌面端推)。
43
- * 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
42
+ * 新版桌面端顶栏的「更多」里能打开每台服务器的设置:页面「更多」里的设置就不放了。
43
+ * 老版本桌面端没有这个入口,照旧放,免得设置打不开。
44
44
  */
45
45
  export const desktopSettings = typeof desktop?.onOpenSettings === "function";
46
- if (desktopSettings) document.documentElement.classList.add("desktop-settings");
46
+ /** 更新的桌面端:页面右上角的「更多」整个交给桌面端顶栏的「更多」(排在最上面),页面上不显示这个按钮 */
47
+ export const desktopMenu = typeof desktop?.setMenu === "function";
@@ -8,7 +8,9 @@ import { addItem, openConversation, openLightbox, renderEmpty, scrollToBottom }
8
8
  import { renderScheduled } from "./schedule.js";
9
9
  import { cancelVoice, SpeechRec, toggleVoice } from "./voice.js";
10
10
  import { closeSlashMenu, commandUsage, oneLine, slashKeydown, updateSlashMenu } from "./slash.js";
11
+ import { attachMention } from "./mention.js";
11
12
  import { doAction, openModelPicker, openSessions } from "./conv-actions.js";
13
+ import { openSettings } from "./layout.js";
12
14
 
13
15
  // ---------- 输入框 ----------
14
16
 
@@ -16,16 +18,21 @@ export function renderComposer() {
16
18
  cancelVoice();
17
19
  ui.input = h("textarea", {
18
20
  rows: 1,
19
- placeholder: "随心输入",
21
+ placeholder: "随心输入,@ 选技能",
20
22
  oninput: () => {
21
23
  // 识别过程中用户自己动了输入框,以用户为准,停掉识别
22
24
  cancelVoice();
23
25
  autosize();
24
26
  void updateSlashMenu();
27
+ void ui.mention.update();
25
28
  },
26
- onblur: () => setTimeout(closeSlashMenu, 150),
29
+ onblur: () =>
30
+ setTimeout(() => {
31
+ closeSlashMenu();
32
+ ui.mention.close();
33
+ }, 150),
27
34
  onkeydown: (e) => {
28
- if (slashKeydown(e)) {
35
+ if (slashKeydown(e) || ui.mention.keydown(e)) {
29
36
  e.preventDefault();
30
37
  return;
31
38
  }
@@ -62,6 +69,10 @@ export function renderComposer() {
62
69
  ui.chips = h("div", { class: "chips" });
63
70
  ui.schedBar = h("div", { class: "sched-bar", hidden: true });
64
71
  ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
72
+ ui.mentionMenu = h("div", { class: "slash-menu", hidden: true });
73
+ ui.mention = attachMention(ui.input, ui.mentionMenu, {
74
+ onEmpty: () => openSettings("技能"),
75
+ });
65
76
  ui.composer = h(
66
77
  "div",
67
78
  {
@@ -78,6 +89,7 @@ export function renderComposer() {
78
89
  },
79
90
  },
80
91
  ui.slashMenu,
92
+ ui.mentionMenu,
81
93
  ui.chips,
82
94
  ui.input,
83
95
  h(
@@ -253,6 +265,7 @@ export async function sendMessage() {
253
265
  return;
254
266
  }
255
267
  closeSlashMenu();
268
+ ui.mention.close();
256
269
  let key;
257
270
  let res;
258
271
  setSending(true);