agent-loft 0.2.29 → 0.2.31

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 (69) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +3 -2
  4. package/src/agents/claude/session.ts +11 -4
  5. package/src/agents/codex/session.ts +9 -3
  6. package/src/channels/feishu/bot.ts +8 -4
  7. package/src/channels/feishu/turn-stream.ts +3 -2
  8. package/src/channels/web/api.ts +4 -0
  9. package/src/channels/web/server.ts +14 -2
  10. package/src/channels/web/tasks-api.ts +94 -0
  11. package/src/core/chat-mode.ts +14 -14
  12. package/src/core/hub.ts +95 -16
  13. package/src/core/memory-mcp.ts +1 -1
  14. package/src/core/personal.ts +3 -1
  15. package/src/core/store.ts +2 -0
  16. package/src/core/task-prompt.ts +38 -0
  17. package/src/core/task-runner.ts +232 -0
  18. package/src/core/task-schedule.ts +250 -0
  19. package/src/core/tasks-bridge.ts +252 -0
  20. package/src/core/tasks-mcp.ts +55 -0
  21. package/src/core/tasks.ts +281 -0
  22. package/src/core/types.ts +2 -0
  23. package/src/main.ts +8 -0
  24. package/src/mcp/tasks-server.ts +206 -0
  25. package/web/app.js +8 -5560
  26. package/web/css/base.css +291 -0
  27. package/web/css/composer.css +506 -0
  28. package/web/css/loader.css +196 -0
  29. package/web/css/messages.css +734 -0
  30. package/web/css/mobile.css +100 -0
  31. package/web/css/overlay.css +437 -0
  32. package/web/css/schedule.css +93 -0
  33. package/web/css/settings.css +335 -0
  34. package/web/css/sidebar.css +593 -0
  35. package/web/css/tasks.css +455 -0
  36. package/web/css/topbar.css +205 -0
  37. package/web/index.html +12 -1
  38. package/web/lib/api.js +26 -0
  39. package/web/lib/dom.js +101 -0
  40. package/web/lib/markdown.js +52 -0
  41. package/web/lib/overlay.js +144 -0
  42. package/web/lib/push.js +70 -0
  43. package/web/lib/time.js +60 -0
  44. package/web/lib/ws.js +236 -0
  45. package/web/settings/agents.js +210 -0
  46. package/web/settings/commands.js +411 -0
  47. package/web/settings/common.js +42 -0
  48. package/web/settings/feishu.js +181 -0
  49. package/web/settings/general.js +184 -0
  50. package/web/settings/index.js +63 -0
  51. package/web/settings/memory.js +366 -0
  52. package/web/settings/notify.js +80 -0
  53. package/web/settings/security.js +188 -0
  54. package/web/settings/users.js +189 -0
  55. package/web/state.js +46 -0
  56. package/web/views/auth.js +61 -0
  57. package/web/views/composer.js +324 -0
  58. package/web/views/conv-actions.js +412 -0
  59. package/web/views/interaction.js +140 -0
  60. package/web/views/layout.js +197 -0
  61. package/web/views/messages.js +531 -0
  62. package/web/views/quota.js +145 -0
  63. package/web/views/schedule.js +120 -0
  64. package/web/views/sidebar.js +458 -0
  65. package/web/views/slash.js +185 -0
  66. package/web/views/tasks.js +460 -0
  67. package/web/views/topbar.js +400 -0
  68. package/web/views/voice.js +189 -0
  69. package/web/style.css +0 -3500
@@ -0,0 +1,531 @@
1
+ import { agentIcon, fill, h, icon, starsLoader } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { clockTime, liveDuration, serverNow, setServerTime, tidyFooter } from "../lib/time.js";
4
+ import { copyText, renderMarkdown } from "../lib/markdown.js";
5
+ import { S, ui } from "../state.js";
6
+ import { WS } from "../lib/ws.js";
7
+ import { agentLabel, convTitle, renderSidebar } from "./sidebar.js";
8
+ import { baseName, renderContext, renderTopbar, updateCtxVisibility, usableAgents } from "./topbar.js";
9
+ import { renderInteraction, upsertInteraction } from "./interaction.js";
10
+ import { renderChips, setBusy } from "./composer.js";
11
+ import { renderScheduled } from "./schedule.js";
12
+ import { changeCwd, forkConversation } from "./conv-actions.js";
13
+
14
+ // ---------- 打开会话 ----------
15
+
16
+ /** 从定时任务页回到对话:把输入框放回来 */
17
+ function leaveTasksView() {
18
+ if (S.view !== "tasks") return;
19
+ S.view = null;
20
+ ui.main.classList.remove("tasks-view");
21
+ }
22
+
23
+ export function newDraft() {
24
+ leaveTasksView();
25
+ if (S.current) WS.raw({ t: "unsub", key: S.current });
26
+ S.current = null;
27
+ S.items = [];
28
+ S.turnAi = null;
29
+ turnStartAt = null;
30
+ S.uploads = [];
31
+ S.pinNext = false;
32
+ ui.pinEl = null;
33
+ ui.follow = true;
34
+ setBusy(false);
35
+ renderScheduled();
36
+ history.replaceState(null, "", location.pathname);
37
+ WS.presence();
38
+ renderTopbar();
39
+ renderEmpty();
40
+ renderChips();
41
+ renderSidebar();
42
+ ui.input?.focus();
43
+ }
44
+
45
+ export function renderEmpty() {
46
+ const enabled = usableAgents();
47
+ const cwd = S.draftCwd ?? S.workspaceRoot ?? "";
48
+ fill(ui.msgInner,
49
+ h(
50
+ "div",
51
+ { class: "empty hero" },
52
+ agentIcon(S.draftAgent, "hero-logo"),
53
+ enabled.length
54
+ ? h(
55
+ "h2",
56
+ {},
57
+ "你想让我们在 ",
58
+ h("button", { class: "hero-dir", title: `${cwd}\n点击切换工作目录`, onclick: () => changeCwd() }, baseName(cwd) || "工作区"),
59
+ " 中构建什么?",
60
+ )
61
+ : [h("h2", {}, "有什么可以帮你?"), h("div", {}, "还没有启用任何 agent,先去设置里启用。")],
62
+ ),
63
+ );
64
+ renderContext();
65
+ }
66
+
67
+ export async function openConversation(key) {
68
+ leaveTasksView();
69
+ const token = ++S.loadToken;
70
+ if (S.current && S.current !== key) WS.raw({ t: "unsub", key: S.current });
71
+ S.current = key;
72
+ S.items = [];
73
+ S.turnAi = null;
74
+ turnStartAt = null;
75
+ S.uploads = [];
76
+ S.pinNext = false;
77
+ ui.pinEl = null;
78
+ ui.follow = true;
79
+ renderChips();
80
+ renderScheduled();
81
+ history.replaceState(null, "", `#${encodeURIComponent(key)}`);
82
+ WS.presence();
83
+ renderTopbar();
84
+ renderSidebar();
85
+ fill(ui.msgInner, h("div", { class: "empty" }, starsLoader("lg")));
86
+ let data;
87
+ for (let attempt = 0; ; attempt++) {
88
+ try {
89
+ data = await api(`/api/conversations/${encodeURIComponent(key)}/history`);
90
+ break;
91
+ } catch (err) {
92
+ if (token !== S.loadToken) return;
93
+ // 503 = 会话正忙着变化,服务端拿不到一致的快照:等一下再拉
94
+ if (err.status === 503 && attempt < 10) {
95
+ await new Promise((r) => setTimeout(r, 500 + attempt * 300));
96
+ if (token !== S.loadToken) return;
97
+ continue;
98
+ }
99
+ fill(ui.msgInner, h("div", { class: "empty" }, err.message));
100
+ return;
101
+ }
102
+ }
103
+ if (token !== S.loadToken) return; // 期间又切到别的会话了
104
+ S.convs.set(key, data.info);
105
+ S.allowedAgents = data.allowedAgents;
106
+ if (Number.isFinite(data.serverTime)) setServerTime(data.serverTime);
107
+ fill(ui.msgInner, );
108
+ for (const m of data.messages) {
109
+ if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
110
+ else addItem({ type: "ai", steps: m.steps ?? [], committed: m.text, partial: "", done: true, footer: m.footer, at: m.ts, anchor: m.anchor });
111
+ }
112
+ S.seq.set(key, 0);
113
+ for (const env of data.live) applyEvent(env);
114
+ S.seq.set(key, Math.max(S.seq.get(key) ?? 0, data.seq));
115
+ for (const ix of data.pending) upsertInteraction(ix);
116
+ if (S.items.length === 0) {
117
+ ui.msgInner.append(
118
+ h(
119
+ "div",
120
+ { class: "empty" },
121
+ h("h2", {}, convTitle(data.info)),
122
+ h("div", {}, data.info.sessionId ? "这条会话暂时读不到历史记录,直接接着说就行。" : `${agentLabel(data.info.agent)} · ${data.info.cwd}`),
123
+ ),
124
+ );
125
+ }
126
+ setBusy(data.info.busy);
127
+ renderTopbar();
128
+ WS.subscribe(key, S.seq.get(key));
129
+ renderScheduled();
130
+ scrollToBottom();
131
+ }
132
+
133
+ // ---------- 消息渲染 ----------
134
+
135
+ // 滚动规则(和 ChatGPT 一样):
136
+ // - 自己发出一条消息后,把它顶到视口最上面,下面留白;回复往下长,长到屏幕底部之后才跟着往下滚。
137
+ // 留白靠消息列表后面的 ui.spacer 撑出来:高度 = 刚好够那条消息滚到顶,回复越长它越矮,到 0 为止。
138
+ // - 回复过程中只要用户自己滚了(滚轮、触摸、拖滚动条、键盘翻页),就不再自动滚,直到下一次发消息。
139
+ // ui.follow 记着现在要不要自动滚。
140
+
141
+ function nearBottom(gap = 120) {
142
+ const m = ui.messages;
143
+ return m.scrollHeight - m.scrollTop - m.clientHeight < gap;
144
+ }
145
+
146
+ /** 内容变了之后:重算留白,要跟随的话贴到底(有留白时「底」正好就是被顶上去的那条消息) */
147
+ function afterLayout() {
148
+ if (!ui.messages) return;
149
+ fitSpacer();
150
+ if (ui.follow && performance.now() > (ui.smoothUntil ?? 0)) ui.messages.scrollTop = ui.messages.scrollHeight;
151
+ updateToBottomBtn();
152
+ }
153
+
154
+ function fitSpacer() {
155
+ const pin = ui.pinEl;
156
+ if (!pin?.isConnected) {
157
+ ui.pinEl = null;
158
+ ui.spacer.style.height = "0px";
159
+ return;
160
+ }
161
+ // 顶栏是叠在消息区上面的:让出它的高度,再留一点空
162
+ const covered = Math.max(0, ui.topbar.getBoundingClientRect().bottom - ui.messages.getBoundingClientRect().top);
163
+ const top = Math.max(0, pin.offsetTop - covered - 12);
164
+ const need = top + ui.messages.clientHeight - ui.msgInner.offsetHeight;
165
+ ui.spacer.style.height = `${Math.max(0, Math.ceil(need))}px`;
166
+ }
167
+
168
+ /** 回到跟随模式并贴到底:打开会话、点「回到底部」时用 */
169
+ export function scrollToBottom() {
170
+ ui.follow = true;
171
+ afterLayout();
172
+ }
173
+
174
+ /** 把刚发出的这条消息顶到最上面 */
175
+ function pinToTop(el) {
176
+ ui.pinEl = el;
177
+ ui.follow = true;
178
+ fitSpacer();
179
+ ui.smoothUntil = performance.now() + 450;
180
+ ui.messages.scrollTo({ top: ui.messages.scrollHeight, behavior: "smooth" });
181
+ // 平滑滚动期间留白可能又变了(比如输入框清空变矮),滚完再对齐一次
182
+ setTimeout(afterLayout, 460);
183
+ updateToBottomBtn();
184
+ }
185
+
186
+ /** 用户自己动了滚动:停止自动滚动 */
187
+ function userScrolled() {
188
+ ui.follow = false;
189
+ ui.smoothUntil = 0;
190
+ }
191
+
192
+ function updateToBottomBtn() {
193
+ if (ui.toBottom) ui.toBottom.hidden = nearBottom(200);
194
+ }
195
+
196
+ export function bindScrollWatch() {
197
+ const m = ui.messages;
198
+ m.addEventListener("wheel", userScrolled, { passive: true });
199
+ m.addEventListener("touchmove", userScrolled, { passive: true });
200
+ // 点在滚动条上时事件目标就是滚动容器本身(点在内容上的是里面的元素)
201
+ m.addEventListener("pointerdown", (e) => e.target === m && userScrolled());
202
+ m.addEventListener("keydown", (e) => {
203
+ if (["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "].includes(e.key)) userScrolled();
204
+ });
205
+ m.addEventListener("scroll", updateToBottomBtn, { passive: true });
206
+ // 消息区变高(发送后输入框清空变矮)、内容变高都要重算留白;窗口 resize 管不到元素自己变尺寸
207
+ const ro = new ResizeObserver(() => afterLayout());
208
+ ro.observe(m);
209
+ ro.observe(ui.msgInner);
210
+ }
211
+
212
+ /** 只有最后一条回复默认显示底部的操作栏(复制 / 分叉 / 用时…),前面的 hover 才出来 */
213
+ function markLatestAi() {
214
+ let latest = null;
215
+ for (let i = S.items.length - 1; i >= 0; i--) {
216
+ const it = S.items[i];
217
+ if (it.type === "ai" && it.done && !it.segment) {
218
+ latest = it;
219
+ break;
220
+ }
221
+ }
222
+ for (const it of S.items) if (it.type === "ai") it.el?.classList.toggle("latest", it === latest);
223
+ }
224
+
225
+ export function addItem(item) {
226
+ ui.msgInner.querySelector(".empty")?.remove();
227
+ item.el = renderItem(item);
228
+ ui.msgInner.append(item.el);
229
+ S.items.push(item);
230
+ markLatestAi();
231
+ updateCtxVisibility();
232
+ afterLayout();
233
+ return item;
234
+ }
235
+
236
+ const dirty = new Set();
237
+ let flushScheduled = false;
238
+ export function updateItem(item) {
239
+ dirty.add(item);
240
+ if (flushScheduled) return;
241
+ flushScheduled = true;
242
+ requestAnimationFrame(() => {
243
+ flushScheduled = false;
244
+ for (const it of dirty) {
245
+ if (!it.el?.isConnected) continue;
246
+ const next = renderItem(it);
247
+ it.el.replaceWith(next);
248
+ it.el = next;
249
+ }
250
+ dirty.clear();
251
+ markLatestAi();
252
+ afterLayout();
253
+ });
254
+ }
255
+
256
+ const fileUrl = (f, download) => `/api/file?path=${encodeURIComponent(f.path)}${download ? "&download=1" : ""}`;
257
+
258
+ function fmtSize(n) {
259
+ if (!n) return "";
260
+ if (n < 1024) return `${n} B`;
261
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
262
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
263
+ }
264
+
265
+ /** 非图片附件:卡片(图标 + 文件名 + 大小),点一下下载 */
266
+ function fileCard(f, gone) {
267
+ const ext = (f.name.match(/\.([^.]+)$/)?.[1] ?? "").slice(0, 4).toUpperCase();
268
+ return h(
269
+ gone ? "span" : "a",
270
+ {
271
+ class: `att-file${gone ? " gone" : ""}`,
272
+ ...(gone ? { title: "文件已清理(开新会话 / 切换目录时会清掉附件)" } : { href: fileUrl(f, true), download: f.name, title: `下载 ${f.name}` }),
273
+ },
274
+ h("span", { class: "att-ext" }, ext || "FILE"),
275
+ h(
276
+ "span",
277
+ { class: "att-meta" },
278
+ h("span", { class: "att-name" }, f.name),
279
+ h("span", { class: "att-size" }, gone ? "已清理" : fmtSize(f.size) || "下载"),
280
+ ),
281
+ );
282
+ }
283
+
284
+ /**
285
+ * 用户消息带的附件,排在气泡上面,顺序和发送时一致。
286
+ * files:新的是 {kind, name, path, size} 对象;老事件里只有「图片 a.png」这样的标签,只能显示名字
287
+ */
288
+ function renderUserFiles(files) {
289
+ if (!files?.length) return null;
290
+ return h(
291
+ "div",
292
+ { class: "msg-atts" },
293
+ files.map((f) => {
294
+ if (typeof f === "string") return h("span", { class: "att-file gone" }, h("span", { class: "att-meta" }, h("span", { class: "att-name" }, f)));
295
+ if (f.kind !== "image") return fileCard(f);
296
+ const box = h("button", { class: "att-img", title: f.name, onclick: () => openLightbox(fileUrl(f), f.name, fileUrl(f, true)) });
297
+ const img = h("img", { src: fileUrl(f), alt: f.name, loading: "lazy" });
298
+ // 文件已经被清理:退回成「已清理」的文件卡片
299
+ img.onerror = () => box.replaceWith(fileCard(f, true));
300
+ box.append(img);
301
+ return box;
302
+ }),
303
+ );
304
+ }
305
+
306
+ /** 点缩略图看大图:点任意处或按 Esc 关闭 */
307
+ export function openLightbox(src, name, download) {
308
+ const close = () => {
309
+ overlay.remove();
310
+ document.removeEventListener("keydown", onKey, true);
311
+ };
312
+ const onKey = (e) => {
313
+ if (e.key === "Escape") {
314
+ e.preventDefault();
315
+ close();
316
+ }
317
+ };
318
+ const overlay = h(
319
+ "div",
320
+ { class: "lightbox", onclick: close },
321
+ h("img", { src, alt: name ?? "" }),
322
+ h(
323
+ "div",
324
+ { class: "lightbox-bar", onclick: (e) => e.stopPropagation() },
325
+ name ? h("span", { class: "lightbox-name" }, name) : null,
326
+ download ? h("a", { class: "lightbox-dl", href: download, download: name ?? "" }, "下载") : null,
327
+ ),
328
+ );
329
+ document.body.append(overlay);
330
+ document.addEventListener("keydown", onKey, true);
331
+ }
332
+
333
+ function renderItem(item) {
334
+ switch (item.type) {
335
+ case "user": {
336
+ const origin = item.origin && item.origin.channel !== "web" ? `来自${item.origin.name ?? item.origin.channel}` : "";
337
+ return h(
338
+ "div",
339
+ { class: "msg-user-group" },
340
+ // 只发了附件、没有文字:「来自飞书」挂在附件上面
341
+ !item.text && origin ? h("span", { class: "origin" }, origin) : null,
342
+ renderUserFiles(item.files),
343
+ item.text ? h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text) : null,
344
+ item.text
345
+ ? h("div", { class: "user-actions" }, h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(item.text) }, icon("copy")))
346
+ : null,
347
+ );
348
+ }
349
+ case "ai":
350
+ return renderAi(item);
351
+ case "notice":
352
+ return h("div", { class: "notice" }, renderMarkdown(item.text));
353
+ case "error":
354
+ return h("div", { class: "error-box" }, item.text);
355
+ case "ix":
356
+ return renderInteraction(item);
357
+ }
358
+ return h("div");
359
+ }
360
+
361
+ /** 定时任务的 agent 判断这次不用提醒时,回复第一行写的暗号(见 core/task-prompt.ts) */
362
+ const SILENT_MARK = "[[静默]]";
363
+
364
+ function renderAi(item) {
365
+ const raw = item.committed + (item.partial ? (item.committed ? "\n\n" : "") + item.partial : "");
366
+ const silent = raw.trimStart().startsWith(SILENT_MARK);
367
+ const body = silent ? raw.trimStart().slice(SILENT_MARK.length).trim() : raw;
368
+ const working = !item.done;
369
+ const steps =
370
+ item.steps.length > 0
371
+ ? h(
372
+ "details",
373
+ {
374
+ class: "steps",
375
+ open: item.stepsOpen ?? false,
376
+ ontoggle: (e) => (item.stepsOpen = e.currentTarget.open),
377
+ },
378
+ h(
379
+ "summary",
380
+ {},
381
+ h(
382
+ "span",
383
+ { class: `steps-text${working ? " shimmer" : ""}` },
384
+ working
385
+ ? item.steps[item.steps.length - 1].split("\n")[0]
386
+ : `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
387
+ ),
388
+ h("span", { class: "chev" }, "›"),
389
+ ),
390
+ h("ol", {}, item.steps.map((s) => h("li", {}, s))),
391
+ )
392
+ : null;
393
+ return h(
394
+ "div",
395
+ { class: "msg-ai" },
396
+ steps,
397
+ // 定时任务这次没什么要说的:折叠成一行,展开能看到它顺手记的检查结果
398
+ silent && body
399
+ ? h("details", { class: "silent-run" }, h("summary", {}, "🔕 定时任务:这次无需提醒"), renderMarkdown(body))
400
+ : silent
401
+ ? h("div", { class: "silent-run" }, "🔕 定时任务:这次无需提醒")
402
+ : body
403
+ ? renderMarkdown(body)
404
+ : null,
405
+ // 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
406
+ working
407
+ ? h(
408
+ "div",
409
+ { class: "thinking" },
410
+ starsLoader(),
411
+ !body && item.steps.length === 0 ? h("span", { class: "shimmer" }, "思考中…") : null,
412
+ item.startAt
413
+ ? h("span", { class: "ai-elapsed", "data-start": item.startAt }, liveDuration(serverNow() - item.startAt))
414
+ : null,
415
+ )
416
+ : null,
417
+ item.error ? h("div", { class: "error-box" }, item.error) : null,
418
+ item.done && !item.segment && (body || item.footer)
419
+ ? h(
420
+ "div",
421
+ { class: "ai-actions" },
422
+ body ? h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(body) }, icon("copy")) : null,
423
+ item.anchor && S.convs.get(S.current)?.canFork
424
+ ? h("button", { class: "icon-btn", title: "从这里分叉成新对话", onclick: () => forkConversation(item.anchor) }, icon("fork"))
425
+ : null,
426
+ item.footer || item.at
427
+ ? h("span", { class: "footer-note" }, [item.at ? clockTime(item.at) : "", tidyFooter(item.footer)].filter(Boolean).join(" "))
428
+ : null,
429
+ )
430
+ : null,
431
+ );
432
+ }
433
+
434
+ /**
435
+ * 这一轮的开始时间(服务端时钟):从用户发出那条消息算起。中途插了授权 / 提问卡片、
436
+ * 回复拆成好几段时,后面的段接着这个时间算,显示的是整轮的总用时。
437
+ * 没有用户消息就开始的一轮(比如后台任务跑完 agent 自己接着干),从第一个事件算起。
438
+ */
439
+ let turnStartAt = null;
440
+
441
+ function currentAi(ts) {
442
+ if (!S.turnAi || S.turnAi.done) {
443
+ turnStartAt ??= ts ?? serverNow();
444
+ S.turnAi = addItem({ type: "ai", steps: [], committed: "", partial: "", done: false, startAt: turnStartAt });
445
+ }
446
+ return S.turnAi;
447
+ }
448
+
449
+ export function applyEvent(env) {
450
+ if (env.key !== S.current) return;
451
+ const last = S.seq.get(env.key) ?? 0;
452
+ if (env.seq <= last) return;
453
+ S.seq.set(env.key, env.seq);
454
+ const ev = env.ev;
455
+ switch (ev.type) {
456
+ case "user": {
457
+ if (S.turnAi && !S.turnAi.done && S.turnAi.steps.length === 0 && !S.turnAi.committed) {
458
+ // 上一个占位还空着(比如消息被当成了作答),不再多留一个
459
+ S.turnAi.el.remove();
460
+ S.items = S.items.filter((i) => i !== S.turnAi);
461
+ S.turnAi = null;
462
+ }
463
+ // 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
464
+ if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
465
+ const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
466
+ // 这个页面刚发的:顶到最上面
467
+ if (S.pinNext && ev.origin?.channel === "web") {
468
+ S.pinNext = false;
469
+ pinToTop(it.el);
470
+ }
471
+ currentAi(env.ts);
472
+ return;
473
+ }
474
+ case "step": {
475
+ const ai = currentAi(env.ts);
476
+ ai.steps.push(ev.text);
477
+ updateItem(ai);
478
+ return;
479
+ }
480
+ case "delta": {
481
+ const ai = currentAi(env.ts);
482
+ ai.partial += ev.text;
483
+ updateItem(ai);
484
+ return;
485
+ }
486
+ case "text": {
487
+ const ai = currentAi(env.ts);
488
+ ai.committed += (ai.committed ? "\n\n" : "") + ev.text;
489
+ ai.partial = "";
490
+ updateItem(ai);
491
+ return;
492
+ }
493
+ case "result": {
494
+ const ai = currentAi(env.ts);
495
+ if (!ai.committed && ev.text) ai.committed = ev.text;
496
+ ai.partial = "";
497
+ ai.done = true;
498
+ ai.footer = ev.footer;
499
+ ai.at = ev.at;
500
+ ai.anchor = ev.anchor;
501
+ updateItem(ai);
502
+ S.turnAi = null;
503
+ turnStartAt = null;
504
+ return;
505
+ }
506
+ case "error": {
507
+ if (S.turnAi && !S.turnAi.done) {
508
+ S.turnAi.done = true;
509
+ S.turnAi.error = ev.message;
510
+ updateItem(S.turnAi);
511
+ S.turnAi = null;
512
+ } else addItem({ type: "error", text: ev.message });
513
+ turnStartAt = null;
514
+ return;
515
+ }
516
+ case "notice":
517
+ addItem({ type: "notice", text: ev.text });
518
+ return;
519
+ case "state":
520
+ setBusy(ev.busy);
521
+ if (!ev.busy && S.turnAi && !S.turnAi.done) {
522
+ S.turnAi.done = true;
523
+ updateItem(S.turnAi);
524
+ S.turnAi = null;
525
+ }
526
+ if (!ev.busy) turnStartAt = null;
527
+ return;
528
+ case "meta":
529
+ return;
530
+ }
531
+ }
@@ -0,0 +1,145 @@
1
+ import { agentIcon, fill, h } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { relTime } from "../lib/time.js";
4
+ import { desktop, desktopQuota, S, ui } from "../state.js";
5
+
6
+ // ---------- 侧栏底部的账号额度 ----------
7
+ // 服务端不主动查:只有前端请求 /api/quota 时才去问各 agent,结果缓存 60 秒
8
+ // (60 秒内再请求直接返回缓存;Claude 要临时拉一个 CLI 进程才问得到,不能太勤)。
9
+ // 前端什么时候请求:
10
+ // - 打开页面拉一次;
11
+ // - 空闲时每隔 S.quotaRefreshMin 分钟(设置 → 通用,默认 10,0 = 不自动刷新),只在页面可见时;
12
+ // - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
13
+ // - 切回前台:距上次超过一个刷新间隔就拉;
14
+ // - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
15
+ // 设置 → 通用里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
16
+ // 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
17
+ // 查好推过来;点一下额度区域是请桌面端去刷新。
18
+
19
+ export const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
20
+ const QUOTA_SERVER_CACHE = 60_000;
21
+ const quotaInterval = () => (S.quotaEnabled ? (S.quotaRefreshMin ?? 10) * 60_000 : 0);
22
+
23
+ export async function loadQuota() {
24
+ if (!S.quotaEnabled) {
25
+ quotaState.data = null;
26
+ renderQuota();
27
+ return;
28
+ }
29
+ if (desktopQuota) {
30
+ ui.quota?.classList.add("loading");
31
+ desktop.refreshQuota();
32
+ return;
33
+ }
34
+ if (quotaState.loading) return;
35
+ quotaState.loading = true;
36
+ ui.quota?.classList.add("loading");
37
+ try {
38
+ const data = await api("/api/quota");
39
+ quotaState.data = data.quotas;
40
+ quotaState.at = Date.now();
41
+ desktop?.setQuota(data.quotas);
42
+ } catch {
43
+ // 保留旧数据
44
+ } finally {
45
+ quotaState.loading = false;
46
+ ui.quota?.classList.remove("loading");
47
+ renderQuota();
48
+ scheduleIdleQuota();
49
+ }
50
+ }
51
+
52
+ /** 桌面端推来的额度。查失败(error)时保留旧数据 */
53
+ export function applyDesktopQuota(payload) {
54
+ if (Array.isArray(payload?.quotas) && !payload.error) {
55
+ quotaState.data = payload.quotas;
56
+ quotaState.at = payload.at || Date.now();
57
+ }
58
+ ui.quota?.classList.remove("loading");
59
+ renderQuota();
60
+ }
61
+
62
+ /** 空闲定时:每次拉完重新计时,所以刚因为跑完一轮拉过,就不会紧接着又拉 */
63
+ export function scheduleIdleQuota() {
64
+ clearTimeout(quotaState.idle);
65
+ if (desktopQuota || !quotaInterval()) return;
66
+ quotaState.idle = setTimeout(() => {
67
+ // 页面不可见就先不拉,等切回来再说
68
+ if (document.visibilityState === "visible") void loadQuota();
69
+ }, quotaInterval());
70
+ }
71
+
72
+ /** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
73
+ export function scheduleQuota() {
74
+ // 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
75
+ if (desktopQuota || !S.quotaEnabled) return;
76
+ const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
77
+ clearTimeout(quotaState.timer);
78
+ quotaState.timer = setTimeout(() => void loadQuota(), wait);
79
+ }
80
+
81
+ document.addEventListener("visibilitychange", () => {
82
+ if (desktopQuota || document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
83
+ if (Date.now() - quotaState.at >= quotaInterval()) void loadQuota();
84
+ });
85
+
86
+ function untilText(ms) {
87
+ const sec = Math.floor((ms - Date.now()) / 1000);
88
+ if (sec <= 0) return "即将重置";
89
+ const d = Math.floor(sec / 86400);
90
+ const hh = Math.floor((sec % 86400) / 3600);
91
+ const mm = Math.floor((sec % 3600) / 60);
92
+ if (d > 0) return `${d} 天 ${hh} 小时后重置`;
93
+ if (hh > 0) return `${hh} 小时 ${mm} 分后重置`;
94
+ return `${Math.max(1, mm)} 分钟后重置`;
95
+ }
96
+
97
+ function quotaLevel(p) {
98
+ return p >= 90 ? "danger" : p >= 75 ? "warn" : "";
99
+ }
100
+
101
+ export function renderQuota() {
102
+ if (!ui.quota) return;
103
+ const list = S.quotaEnabled ? (quotaState.data ?? []) : [];
104
+ if (list.length === 0) return fill(ui.quota);
105
+ const rows = list.map((q) =>
106
+ h(
107
+ "div",
108
+ { class: `quota-row${q.reached ? " reached" : ""}` },
109
+ agentIcon(q.agent, "agent-icon quota-icon"),
110
+ q.windows.slice(0, 2).map((w) =>
111
+ h(
112
+ "span",
113
+ { class: `quota-cell ${quotaLevel(w.percent)}` },
114
+ h("span", { class: "q-label" }, w.short),
115
+ h("span", { class: "q-bar" }, h("i", { style: { width: `${Math.max(2, Math.min(100, w.percent))}%` } })),
116
+ h("span", { class: "q-pct" }, `${Math.round(w.percent)}%`),
117
+ ),
118
+ ),
119
+ ),
120
+ );
121
+ const tip = h(
122
+ "div",
123
+ { class: "quota-tip" },
124
+ list.map((q) =>
125
+ h(
126
+ "div",
127
+ { class: "qt-agent" },
128
+ h("div", { class: "qt-head" }, agentIcon(q.agent), h("b", {}, q.label), q.plan ? h("span", { class: "qt-plan" }, q.plan) : null),
129
+ q.windows.map((w) =>
130
+ h(
131
+ "div",
132
+ { class: "qt-row" },
133
+ h("span", { class: "qt-name" }, w.label),
134
+ h("span", { class: `qt-pct ${quotaLevel(w.percent)}` }, `已用 ${Math.round(w.percent)}%`),
135
+ w.resetsAt ? h("span", { class: "qt-reset" }, untilText(w.resetsAt)) : null,
136
+ ),
137
+ ),
138
+ q.extra ? h("div", { class: "qt-extra" }, q.extra) : null,
139
+ q.reached ? h("div", { class: "qt-extra danger" }, "已达上限,等窗口重置后才能继续") : null,
140
+ ),
141
+ ),
142
+ h("div", { class: "qt-foot" }, `${relTime(quotaState.at)}更新 · 点击刷新`),
143
+ );
144
+ fill(ui.quota, h("button", { class: "quota-btn", onclick: () => void loadQuota() }, rows), tip);
145
+ }