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
@@ -1,4 +1,5 @@
1
1
  import { fill, h, icon, ICON, toast } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
2
3
  import { desktop, S, ui } from "../state.js";
3
4
  import { baseName } from "./topbar.js";
4
5
  import { newDraft, openConversation } from "./messages.js";
@@ -190,7 +191,7 @@ function agentShort(id) {
190
191
  return id === "claude" ? "Claude" : id === "codex" ? "Codex" : id;
191
192
  }
192
193
 
193
- /** 侧栏里展开 / 收起的状态:哪些项目目录展开了、「最近」和各目录是否显示全部 */
194
+ /** 侧栏里展开 / 收起的状态:哪些项目目录展开了、哪些分组折叠了、目录的顺序(more 是老版本的,不再用) */
194
195
  export const sideState = { open: [], more: [], folded: [], order: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
195
196
 
196
197
  /**
@@ -249,6 +250,95 @@ function toggleSide(list, id) {
249
250
  let autoOpenedFor = null;
250
251
  const RECENT_LIMIT = 10;
251
252
  const PROJECT_LIMIT = 5;
253
+ /** 「展开显示」一次多显示几条 */
254
+ const MORE_STEP = 10;
255
+
256
+ // ---------- 分页(服务端见 channels/web/conv-pages.ts) ----------
257
+ // 打开页面只拿「最近」的前 10 条、展开着的目录各自的前 5 条(一条 = 主对话连同它的分叉),
258
+ // 正在跑的、等你处理的、每个飞书机器人最近的一条也都在;点「展开显示」多显示 10 条,本地不够再按页拉。
259
+ // 新出现的、变活跃的会话经 WebSocket 的 conv 消息补进 S.convs。
260
+ // next:下一页的游标;null = 没有更多了;undefined = 这个目录还没拉过。
261
+
262
+ /** full:老版本服务端一次给了全部,不用再拉 */
263
+ const pages = { recent: { show: RECENT_LIMIT, next: null, loading: false }, dirs: new Map(), dirAt: new Map(), full: false };
264
+
265
+ function dirState(dir) {
266
+ let st = pages.dirs.get(dir);
267
+ if (!st) pages.dirs.set(dir, (st = { show: PROJECT_LIMIT, next: pages.full ? null : undefined, loading: false }));
268
+ return st;
269
+ }
270
+
271
+ /** /api/bootstrap 的参数:按现在显示了多少条要(重连后整份换掉时,已经展开的不会缩回去) */
272
+ export function bootstrapQuery() {
273
+ const q = new URLSearchParams({ recent: String(pages.recent.show), open: JSON.stringify(sideState.open.map((d) => [d, dirState(d).show])) });
274
+ // 正开着的会话;刚打开页面时是地址里带的那条(可能在后面几页)
275
+ const current = S.current ?? decodeURIComponent(location.hash.slice(1));
276
+ if (current) q.set("current", current);
277
+ return `?${q}`;
278
+ }
279
+
280
+ export function applyBootstrap(data) {
281
+ S.convs = new Map(data.conversations.map((c) => [c.key, c]));
282
+ pages.full = !data.sidebar;
283
+ pages.recent.next = data.sidebar?.recent.next ?? null;
284
+ pages.dirAt = new Map((data.sidebar?.dirs ?? []).map((d) => [d.dir, d.at]));
285
+ const fetched = new Map((data.sidebar?.dirs ?? []).filter((d) => "next" in d).map((d) => [d.dir, d.next]));
286
+ for (const [dir, st] of pages.dirs) {
287
+ // 这次要了的(展开着的)目录用新游标;没要的下次展开时重新拉
288
+ st.next = pages.full ? null : sideState.open.includes(dir) ? (fetched.get(dir) ?? null) : undefined;
289
+ }
290
+ for (const [dir, next] of fetched) dirState(dir).next = next;
291
+ }
292
+
293
+ /** 拉下一页,合进 S.convs。失败了不自动重试(否则每次重绘都去拉),点「展开显示」再试 */
294
+ async function fetchPage(st, params) {
295
+ if (st.loading) return;
296
+ st.loading = true;
297
+ st.failed = false;
298
+ try {
299
+ const q = new URLSearchParams(params);
300
+ if (st.next) {
301
+ q.set("at", String(st.next.at));
302
+ q.set("key", st.next.key);
303
+ }
304
+ const data = await api(`/api/conversations/page?${q}`);
305
+ for (const c of data.conversations) S.convs.set(c.key, c);
306
+ st.next = data.next;
307
+ } catch (err) {
308
+ st.failed = true;
309
+ toast(`会话列表加载失败:${err.message}`);
310
+ } finally {
311
+ st.loading = false;
312
+ scheduleSidebar();
313
+ }
314
+ }
315
+
316
+ /**
317
+ * 只显示连续拉到的那一段:比下一页游标还旧的(正在跑的、地址里带的老会话先拉来了),
318
+ * 中间还隔着没拉的,先不显示,免得顺序看着跳
319
+ */
320
+ const loadedPrefix = (st, list) => (st.next ? list.filter((x) => x.at >= st.next.at) : list);
321
+
322
+ /** 「展开显示」「收起」:count 是能显示的条数,limit 是默认显示几条 */
323
+ function moreButton(st, count, limit, load) {
324
+ if (st.loading) return h("button", { class: "conv-more", disabled: true }, "加载中…");
325
+ if (count > st.show || st.next) {
326
+ return h(
327
+ "button",
328
+ {
329
+ class: "conv-more",
330
+ onclick: () => {
331
+ st.show += MORE_STEP;
332
+ if (count < st.show && st.next) void load(Math.max(MORE_STEP, st.show - count));
333
+ renderSidebar();
334
+ },
335
+ },
336
+ "展开显示",
337
+ );
338
+ }
339
+ if (st.show > limit) return h("button", { class: "conv-more", onclick: () => ((st.show = limit), renderSidebar()) }, "收起");
340
+ return null;
341
+ }
252
342
 
253
343
  /** 待处理的授权 / 提问和正在跑的会话,报给桌面端显示在菜单栏里 */
254
344
  export function reportDesktop() {
@@ -281,9 +371,6 @@ export function renderSidebar() {
281
371
  closeSide();
282
372
  if (key !== S.current) void openConversation(key);
283
373
  };
284
- const more = (list, id, expanded) =>
285
- h("button", { class: "conv-more", onclick: () => toggleSide(list, id) }, expanded ? "收起" : "展开显示");
286
-
287
374
  // 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
288
375
  const convRow = (c, cls, detail, orphan) =>
289
376
  h(
@@ -297,6 +384,8 @@ export function renderSidebar() {
297
384
  orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
298
385
  // 定时任务的专用对话
299
386
  c.taskId ? h("span", { class: "brand-icon lead task-mark", title: "定时任务的对话", html: ICON.clock }) : null,
387
+ // 聊天模式的对话
388
+ c.mode === "chat" ? h("span", { class: "brand-icon lead chat-mark", title: "聊天模式", html: ICON.chat }) : null,
300
389
  h("span", { class: "title" }, convTitle(c)),
301
390
  status([c.key]),
302
391
  );
@@ -328,13 +417,15 @@ export function renderSidebar() {
328
417
  });
329
418
  }
330
419
  recent.sort((a, b) => b.at - a.at);
331
- const recentAll = sideState.more.includes("@recent");
332
420
  const recentFolded = sideState.folded.includes("@recent");
333
421
  const nodes = [groupHead("最近", "@recent", recentFolded)];
334
422
  if (!recentFolded) {
335
- if (recent.length === 0) nodes.push(h("div", { class: "conv-empty" }, "还没有会话"));
336
- nodes.push(...recent.slice(0, recentAll ? recent.length : RECENT_LIMIT).map((r) => r.el));
337
- if (recent.length > RECENT_LIMIT) nodes.push(more("more", "@recent", recentAll));
423
+ const rs = pages.recent;
424
+ const shown = loadedPrefix(rs, recent);
425
+ if (shown.length === 0 && !rs.loading) nodes.push(h("div", { class: "conv-empty" }, "还没有会话"));
426
+ nodes.push(...shown.slice(0, rs.show).map((r) => r.el));
427
+ const btn = moreButton(rs, shown.length, RECENT_LIMIT, (limit) => fetchPage(rs, { scope: "recent", limit: String(limit) }));
428
+ if (btn) nodes.push(btn);
338
429
  }
339
430
 
340
431
  // ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
@@ -351,10 +442,13 @@ export function renderSidebar() {
351
442
  localStorage.setItem("aim.side", JSON.stringify(sideState));
352
443
  }
353
444
  }
445
+ // 目录行要全部显示:服务端给了每个目录最近的活跃时间,加上本地已有的(刚建的、刚变活跃的)
446
+ const dirAt = new Map(pages.dirAt);
447
+ for (const [dir, convs] of byDir) dirAt.set(dir, Math.max(dirAt.get(dir) ?? 0, convs[0].activeAt));
354
448
  const projFolded = sideState.folded.includes("@projects");
355
- if (byDir.size) nodes.push(groupHead("项目", "@projects", projFolded));
356
- projDirs = orderProjectDirs([...byDir.keys()]);
357
- if (byDir.size && !projFolded) {
449
+ if (dirAt.size) nodes.push(groupHead("项目", "@projects", projFolded));
450
+ projDirs = orderProjectDirs([...dirAt.keys()].sort((a, b) => dirAt.get(b) - dirAt.get(a)));
451
+ if (dirAt.size && !projFolded) {
358
452
  const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
359
453
  const endDrag = () => {
360
454
  dirDrag = null;
@@ -362,9 +456,8 @@ export function renderSidebar() {
362
456
  if (sidebarStale) renderSidebar();
363
457
  };
364
458
  for (const dir of projDirs) {
365
- const convs = byDir.get(dir);
459
+ const convs = byDir.get(dir) ?? [];
366
460
  const expanded = sideState.open.includes(dir);
367
- const showAll = sideState.more.includes(dir);
368
461
  // 鼠标在目标行的上半截就插到它前面,下半截插到后面
369
462
  const isAfter = (e, row) => {
370
463
  const r = row.getBoundingClientRect();
@@ -435,12 +528,17 @@ export function renderSidebar() {
435
528
  ),
436
529
  );
437
530
  if (!expanded) continue;
531
+ // 刚展开、还没拉过这个目录:去拉第一页(本地可能已经有几条正在跑的,先显示着)
532
+ const st = dirState(dir);
533
+ const load = (limit) => fetchPage(st, { scope: "dir", dir, limit: String(limit) });
534
+ if (st.next === undefined && !st.loading && !st.failed) void load(st.show);
438
535
  // 「展开显示」按主对话算条数,分叉跟着主对话一起出现
439
- const forest = forkForest(convs);
440
- for (const node of forest.slice(0, showAll ? forest.length : PROJECT_LIMIT)) {
536
+ const forest = loadedPrefix(st, forkForest(convs));
537
+ for (const node of forest.slice(0, st.show)) {
441
538
  nodes.push(renderForkNode(node, (c, orphan) => convRow(c, " sub", agentLabel(c.agent), orphan), " in-proj"));
442
539
  }
443
- if (forest.length > PROJECT_LIMIT) nodes.push(h("div", { class: "sub-more" }, more("more", dir, showAll)));
540
+ const btn = moreButton(st, forest.length, PROJECT_LIMIT, load);
541
+ if (btn) nodes.push(h("div", { class: "sub-more" }, btn));
444
542
  }
445
543
  }
446
544
  // 重绘会换掉 DOM:如果鼠标正停在某个标题上,重绘后接着滚
@@ -8,6 +8,7 @@ import { agentLabel, closeSide, renderSidebar } from "./sidebar.js";
8
8
  import { baseName, renderTopbar, usableAgents } from "./topbar.js";
9
9
  import { openConversation } from "./messages.js";
10
10
  import { changeCwd } from "./conv-actions.js";
11
+ import { attachMention } from "./mention.js";
11
12
 
12
13
  // ---------- 定时任务页 ----------
13
14
  // 到点让 agent 按提示词干活,结果回到一条对话里;「有需要才提醒」时由 agent 判断这次要不要打扰你。
@@ -281,8 +282,16 @@ export function openTaskForm(task, preset) {
281
282
  const src = task ?? preset ?? {};
282
283
  const agents = usableAgents();
283
284
  const title = h("input", { value: src.title ?? "", maxlength: "60", placeholder: "比如:检查 agent-loft 更新" });
284
- const prompt = h("textarea", { rows: 5, maxlength: "4000", placeholder: "到点让 agent 做什么。写成完整的指令:那时你多半不在,它没法再问你。" });
285
+ const prompt = h("textarea", { rows: 5, maxlength: "4000", placeholder: "到点让 agent 做什么。写成完整的指令:那时你多半不在,它没法再问你。可以用 @ 指定技能。" });
285
286
  prompt.value = src.prompt ?? "";
287
+ // @ 技能:和输入框一样,到点时服务端认提示词里的 @name
288
+ const mentionMenu = h("div", { class: "slash-menu below", hidden: true });
289
+ const mention = attachMention(prompt, mentionMenu);
290
+ prompt.addEventListener("input", () => void mention.update());
291
+ prompt.addEventListener("blur", () => setTimeout(mention.close, 150));
292
+ prompt.addEventListener("keydown", (e) => {
293
+ if (mention.keydown(e)) e.preventDefault();
294
+ });
286
295
 
287
296
  // ---- 频率 ----
288
297
  const sch = { kind: "daily", time: "09:00", days: [1], day: 1, every: 2, minute: 0, expr: "0 9 * * *", at: Date.now() + 3600_000, ...(src.schedule ?? {}) };
@@ -405,7 +414,7 @@ export function openTaskForm(task, preset) {
405
414
  "div",
406
415
  { class: "modal-body task-form" },
407
416
  field("名称", title),
408
- field("要做的事", prompt, "需要对比的写清楚怎么判断,比如「和上次的结果比」「版本号比当前的新才算更新」。"),
417
+ field("要做的事", h("div", { class: "mention-wrap" }, prompt, mentionMenu), "需要对比的写清楚怎么判断,比如「和上次的结果比」「版本号比当前的新才算更新」。输入 @ 可以指定这次要用的技能。"),
409
418
  field("频率", h("div", { class: "task-freq-row" }, freq, freqBox), freqHint),
410
419
  field("提醒", notify),
411
420
  target ? field("结果发到", target) : field("结果发到", h("div", { class: "task-static" }, task?.convKey ? `专用对话「${task.convTitle || "对话"}」` : "专用对话(第一次运行时新建,之后都发到这条)")),
@@ -1,10 +1,11 @@
1
1
  import { agentIcon, fill, h, icon, ICON, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { relTime } from "../lib/time.js";
4
- import { S, ui } from "../state.js";
4
+ import { desktop, desktopMenu, desktopSettings, S, ui } from "../state.js";
5
5
  import { agentLabel, convTitle, openSide } from "./sidebar.js";
6
6
  import { closeMenu, confirmDialog, modal, placePopover, showMenu, showPopover } from "../lib/overlay.js";
7
7
  import { newDraft, openConversation } from "./messages.js";
8
+ import { openSettings } from "./layout.js";
8
9
  import { openSchedule } from "./schedule.js";
9
10
  import { changeCwd, deleteConv, doAction, forkConversation, openModelPicker, renameConv, switchAgent } from "./conv-actions.js";
10
11
 
@@ -12,29 +13,168 @@ import { changeCwd, deleteConv, doAction, forkConversation, openModelPicker, ren
12
13
 
13
14
  export function renderTopbar() {
14
15
  const c = S.current ? S.convs.get(S.current) : null;
15
- // 定时任务页:只有标题,模式切换、「更多」都是对话用的
16
16
  const tasks = S.view === "tasks";
17
17
  fill(ui.topbar,
18
18
  h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
19
19
  h(
20
20
  "div",
21
21
  { class: "top-title" },
22
- tasks ? h("span", { class: "conv-name" }, "定时任务") : c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null,
22
+ tasks
23
+ ? h("span", { class: "conv-name" }, "定时任务")
24
+ : c
25
+ ? [
26
+ // 聊天模式的对话:标题前面挂个聊天图标
27
+ c.mode === "chat" ? h("span", { class: "conv-mode-icon", title: "聊天模式", html: ICON.chat }) : null,
28
+ h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)),
29
+ ]
30
+ : null,
23
31
  ),
24
- tasks ? null : ui.modeSlot,
25
- tasks ? null : h("button", { class: "icon-btn top-more", title: "更多", onclick: (e) => openTopMore(e.currentTarget) }, icon("more")),
32
+ // 桌面端里这个菜单放到桌面端顶栏的「更多」里了
33
+ desktopMenu ? null : h("button", { class: "icon-btn top-more", title: "更多", onclick: (e) => openTopMore(e.currentTarget) }, icon("more")),
26
34
  );
27
35
  renderContext();
36
+ syncDesktopMenu();
28
37
  }
29
38
 
30
- /** 顶栏右上角「更多」 */
39
+ /**
40
+ * 右上角「更多」里的项。对话页(含新对话)有聊天模式、定时发送;设置哪个页面都有,
41
+ * 桌面端自己的「更多」里已经有每台服务器的设置时不放
42
+ */
43
+ function topMenuItems() {
44
+ const items = [];
45
+ if (!S.view) {
46
+ const c = S.current ? S.convs.get(S.current) : null;
47
+ const chat = (c ? (c.mode ?? "work") : S.draftMode) === "chat";
48
+ items.push({ id: "mode", label: "聊天模式", icon: "chat", checked: chat, onclick: () => void toggleMode() });
49
+ items.push({ id: "schedule", label: "定时发送…", icon: "clock", onclick: () => openSchedule() });
50
+ }
51
+ if (!desktopSettings) {
52
+ if (items.length) items.push("sep");
53
+ items.push({ id: "settings", label: "设置", icon: "gear", onclick: () => openSettings() });
54
+ }
55
+ return items;
56
+ }
57
+
58
+ /** 顶栏右上角「更多」:菜单项 + 最下面当前会话的 token 用量 */
31
59
  function openTopMore(anchor) {
32
60
  if (ui.menu?.anchor === anchor) return closeMenu();
33
- showMenu(anchor, [{ label: "定时发送…", onclick: () => openSchedule() }]);
34
- ui.menu.anchor = anchor;
35
- // 贴着按钮右边对齐,别超出屏幕
36
- const r = anchor.getBoundingClientRect();
37
- ui.menu.style.left = `${Math.max(8, r.right - ui.menu.offsetWidth)}px`;
61
+ const pop = showPopover(anchor, { place: "below", align: "right" });
62
+ pop.classList.add("top-menu");
63
+ const usage = renderUsage();
64
+ fill(
65
+ pop,
66
+ topMenuItems().map((it) =>
67
+ it === "sep"
68
+ ? h("div", { class: "sep" })
69
+ : h(
70
+ "button",
71
+ { class: `pop-item${it.checked ? " on" : ""}`, onclick: () => (closeMenu(), it.onclick()) },
72
+ icon(it.icon),
73
+ h("span", { class: "grow" }, it.label),
74
+ it.checked ? h("span", { class: "check" }, "✓") : null,
75
+ ),
76
+ ),
77
+ usage ? [h("div", { class: "sep" }), usage] : null,
78
+ );
79
+ placePopover(pop, anchor);
80
+ }
81
+
82
+ // ---------- 当前会话的 token 用量(「更多」菜单底部) ----------
83
+
84
+ /** 分布的各项,顺序和颜色固定(颜色见 css/topbar.css 的 --usage-*) */
85
+ const USAGE_PARTS = [
86
+ { key: "input", label: "输入", cls: "u-input" },
87
+ { key: "cacheWrite", label: "缓存写入", cls: "u-cwrite" },
88
+ { key: "cacheRead", label: "缓存读取", cls: "u-cread" },
89
+ { key: "output", label: "输出", cls: "u-output" },
90
+ ];
91
+
92
+ /** 1234 → 1.2k,1234567 → 1.23M */
93
+ function fmtCount(n) {
94
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e8 ? 0 : n >= 1e7 ? 1 : 2)}M`;
95
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e5 ? 0 : 1)}k`;
96
+ return String(n);
97
+ }
98
+
99
+ const pctText = (n, total) => {
100
+ const p = (n / total) * 100;
101
+ return p > 0 && p < 1 ? "<1%" : `${Math.round(p)}%`;
102
+ };
103
+
104
+ /** 对话页才有:{ total, parts(为 0 的不列), usage };还没有记录时 total 为 0。新对话、任务页返回 null */
105
+ function usageSummary() {
106
+ if (S.view || !S.current) return null;
107
+ const u = S.convs.get(S.current)?.usage;
108
+ const parts = USAGE_PARTS.map((p) => ({ ...p, n: u?.[p.key] ?? 0 })).filter((p) => p.n > 0);
109
+ return { total: parts.reduce((sum, p) => sum + p.n, 0), parts, usage: u };
110
+ }
111
+
112
+ function renderUsage() {
113
+ const sum = usageSummary();
114
+ if (!sum) return null;
115
+ if (!sum.total) return h("div", { class: "usage" }, h("div", { class: "usage-empty" }, "本会话还没有 token 用量记录"));
116
+ const { total, parts, usage } = sum;
117
+ const exact = (n) => n.toLocaleString();
118
+ return h(
119
+ "div",
120
+ { class: "usage", role: "group", "aria-label": "本会话的 token 用量" },
121
+ h("div", { class: "usage-head" }, h("span", {}, "本会话 Token"), h("b", { title: `${exact(total)} tokens` }, fmtCount(total))),
122
+ h(
123
+ "div",
124
+ { class: "usage-bar", "aria-hidden": "true" },
125
+ parts.map((p) => h("i", { class: p.cls, style: { flexGrow: String(p.n) }, title: `${p.label} ${exact(p.n)}(${pctText(p.n, total)})` })),
126
+ ),
127
+ parts.map((p) =>
128
+ h(
129
+ "div",
130
+ { class: "usage-row", title: `${exact(p.n)} tokens` },
131
+ h("span", { class: `usage-dot ${p.cls}` }),
132
+ h("span", { class: "usage-name" }, p.label, p.key === "output" && usage.reasoning ? h("small", {}, `含推理 ${fmtCount(usage.reasoning)}`) : null),
133
+ h("span", { class: "usage-num" }, fmtCount(p.n)),
134
+ h("span", { class: "usage-pct" }, pctText(p.n, total)),
135
+ ),
136
+ ),
137
+ usage.costUsd ? h("div", { class: "usage-foot" }, `按 API 价格约 $${usage.costUsd.toFixed(2)}`) : null,
138
+ );
139
+ }
140
+
141
+ /** 桌面端原生菜单里只能放文字:用量写成几行 */
142
+ function usageLines() {
143
+ const sum = usageSummary();
144
+ if (!sum) return [];
145
+ if (!sum.total) return ["本会话还没有 token 用量记录"];
146
+ const { total, parts, usage } = sum;
147
+ return [
148
+ `本会话 Token 共 ${fmtCount(total)}${usage.costUsd ? `(按 API 价格约 $${usage.costUsd.toFixed(2)})` : ""}`,
149
+ ...parts.map(
150
+ (p) => ` ${p.label} ${fmtCount(p.n)} ${pctText(p.n, total)}${p.key === "output" && usage.reasoning ? `(含推理 ${fmtCount(usage.reasoning)})` : ""}`,
151
+ ),
152
+ ];
153
+ }
154
+
155
+ // ---------- 桌面端:菜单交给它的顶栏「更多」 ----------
156
+
157
+ let desktopActions = new Map();
158
+ let desktopMenuSig = "";
159
+
160
+ /** 菜单(模式、用量…)变了就报给桌面端 */
161
+ function syncDesktopMenu() {
162
+ if (!desktopMenu) return;
163
+ const items = topMenuItems();
164
+ desktopActions = new Map(items.filter((it) => it !== "sep").map((it) => [it.id, it.onclick]));
165
+ const menu = {
166
+ items: items.map((it) => (it === "sep" ? { type: "separator" } : { id: it.id, label: it.label, ...(it.checked !== undefined ? { checked: it.checked } : {}) })),
167
+ usage: usageLines(),
168
+ };
169
+ const sig = JSON.stringify(menu);
170
+ if (sig === desktopMenuSig) return;
171
+ desktopMenuSig = sig;
172
+ desktop.setMenu(menu);
173
+ }
174
+
175
+ /** 桌面端「更多」里点了页面的菜单项 */
176
+ export function runDesktopMenu(id) {
177
+ desktopActions.get(id)?.();
38
178
  }
39
179
 
40
180
  export function baseName(p) {
@@ -180,7 +320,6 @@ export function renderContext() {
180
320
  effort = (c ? (c.runtimeEffort ?? c.effort) : pick.effort) ?? defaults.effort;
181
321
  }
182
322
  renderCtxRing();
183
- renderModePill(c);
184
323
  fill(
185
324
  ui.modelSlot,
186
325
  model
@@ -195,34 +334,18 @@ export function renderContext() {
195
334
  );
196
335
  }
197
336
 
198
- /** 输入框里的「聊天 / 工作」切换。会话里切换走服务端(同一条对话接着聊),新对话里只是记下选择 */
199
- function renderModePill(c) {
200
- if (!ui.modeSlot) return;
201
- const mode = c ? (c.mode ?? "work") : S.draftMode;
202
- const chat = mode === "chat";
203
- fill(
204
- ui.modeSlot,
205
- h(
206
- "button",
207
- {
208
- class: `mode-pill${chat ? " chat" : ""}`,
209
- title: chat
210
- ? "聊天模式:精简的系统提示,只能联网搜索,不执行命令、不读写文件,每轮开销小很多。点击切到工作模式"
211
- : "工作模式:agent 原装的全部能力。点击切到聊天模式(更省、更快,适合日常聊天和查资料)",
212
- onclick: () => void toggleMode(),
213
- },
214
- chat ? "💬 聊天" : "🛠 工作",
215
- ),
216
- );
217
- }
218
-
337
+ /**
338
+ * 「更多」里的聊天模式开关。聊天模式:精简的系统提示,只能联网搜索,不执行命令、不读写文件,每轮开销小很多;
339
+ * 工作模式:agent 原装的全部能力。会话里切换走服务端(同一条对话接着聊),新对话里只是记下选择
340
+ */
219
341
  async function toggleMode() {
220
342
  const c = S.current ? S.convs.get(S.current) : null;
221
343
  const next = (c ? (c.mode ?? "work") : S.draftMode) === "chat" ? "work" : "chat";
222
344
  if (!c) {
223
345
  S.draftMode = next;
224
346
  localStorage.setItem("aim.mode", next);
225
- renderContext();
347
+ renderTopbar();
348
+ toast(next === "chat" ? "新对话用聊天模式:更省、更快,只能联网搜索,不执行命令、不读写文件" : "新对话用工作模式");
226
349
  return;
227
350
  }
228
351
  // 聊过的聊天对话切到工作模式:工作模式会在对话里留下一大段 Claude Code / Codex 的背景,
@@ -365,14 +488,19 @@ async function continueInFeishu(key) {
365
488
  );
366
489
  }
367
490
 
368
- /** 飞书机器人的历史会话:这个机器人的所有聊天,按活跃时间排,点一下打开 */
369
- export function openBotConversations(botId) {
491
+ /** 飞书机器人的历史会话:这个机器人的所有聊天(侧栏只拿了最近的一条,这里去服务端拉全),按活跃时间排,点一下打开 */
492
+ export async function openBotConversations(botId) {
370
493
  const bot = S.bots.find((b) => b.id === botId);
371
- const convs = [...S.convs.values()]
372
- .filter((c) => c.channel === "feishu" && c.channelId === botId && !c.hidden)
373
- .sort((a, b) => b.activeAt - a.activeAt);
374
- const body = h("div", { class: "modal-body" });
494
+ const body = h("div", { class: "modal-body" }, h("span", { class: "spinner" }));
375
495
  const close = modal({ title: `历史会话${bot ? ` · ${bot.name}` : ""}`, body });
496
+ let convs;
497
+ try {
498
+ convs = (await api(`/api/conversations/page?${new URLSearchParams({ scope: "bot", bot: botId })}`)).conversations;
499
+ } catch (err) {
500
+ fill(body, h("div", { class: "error-text" }, err.message));
501
+ return;
502
+ }
503
+ for (const c of convs) S.convs.set(c.key, c);
376
504
  fill(
377
505
  body,
378
506
  ...(convs.length === 0