agent-loft 0.2.28 → 0.2.30

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/channels/web/server.ts +1 -1
  3. package/web/app.js +8 -5555
  4. package/web/css/base.css +291 -0
  5. package/web/css/composer.css +506 -0
  6. package/web/css/loader.css +196 -0
  7. package/web/css/messages.css +734 -0
  8. package/web/css/mobile.css +100 -0
  9. package/web/css/overlay.css +437 -0
  10. package/web/css/schedule.css +93 -0
  11. package/web/css/settings.css +335 -0
  12. package/web/css/sidebar.css +593 -0
  13. package/web/css/topbar.css +205 -0
  14. package/web/index.html +11 -1
  15. package/web/lib/api.js +26 -0
  16. package/web/lib/dom.js +101 -0
  17. package/web/lib/markdown.js +52 -0
  18. package/web/lib/overlay.js +144 -0
  19. package/web/lib/push.js +70 -0
  20. package/web/lib/time.js +60 -0
  21. package/web/lib/ws.js +232 -0
  22. package/web/settings/agents.js +210 -0
  23. package/web/settings/commands.js +411 -0
  24. package/web/settings/common.js +42 -0
  25. package/web/settings/feishu.js +181 -0
  26. package/web/settings/general.js +184 -0
  27. package/web/settings/index.js +63 -0
  28. package/web/settings/memory.js +366 -0
  29. package/web/settings/notify.js +80 -0
  30. package/web/settings/security.js +188 -0
  31. package/web/settings/users.js +189 -0
  32. package/web/state.js +44 -0
  33. package/web/views/auth.js +61 -0
  34. package/web/views/composer.js +324 -0
  35. package/web/views/conv-actions.js +404 -0
  36. package/web/views/interaction.js +140 -0
  37. package/web/views/layout.js +186 -0
  38. package/web/views/messages.js +510 -0
  39. package/web/views/quota.js +145 -0
  40. package/web/views/schedule.js +120 -0
  41. package/web/views/sidebar.js +455 -0
  42. package/web/views/slash.js +185 -0
  43. package/web/views/topbar.js +394 -0
  44. package/web/views/voice.js +189 -0
  45. package/web/style.css +0 -3500
@@ -0,0 +1,455 @@
1
+ import { fill, h, icon, ICON, toast } from "../lib/dom.js";
2
+ import { desktop, S, ui } from "../state.js";
3
+ import { baseName } from "./topbar.js";
4
+ import { newDraft, openConversation } from "./messages.js";
5
+
6
+ // ---------- 侧栏标题跑马灯:名字被省略时,hover 来回滚动显示全名 ----------
7
+
8
+ export const marquee = { el: null, raf: 0 };
9
+
10
+ export function startMarquee(el) {
11
+ stopMarquee();
12
+ const dist = el.scrollWidth - el.clientWidth;
13
+ if (dist <= 2 || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
14
+ marquee.el = el;
15
+ el.classList.add("marquee");
16
+ const speed = 45; // px/s
17
+ const run = dist / speed * 1000;
18
+ const pause = 700;
19
+ const cycle = (pause + run) * 2;
20
+ let t0 = null;
21
+ const step = (now) => {
22
+ if (marquee.el !== el) return;
23
+ t0 ??= now;
24
+ const t = (now - t0) % cycle;
25
+ // 停 → 滚到末尾 → 停 → 滚回开头
26
+ let x;
27
+ if (t < pause) x = 0;
28
+ else if (t < pause + run) x = ((t - pause) / run) * dist;
29
+ else if (t < pause * 2 + run) x = dist;
30
+ else x = dist - ((t - pause * 2 - run) / run) * dist;
31
+ el.scrollLeft = x;
32
+ marquee.raf = requestAnimationFrame(step);
33
+ };
34
+ marquee.raf = requestAnimationFrame(step);
35
+ }
36
+
37
+ export function stopMarquee() {
38
+ cancelAnimationFrame(marquee.raf);
39
+ if (marquee.el) {
40
+ marquee.el.scrollLeft = 0;
41
+ marquee.el.classList.remove("marquee");
42
+ }
43
+ marquee.el = null;
44
+ }
45
+
46
+ // ---------- 侧栏宽度(桌面端可拖动) ----------
47
+
48
+ const SIDE_MIN = 200;
49
+ const SIDE_MAX = 520;
50
+
51
+ export function setSideWidth(w) {
52
+ if (w == null) {
53
+ document.documentElement.style.removeProperty("--side-w");
54
+ localStorage.removeItem("aim.sideW");
55
+ return;
56
+ }
57
+ const px = Math.round(Math.max(SIDE_MIN, Math.min(SIDE_MAX, w, innerWidth - 360)));
58
+ document.documentElement.style.setProperty("--side-w", `${px}px`);
59
+ return px;
60
+ }
61
+
62
+ {
63
+ const saved = Number(localStorage.getItem("aim.sideW"));
64
+ if (saved) setSideWidth(saved);
65
+ }
66
+
67
+ export function startSideResize(e) {
68
+ if (e.button !== 0) return;
69
+ e.preventDefault();
70
+ const handle = e.currentTarget;
71
+ const left = ui.sidebar.getBoundingClientRect().left;
72
+ let last = null;
73
+ handle.setPointerCapture(e.pointerId);
74
+ document.body.classList.add("side-resizing");
75
+ const move = (ev) => (last = setSideWidth(ev.clientX - left));
76
+ const up = () => {
77
+ handle.removeEventListener("pointermove", move);
78
+ handle.removeEventListener("pointerup", up);
79
+ handle.removeEventListener("pointercancel", up);
80
+ document.body.classList.remove("side-resizing");
81
+ if (last) localStorage.setItem("aim.sideW", String(last));
82
+ };
83
+ handle.addEventListener("pointermove", move);
84
+ handle.addEventListener("pointerup", up);
85
+ handle.addEventListener("pointercancel", up);
86
+ }
87
+
88
+ export function openSide() {
89
+ ui.layout.classList.add("side-open");
90
+ nudgeStatusBar();
91
+ }
92
+
93
+ export function closeSide() {
94
+ if (!ui.layout?.classList.contains("side-open")) return;
95
+ ui.layout.classList.remove("side-open");
96
+ nudgeStatusBar();
97
+ }
98
+
99
+ /**
100
+ * iOS 26 的 PWA 会按页面顶部的颜色给状态栏上色,而且元素变了不一定重算,
101
+ * 侧栏遮罩关掉后状态栏还是暗的。重设一遍 theme-color 逼它刷新。
102
+ */
103
+ function nudgeStatusBar() {
104
+ requestAnimationFrame(() => {
105
+ for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
106
+ const v = meta.getAttribute("content");
107
+ meta.setAttribute("content", "");
108
+ meta.setAttribute("content", v);
109
+ }
110
+ });
111
+ }
112
+
113
+ // ---------- 侧边栏 ----------
114
+
115
+ let sidebarScheduled = false;
116
+ export function scheduleSidebar() {
117
+ if (sidebarScheduled) return;
118
+ sidebarScheduled = true;
119
+ requestAnimationFrame(() => {
120
+ sidebarScheduled = false;
121
+ renderSidebar();
122
+ reportDesktop();
123
+ });
124
+ }
125
+
126
+ /**
127
+ * 把一组会话按分叉关系排成树:父对话也在这一组里的,挂到父对话下面;其余的是根。
128
+ * 节点 { c, kids, at },at 是整棵子树里最近的活跃时间。根和同级都按 at 从新到旧。
129
+ */
130
+ function forkForest(convs) {
131
+ const byKey = new Map(convs.map((c) => [c.key, c]));
132
+ const nodes = new Map(convs.map((c) => [c.key, { c, kids: [], at: c.activeAt }]));
133
+ // 父链里绕回自己(数据坏了)就当根处理,免得整串都不显示
134
+ const parentOf = (c) => {
135
+ if (!c.forkOf || !byKey.has(c.forkOf)) return null;
136
+ for (let p = byKey.get(c.forkOf), n = 0; p && n < 50; p = byKey.get(p.forkOf), n++) {
137
+ if (p.key === c.key) return null;
138
+ }
139
+ return c.forkOf;
140
+ };
141
+ const roots = [];
142
+ for (const c of convs) {
143
+ const parent = parentOf(c);
144
+ (parent ? nodes.get(parent).kids : roots).push(nodes.get(c.key));
145
+ }
146
+ const settle = (node) => {
147
+ for (const k of node.kids) node.at = Math.max(node.at, settle(k));
148
+ node.kids.sort((a, b) => b.at - a.at);
149
+ return node.at;
150
+ };
151
+ for (const r of roots) settle(r);
152
+ return roots.sort((a, b) => b.at - a.at);
153
+ }
154
+
155
+ /** 画一棵分叉树:主对话一行,分叉缩进挂在下面,用虚线连起来(样式见 .fork-node) */
156
+ function renderForkNode(node, row, cls = "") {
157
+ const self = row(node.c, !!node.c.forkOf && !node.parentShown);
158
+ if (node.kids.length === 0) return self;
159
+ return h(
160
+ "div",
161
+ { class: "fork-group" },
162
+ self,
163
+ h(
164
+ "div",
165
+ { class: `fork-children${cls}` },
166
+ node.kids.map((k) => h("div", { class: "fork-node" }, renderForkNode({ ...k, parentShown: true }, row))),
167
+ ),
168
+ );
169
+ }
170
+
171
+ /** 分叉出来的对话:提示里写上从哪条来的 */
172
+ function forkNote(c) {
173
+ if (!c.forkOf) return "";
174
+ const parent = S.convs.get(c.forkOf);
175
+ return `\n分叉自「${parent ? convTitle(parent) : "已删除的对话"}」`;
176
+ }
177
+
178
+ export function convTitle(c) {
179
+ if (!c) return "新对话";
180
+ if (c.title) return c.title;
181
+ if (c.channel === "feishu") return `飞书会话 ${c.key.split(":")[2]?.slice(-6) ?? ""}`;
182
+ return "新对话";
183
+ }
184
+
185
+ export function agentLabel(id) {
186
+ return S.agents.find((a) => a.id === id)?.label ?? id;
187
+ }
188
+
189
+ function agentShort(id) {
190
+ return id === "claude" ? "Claude" : id === "codex" ? "Codex" : id;
191
+ }
192
+
193
+ /** 侧栏里展开 / 收起的状态:哪些项目目录展开了、「最近」和各目录是否显示全部 */
194
+ export const sideState = { open: [], more: [], folded: [], order: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
195
+
196
+ /**
197
+ * 「项目」目录的顺序:没手动排过时按最近活跃;拖过之后按 sideState.order,
198
+ * 之后新出现的目录排在已排好的后面(同样按活跃),不会挤掉用户置顶的目录
199
+ */
200
+ function orderProjectDirs(dirs) {
201
+ const rank = new Map(sideState.order.map((d, i) => [d, i]));
202
+ const ranked = dirs.filter((d) => rank.has(d)).sort((a, b) => rank.get(a) - rank.get(b));
203
+ return [...ranked, ...dirs.filter((d) => !rank.has(d))];
204
+ }
205
+
206
+ /** 当前侧栏里显示的目录顺序(renderSidebar 时记下,拖拽 / 右键菜单用) */
207
+ export let projDirs = [];
208
+
209
+ /** 把目录 dir 挪到 target 前面(after 为 true 时挪到后面);target 为 null 表示挪到最前 */
210
+ export function moveProjectDir(dir, target, after = false) {
211
+ if (dir === target) return;
212
+ const list = projDirs.filter((d) => d !== dir);
213
+ const idx = target === null ? 0 : list.indexOf(target);
214
+ if (idx < 0) return;
215
+ list.splice(idx + (after ? 1 : 0), 0, dir);
216
+ // 暂时没显示出来的目录(会话都被隐藏了之类)保留原来的名次,排在后面
217
+ sideState.order = [...list, ...sideState.order.filter((d) => !list.includes(d))];
218
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
219
+ renderSidebar();
220
+ }
221
+
222
+ export function resetProjectOrder() {
223
+ sideState.order = [];
224
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
225
+ renderSidebar();
226
+ }
227
+
228
+ /** 正在拖的目录。拖的过程中侧栏不重绘(重绘会换掉被拖的元素,拖拽就断了),松手后补一次 */
229
+ let dirDrag = null;
230
+ let sidebarStale = false;
231
+
232
+ /** 「最近」「项目」分组标题:点击折叠 / 展开,hover 时显示箭头 */
233
+ function groupHead(label, id, folded) {
234
+ return h(
235
+ "button",
236
+ { class: `conv-group${folded ? " collapsed" : ""}`, title: folded ? "展开" : "折叠", onclick: () => toggleSide("folded", id) },
237
+ label,
238
+ h("span", { class: "chev", html: ICON.down }),
239
+ );
240
+ }
241
+ function toggleSide(list, id) {
242
+ const set = new Set(sideState[list]);
243
+ set.has(id) ? set.delete(id) : set.add(id);
244
+ sideState[list] = [...set];
245
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
246
+ renderSidebar();
247
+ }
248
+
249
+ let autoOpenedFor = null;
250
+ const RECENT_LIMIT = 10;
251
+ const PROJECT_LIMIT = 5;
252
+
253
+ /** 待处理的授权 / 提问和正在跑的会话,报给桌面端显示在菜单栏里 */
254
+ export function reportDesktop() {
255
+ if (!desktop) return;
256
+ const pending = [...S.pending.values()]
257
+ .filter((i) => !i.settled)
258
+ .map((i) => ({ id: i.id, key: i.convKey, title: i.spec.title, conv: convTitle(S.convs.get(i.convKey)) }));
259
+ const busy = [...S.convs.values()].filter((c) => c.busy).length;
260
+ desktop.setState({ pending, busy });
261
+ }
262
+
263
+ export function renderSidebar() {
264
+ if (!ui.convList) return;
265
+ if (dirDrag) {
266
+ sidebarStale = true;
267
+ return;
268
+ }
269
+ sidebarStale = false;
270
+ const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
271
+ const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
272
+ const cur = S.current ? S.convs.get(S.current) : null;
273
+ const status = (keys) =>
274
+ keys.some((k) => askKeys.has(k))
275
+ ? h("span", { class: "dot ask", title: "等你处理" })
276
+ : keys.some((k) => S.convs.get(k)?.busy)
277
+ ? h("span", { class: "conv-spin", title: "进行中" })
278
+ : null;
279
+ const open = (key) => {
280
+ closeSide();
281
+ if (key !== S.current) void openConversation(key);
282
+ };
283
+ const more = (list, id, expanded) =>
284
+ h("button", { class: "conv-more", onclick: () => toggleSide(list, id) }, expanded ? "收起" : "展开显示");
285
+
286
+ // 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
287
+ const convRow = (c, cls, detail, orphan) =>
288
+ h(
289
+ "button",
290
+ {
291
+ class: `conv-item${cls}${c.key === S.current ? " active" : ""}`,
292
+ "data-key": c.key,
293
+ title: `${convTitle(c)}${forkNote(c)}\n${detail}\n双击重命名,右键更多操作`,
294
+ onclick: () => open(c.key),
295
+ },
296
+ orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
297
+ h("span", { class: "title" }, convTitle(c)),
298
+ status([c.key]),
299
+ );
300
+
301
+ // ---- 最近:Web 会话 + 每个飞书机器人一行(点开它最近活跃的聊天),按活跃时间混排 ----
302
+ // 分叉出来的对话挂在主对话下面,整棵树按里面最近活跃的那条排
303
+ const recent = [];
304
+ for (const node of forkForest(visible.filter((c) => c.channel !== "feishu"))) {
305
+ recent.push({ at: node.at, el: renderForkNode(node, (c, orphan) => convRow(c, "", `${agentLabel(c.agent)} · ${c.cwd}`, orphan)) });
306
+ }
307
+ for (const bot of S.bots) {
308
+ const convs = visible.filter((c) => c.channel === "feishu" && c.channelId === bot.id);
309
+ if (bot.enabled === false && convs.length === 0) continue;
310
+ const latest = convs[0];
311
+ recent.push({
312
+ at: latest?.activeAt ?? 0,
313
+ el: h(
314
+ "button",
315
+ {
316
+ class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
317
+ "data-bot": bot.id,
318
+ title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
319
+ onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
320
+ },
321
+ h("span", { class: "brand-icon lead", title: "飞书", html: ICON.feishu }),
322
+ h("span", { class: "title" }, bot.name),
323
+ status(convs.map((c) => c.key)),
324
+ ),
325
+ });
326
+ }
327
+ recent.sort((a, b) => b.at - a.at);
328
+ const recentAll = sideState.more.includes("@recent");
329
+ const recentFolded = sideState.folded.includes("@recent");
330
+ const nodes = [groupHead("最近", "@recent", recentFolded)];
331
+ if (!recentFolded) {
332
+ if (recent.length === 0) nodes.push(h("div", { class: "conv-empty" }, "还没有会话"));
333
+ nodes.push(...recent.slice(0, recentAll ? recent.length : RECENT_LIMIT).map((r) => r.el));
334
+ if (recent.length > RECENT_LIMIT) nodes.push(more("more", "@recent", recentAll));
335
+ }
336
+
337
+ // ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
338
+ const byDir = new Map();
339
+ for (const c of visible.filter((c) => c.channel === "web")) {
340
+ if (!byDir.has(c.cwd)) byDir.set(c.cwd, []);
341
+ byDir.get(c.cwd).push(c);
342
+ }
343
+ // 切到某个会话时,把它所在的目录展开一次(之后用户可以自己收起)
344
+ if (cur?.channel === "web" && cur.key !== autoOpenedFor) {
345
+ autoOpenedFor = cur.key;
346
+ if (!sideState.open.includes(cur.cwd)) {
347
+ sideState.open.push(cur.cwd);
348
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
349
+ }
350
+ }
351
+ const projFolded = sideState.folded.includes("@projects");
352
+ if (byDir.size) nodes.push(groupHead("项目", "@projects", projFolded));
353
+ projDirs = orderProjectDirs([...byDir.keys()]);
354
+ if (byDir.size && !projFolded) {
355
+ const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
356
+ const endDrag = () => {
357
+ dirDrag = null;
358
+ clearDrop();
359
+ if (sidebarStale) renderSidebar();
360
+ };
361
+ for (const dir of projDirs) {
362
+ const convs = byDir.get(dir);
363
+ const expanded = sideState.open.includes(dir);
364
+ const showAll = sideState.more.includes(dir);
365
+ // 鼠标在目标行的上半截就插到它前面,下半截插到后面
366
+ const isAfter = (e, row) => {
367
+ const r = row.getBoundingClientRect();
368
+ return e.clientY > r.top + r.height / 2;
369
+ };
370
+ nodes.push(
371
+ h(
372
+ "div",
373
+ {
374
+ class: "proj-row",
375
+ "data-dir": dir,
376
+ draggable: "true",
377
+ ondragstart: (e) => {
378
+ dirDrag = dir;
379
+ e.dataTransfer.effectAllowed = "move";
380
+ e.dataTransfer.setData("text/plain", dir);
381
+ e.currentTarget.classList.add("dragging");
382
+ stopMarquee();
383
+ },
384
+ ondragend: (e) => {
385
+ e.currentTarget.classList.remove("dragging");
386
+ endDrag();
387
+ },
388
+ ondragover: (e) => {
389
+ if (!dirDrag) return;
390
+ e.preventDefault();
391
+ e.dataTransfer.dropEffect = "move";
392
+ const after = isAfter(e, e.currentTarget);
393
+ if (!e.currentTarget.classList.contains(after ? "drop-after" : "drop-before")) {
394
+ clearDrop();
395
+ if (dirDrag !== dir) e.currentTarget.classList.add(after ? "drop-after" : "drop-before");
396
+ }
397
+ },
398
+ ondragleave: (e) => {
399
+ if (!e.currentTarget.contains(e.relatedTarget)) e.currentTarget.classList.remove("drop-before", "drop-after");
400
+ },
401
+ ondrop: (e) => {
402
+ if (!dirDrag) return;
403
+ e.preventDefault();
404
+ const from = dirDrag;
405
+ const after = isAfter(e, e.currentTarget);
406
+ dirDrag = null;
407
+ clearDrop();
408
+ if (from !== dir) moveProjectDir(from, dir, after);
409
+ else if (sidebarStale) renderSidebar();
410
+ },
411
+ },
412
+ h(
413
+ "button",
414
+ { class: "conv-item proj", title: `${dir}\n拖动可调整顺序,右键可置顶`, onclick: () => toggleSide("open", dir) },
415
+ h("span", { class: "proj-icon", html: expanded ? ICON.folderOpen : ICON.folder }),
416
+ h("span", { class: "title" }, baseName(dir)),
417
+ expanded ? null : status(convs.map((c) => c.key)),
418
+ ),
419
+ h(
420
+ "button",
421
+ {
422
+ class: "proj-new",
423
+ title: `在 ${baseName(dir)} 中开新对话`,
424
+ onclick: () => {
425
+ S.draftCwd = dir === S.workspaceRoot ? null : dir;
426
+ newDraft();
427
+ closeSide();
428
+ },
429
+ },
430
+ icon("edit"),
431
+ ),
432
+ ),
433
+ );
434
+ if (!expanded) continue;
435
+ // 「展开显示」按主对话算条数,分叉跟着主对话一起出现
436
+ const forest = forkForest(convs);
437
+ for (const node of forest.slice(0, showAll ? forest.length : PROJECT_LIMIT)) {
438
+ nodes.push(renderForkNode(node, (c, orphan) => convRow(c, " sub", agentLabel(c.agent), orphan), " in-proj"));
439
+ }
440
+ if (forest.length > PROJECT_LIMIT) nodes.push(h("div", { class: "sub-more" }, more("more", dir, showAll)));
441
+ }
442
+ }
443
+ // 重绘会换掉 DOM:如果鼠标正停在某个标题上,重绘后接着滚
444
+ const hovered = marquee.el ? marquee.el.textContent : null;
445
+ stopMarquee();
446
+ fill(ui.convList, ...nodes);
447
+ if (hovered) {
448
+ requestAnimationFrame(() => {
449
+ const el = ui.convList.querySelector(".conv-item:hover .title");
450
+ if (el?.textContent === hovered) startMarquee(el);
451
+ });
452
+ }
453
+ const asks = askKeys.size;
454
+ document.title = asks ? `(${asks}) 待处理 · Agent Loft` : "Agent Loft";
455
+ }
@@ -0,0 +1,185 @@
1
+ import { fill, h } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { S, ui } from "../state.js";
4
+ import { agentLabel } from "./sidebar.js";
5
+ import { usableAgents } from "./topbar.js";
6
+ import { autosize, sendMessage, updateSendBtn } from "./composer.js";
7
+
8
+ // ---------- 斜杠命令补全 ----------
9
+ //
10
+ // 输入框里只有「/xxx」(还没打空格)时弹出命令表,顺序就是设置 → 命令里排好的。
11
+ // 列表按 agent + 会话目录缓存一分钟:Claude 的原生命令要起一次进程才问得到。
12
+
13
+ export const slash = { open: false, items: [], index: 0, cache: new Map(), token: 0 };
14
+
15
+ export function commandUsage(c) {
16
+ return `/${c.name}${c.args ? ` ${c.args}` : ""}`;
17
+ }
18
+
19
+ export function oneLine(text, max = 80) {
20
+ const t = String(text ?? "").replace(/\s+/g, " ").trim();
21
+ return t.length > max ? `${t.slice(0, max)}…` : t;
22
+ }
23
+
24
+ export function currentAgentId() {
25
+ const c = S.current ? S.convs.get(S.current) : null;
26
+ return c?.agent ?? S.draftAgent ?? usableAgents()[0]?.id ?? "claude";
27
+ }
28
+
29
+ async function loadCommands(agent) {
30
+ const cacheKey = `${agent}|${S.current ?? ""}`;
31
+ const hit = slash.cache.get(cacheKey);
32
+ if (hit && Date.now() - hit.at < 60_000) return hit.list;
33
+ const q = S.current ? `?key=${encodeURIComponent(S.current)}` : "";
34
+ const data = await api(`/api/agents/${agent}/commands${q}`);
35
+ const list = data.commands.filter((c) => !c.hidden);
36
+ slash.cache.set(cacheKey, { at: Date.now(), list });
37
+ return list;
38
+ }
39
+
40
+ function slashQuery() {
41
+ const v = ui.input.value;
42
+ const m = /^[//]([^\s]*)$/.exec(v);
43
+ return m ? m[1].toLowerCase() : null;
44
+ }
45
+
46
+ /** 名字前缀匹配排最前,其次名字里包含,最后是说明里包含;同档内保持用户排的顺序 */
47
+ function matchCommands(list, q) {
48
+ if (!q) return list;
49
+ const tiers = [[], [], []];
50
+ for (const c of list) {
51
+ const name = c.name.toLowerCase();
52
+ if (name.startsWith(q)) tiers[0].push(c);
53
+ else if (name.includes(q)) tiers[1].push(c);
54
+ else if (c.description.toLowerCase().includes(q)) tiers[2].push(c);
55
+ }
56
+ return tiers.flat();
57
+ }
58
+
59
+ export async function updateSlashMenu() {
60
+ const q = slashQuery();
61
+ if (q === null) return closeSlashMenu();
62
+ const token = ++slash.token;
63
+ if (!slash.open) renderSlashMenu(null);
64
+ let list;
65
+ try {
66
+ list = await loadCommands(currentAgentId());
67
+ } catch (err) {
68
+ if (token === slash.token) renderSlashMenu(null, err.message);
69
+ return;
70
+ }
71
+ // 等待期间又打了字:以最新的那次为准
72
+ if (token !== slash.token || slashQuery() === null) return;
73
+ const items = matchCommands(list, slashQuery());
74
+ if (items.length === 0) return closeSlashMenu();
75
+ slash.items = items;
76
+ slash.index = Math.min(slash.index, items.length - 1);
77
+ renderSlashMenu(items);
78
+ }
79
+
80
+ function renderSlashMenu(items, error) {
81
+ slash.open = true;
82
+ ui.slashMenu.hidden = false;
83
+ if (!items) {
84
+ fill(ui.slashMenu, h("div", { class: "slash-empty" }, error ? `读取命令失败:${error}` : h("span", { class: "spinner" })));
85
+ return;
86
+ }
87
+ fill(
88
+ ui.slashMenu,
89
+ ...items.map((c, i) =>
90
+ h(
91
+ "button",
92
+ {
93
+ class: `slash-item${i === slash.index ? " on" : ""}`,
94
+ type: "button",
95
+ // mousedown 而不是 click:不然输入框先失焦,手机上键盘会闪一下
96
+ onmousedown: (e) => {
97
+ e.preventDefault();
98
+ void pickSlash(c, false);
99
+ },
100
+ onmousemove: () => {
101
+ if (slash.index !== i) {
102
+ slash.index = i;
103
+ highlightSlash();
104
+ }
105
+ },
106
+ },
107
+ h(
108
+ "div",
109
+ { class: "slash-head" },
110
+ h("span", { class: "slash-name" }, `/${c.name}`),
111
+ c.args ? h("span", { class: "slash-args" }, c.args) : null,
112
+ c.source === "agent" ? h("span", { class: "badge" }, agentLabel(currentAgentId())) : null,
113
+ c.source === "custom" ? h("span", { class: "badge" }, "自定义") : null,
114
+ ),
115
+ h("div", { class: "slash-desc" }, oneLine(c.description, 120) || "(无说明)"),
116
+ ),
117
+ ),
118
+ );
119
+ highlightSlash();
120
+ }
121
+
122
+ function highlightSlash() {
123
+ ui.slashMenu.querySelectorAll(".slash-item").forEach((el, i) => el.classList.toggle("on", i === slash.index));
124
+ ui.slashMenu.querySelector(".slash-item.on")?.scrollIntoView({ block: "nearest" });
125
+ }
126
+
127
+ export function closeSlashMenu() {
128
+ slash.open = false;
129
+ slash.items = [];
130
+ slash.index = 0;
131
+ slash.token++;
132
+ if (ui.slashMenu) ui.slashMenu.hidden = true;
133
+ }
134
+
135
+ /**
136
+ * 选中一条命令。fillOnly(Tab)只补全;回车 / 点击时,不需要参数的直接发出去,
137
+ * 需要参数的补全成「/name 」等用户接着打。
138
+ */
139
+ async function pickSlash(c, fillOnly) {
140
+ const needsArgs = !c.runnable;
141
+ if (fillOnly || needsArgs) {
142
+ ui.input.value = `/${c.name}${c.args ? " " : ""}`;
143
+ closeSlashMenu();
144
+ autosize();
145
+ updateSendBtn();
146
+ ui.input.focus();
147
+ return;
148
+ }
149
+ ui.input.value = `/${c.name}`;
150
+ closeSlashMenu();
151
+ await sendMessage();
152
+ }
153
+
154
+ /** 菜单开着时接管方向键 / Tab / 回车 / Esc;返回 true 表示已处理 */
155
+ export function slashKeydown(e) {
156
+ if (!slash.open || slash.items.length === 0) {
157
+ if (slash.open && e.key === "Escape") {
158
+ closeSlashMenu();
159
+ return true;
160
+ }
161
+ return false;
162
+ }
163
+ if (e.isComposing) return false;
164
+ switch (e.key) {
165
+ case "ArrowDown":
166
+ slash.index = (slash.index + 1) % slash.items.length;
167
+ highlightSlash();
168
+ return true;
169
+ case "ArrowUp":
170
+ slash.index = (slash.index - 1 + slash.items.length) % slash.items.length;
171
+ highlightSlash();
172
+ return true;
173
+ case "Tab":
174
+ void pickSlash(slash.items[slash.index], true);
175
+ return true;
176
+ case "Enter":
177
+ if (e.shiftKey) return false;
178
+ void pickSlash(slash.items[slash.index], false);
179
+ return true;
180
+ case "Escape":
181
+ closeSlashMenu();
182
+ return true;
183
+ }
184
+ return false;
185
+ }