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.
@@ -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() {
@@ -268,7 +358,6 @@ export function renderSidebar() {
268
358
  }
269
359
  sidebarStale = false;
270
360
  ui.tasksNav?.classList.toggle("active", S.view === "tasks");
271
- ui.skillsNav?.classList.toggle("active", S.view === "skills");
272
361
  const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
273
362
  const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
274
363
  const cur = S.current ? S.convs.get(S.current) : null;
@@ -282,9 +371,6 @@ export function renderSidebar() {
282
371
  closeSide();
283
372
  if (key !== S.current) void openConversation(key);
284
373
  };
285
- const more = (list, id, expanded) =>
286
- h("button", { class: "conv-more", onclick: () => toggleSide(list, id) }, expanded ? "收起" : "展开显示");
287
-
288
374
  // 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
289
375
  const convRow = (c, cls, detail, orphan) =>
290
376
  h(
@@ -298,6 +384,8 @@ export function renderSidebar() {
298
384
  orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
299
385
  // 定时任务的专用对话
300
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,
301
389
  h("span", { class: "title" }, convTitle(c)),
302
390
  status([c.key]),
303
391
  );
@@ -329,13 +417,15 @@ export function renderSidebar() {
329
417
  });
330
418
  }
331
419
  recent.sort((a, b) => b.at - a.at);
332
- const recentAll = sideState.more.includes("@recent");
333
420
  const recentFolded = sideState.folded.includes("@recent");
334
421
  const nodes = [groupHead("最近", "@recent", recentFolded)];
335
422
  if (!recentFolded) {
336
- if (recent.length === 0) nodes.push(h("div", { class: "conv-empty" }, "还没有会话"));
337
- nodes.push(...recent.slice(0, recentAll ? recent.length : RECENT_LIMIT).map((r) => r.el));
338
- 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);
339
429
  }
340
430
 
341
431
  // ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
@@ -352,10 +442,13 @@ export function renderSidebar() {
352
442
  localStorage.setItem("aim.side", JSON.stringify(sideState));
353
443
  }
354
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));
355
448
  const projFolded = sideState.folded.includes("@projects");
356
- if (byDir.size) nodes.push(groupHead("项目", "@projects", projFolded));
357
- projDirs = orderProjectDirs([...byDir.keys()]);
358
- 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) {
359
452
  const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
360
453
  const endDrag = () => {
361
454
  dirDrag = null;
@@ -363,9 +456,8 @@ export function renderSidebar() {
363
456
  if (sidebarStale) renderSidebar();
364
457
  };
365
458
  for (const dir of projDirs) {
366
- const convs = byDir.get(dir);
459
+ const convs = byDir.get(dir) ?? [];
367
460
  const expanded = sideState.open.includes(dir);
368
- const showAll = sideState.more.includes(dir);
369
461
  // 鼠标在目标行的上半截就插到它前面,下半截插到后面
370
462
  const isAfter = (e, row) => {
371
463
  const r = row.getBoundingClientRect();
@@ -436,12 +528,17 @@ export function renderSidebar() {
436
528
  ),
437
529
  );
438
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);
439
535
  // 「展开显示」按主对话算条数,分叉跟着主对话一起出现
440
- const forest = forkForest(convs);
441
- 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)) {
442
538
  nodes.push(renderForkNode(node, (c, orphan) => convRow(c, " sub", agentLabel(c.agent), orphan), " in-proj"));
443
539
  }
444
- 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));
445
542
  }
446
543
  }
447
544
  // 重绘会换掉 DOM:如果鼠标正停在某个标题上,重绘后接着滚
@@ -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
- const tasks = S.view === "tasks" || S.view === "skills";
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" }, S.view === "skills" ? "技能" : "定时任务") : 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