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.
- package/README.md +9 -3
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +80 -2
- package/src/agents/claude/session.ts +51 -7
- package/src/agents/codex/session.ts +43 -7
- package/src/channels/feishu/bot.ts +2 -2
- package/src/channels/web/api.ts +84 -2
- package/src/channels/web/conv-pages.ts +68 -0
- package/src/channels/web/server.ts +13 -4
- package/src/core/hub.ts +126 -19
- package/src/core/model-prices.json +103 -0
- package/src/core/pricing.ts +93 -0
- package/src/core/skill-runtime.ts +1 -1
- package/src/core/skills.ts +2 -1
- package/src/core/types.ts +24 -2
- package/src/core/usage.ts +144 -0
- package/web/css/composer.css +0 -25
- package/web/css/messages.css +169 -0
- package/web/css/settings.css +194 -0
- package/web/css/sidebar.css +5 -5
- package/web/css/skills.css +18 -1
- package/web/css/topbar.css +131 -0
- package/web/lib/dom.js +2 -1
- package/web/lib/markdown.js +84 -14
- package/web/lib/ws.js +46 -6
- package/web/settings/index.js +11 -4
- package/web/{views → settings}/skills.js +86 -172
- package/web/settings/usage.js +272 -0
- package/web/state.js +5 -4
- package/web/views/composer.js +2 -1
- package/web/views/interaction.js +12 -4
- package/web/views/layout.js +22 -25
- package/web/views/mention.js +2 -2
- package/web/views/messages.js +239 -81
- package/web/views/msg-nav.js +120 -0
- package/web/views/quota.js +1 -0
- package/web/views/sidebar.js +114 -17
- package/web/views/topbar.js +169 -41
package/web/views/messages.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { agentIcon, fill, h, icon, starsLoader } from "../lib/dom.js";
|
|
2
2
|
import { api } from "../lib/api.js";
|
|
3
3
|
import { clockTime, liveDuration, serverNow, setServerTime, tidyFooter } from "../lib/time.js";
|
|
4
|
-
import { copyText, renderMarkdown } from "../lib/markdown.js";
|
|
4
|
+
import { copyText, patchMarkdown, renderMarkdown } from "../lib/markdown.js";
|
|
5
5
|
import { S, ui } from "../state.js";
|
|
6
6
|
import { WS } from "../lib/ws.js";
|
|
7
7
|
import { agentLabel, convTitle, renderSidebar } from "./sidebar.js";
|
|
8
8
|
import { baseName, renderContext, renderTopbar, updateCtxVisibility, usableAgents } from "./topbar.js";
|
|
9
|
-
import { renderInteraction, upsertInteraction } from "./interaction.js";
|
|
9
|
+
import { insertCard, renderInteraction, upsertInteraction } from "./interaction.js";
|
|
10
10
|
import { renderChips, setBusy } from "./composer.js";
|
|
11
11
|
import { renderScheduled } from "./schedule.js";
|
|
12
|
-
import { changeCwd, forkConversation } from "./conv-actions.js";
|
|
12
|
+
import { changeCwd, doAction, forkConversation } from "./conv-actions.js";
|
|
13
|
+
import { scheduleNav } from "./msg-nav.js";
|
|
13
14
|
|
|
14
15
|
// ---------- 打开会话 ----------
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
+
/** 从定时任务页回到对话:把输入框放回来 */
|
|
17
18
|
function leaveTasksView() {
|
|
18
19
|
if (!S.view) return;
|
|
19
20
|
S.view = null;
|
|
@@ -39,6 +40,7 @@ export function newDraft() {
|
|
|
39
40
|
renderEmpty();
|
|
40
41
|
renderChips();
|
|
41
42
|
renderSidebar();
|
|
43
|
+
scheduleNav();
|
|
42
44
|
ui.input?.focus();
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -101,18 +103,35 @@ export async function openConversation(key) {
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
if (token !== S.loadToken) return; // 期间又切到别的会话了
|
|
106
|
+
// 侧栏分页:打开的可能是还没拉到的那条(通知、地址里带的),拿到了就补进侧栏
|
|
107
|
+
const known = S.convs.has(key);
|
|
104
108
|
S.convs.set(key, data.info);
|
|
109
|
+
if (!known) renderSidebar();
|
|
105
110
|
S.allowedAgents = data.allowedAgents;
|
|
106
111
|
if (Number.isFinite(data.serverTime)) setServerTime(data.serverTime);
|
|
107
112
|
fill(ui.msgInner, );
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
// 历史一次性加进来,最后再统一排版,免得每加一条就强制排版一次
|
|
114
|
+
bulk = true;
|
|
115
|
+
try {
|
|
116
|
+
for (const m of data.messages) {
|
|
117
|
+
if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
|
|
118
|
+
else if (m.role === "question") addItem({ type: "ix", id: m.question.id, ix: historyQuestion(key, m) });
|
|
119
|
+
else addItem({ type: "ai", steps: m.steps ?? [], committed: m.text, partial: "", done: true, footer: m.footer, at: m.ts, anchor: m.anchor, segment: m.segment });
|
|
120
|
+
}
|
|
121
|
+
S.seq.set(key, 0);
|
|
122
|
+
// 进行中这一轮弹过的卡(含已处理的):重放到 ix 事件时放回原位
|
|
123
|
+
replayCards = new Map((data.interactions ?? []).map((ix) => [ix.id, ix]));
|
|
124
|
+
for (const env of data.live) applyEvent(env);
|
|
125
|
+
replayCards = new Map();
|
|
126
|
+
S.seq.set(key, Math.max(S.seq.get(key) ?? 0, data.seq));
|
|
127
|
+
for (const ix of data.pending) upsertInteraction(ix);
|
|
128
|
+
} finally {
|
|
129
|
+
bulk = false;
|
|
111
130
|
}
|
|
112
|
-
S.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
clampLongUsers(S.items);
|
|
132
|
+
markLatestAi();
|
|
133
|
+
updateCtxVisibility();
|
|
134
|
+
scheduleNav();
|
|
116
135
|
if (S.items.length === 0) {
|
|
117
136
|
ui.msgInner.append(
|
|
118
137
|
h(
|
|
@@ -130,6 +149,22 @@ export async function openConversation(key) {
|
|
|
130
149
|
scrollToBottom();
|
|
131
150
|
}
|
|
132
151
|
|
|
152
|
+
/** 打开会话、重放进行中这一轮时,ix 事件对应的卡片内容(见 openConversation) */
|
|
153
|
+
let replayCards = new Map();
|
|
154
|
+
|
|
155
|
+
/** 会话记录里还原出来的提问(问了什么、你怎么答的)→ 画成一张已处理的提问卡 */
|
|
156
|
+
function historyQuestion(key, m) {
|
|
157
|
+
const q = m.question;
|
|
158
|
+
return {
|
|
159
|
+
id: q.id,
|
|
160
|
+
convKey: key,
|
|
161
|
+
spec: { kind: "question", title: q.title, questions: q.questions },
|
|
162
|
+
selections: q.selections,
|
|
163
|
+
freeText: q.freeText,
|
|
164
|
+
settled: { settlement: q.skipped ? "skipped" : "answered", by: "", at: m.ts ?? 0 },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
133
168
|
// ---------- 消息渲染 ----------
|
|
134
169
|
|
|
135
170
|
// 滚动规则(和 ChatGPT 一样):
|
|
@@ -183,8 +218,8 @@ function pinToTop(el) {
|
|
|
183
218
|
updateToBottomBtn();
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
/**
|
|
187
|
-
function userScrolled() {
|
|
221
|
+
/** 用户自己动了滚动(或者点了快速定位、展开了长消息):停止自动滚动 */
|
|
222
|
+
export function userScrolled() {
|
|
188
223
|
ui.follow = false;
|
|
189
224
|
ui.smoothUntil = 0;
|
|
190
225
|
}
|
|
@@ -222,11 +257,19 @@ function markLatestAi() {
|
|
|
222
257
|
for (const it of S.items) if (it.type === "ai") it.el?.classList.toggle("latest", it === latest);
|
|
223
258
|
}
|
|
224
259
|
|
|
260
|
+
/** 正在批量加历史:addItem 先不排版,加完由 openConversation 统一做 */
|
|
261
|
+
let bulk = false;
|
|
262
|
+
|
|
225
263
|
export function addItem(item) {
|
|
226
264
|
ui.msgInner.querySelector(".empty")?.remove();
|
|
227
265
|
item.el = renderItem(item);
|
|
228
266
|
ui.msgInner.append(item.el);
|
|
229
267
|
S.items.push(item);
|
|
268
|
+
if (bulk) return item;
|
|
269
|
+
if (item.type === "user") {
|
|
270
|
+
clampLongUsers([item]);
|
|
271
|
+
scheduleNav();
|
|
272
|
+
}
|
|
230
273
|
markLatestAi();
|
|
231
274
|
updateCtxVisibility();
|
|
232
275
|
afterLayout();
|
|
@@ -243,6 +286,11 @@ export function updateItem(item) {
|
|
|
243
286
|
flushScheduled = false;
|
|
244
287
|
for (const it of dirty) {
|
|
245
288
|
if (!it.el?.isConnected) continue;
|
|
289
|
+
// 回复原地更新;其它(交互卡片等)不常变,整个重建
|
|
290
|
+
if (it.type === "ai") {
|
|
291
|
+
patchAi(it);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
246
294
|
const next = renderItem(it);
|
|
247
295
|
it.el.replaceWith(next);
|
|
248
296
|
it.el = next;
|
|
@@ -330,6 +378,59 @@ export function openLightbox(src, name, download) {
|
|
|
330
378
|
document.addEventListener("keydown", onKey, true);
|
|
331
379
|
}
|
|
332
380
|
|
|
381
|
+
/** 用户消息超过这个高度(约 10 行)就先收起来,像 ChatGPT;只多出一两行的不收 */
|
|
382
|
+
const USER_CLAMP = 260;
|
|
383
|
+
|
|
384
|
+
/** 太长的用户消息收起来,下面放「展开显示」。先把高度都量完再统一改,只排版一次 */
|
|
385
|
+
function clampLongUsers(items) {
|
|
386
|
+
const long = items.filter((it) => {
|
|
387
|
+
if (it.type !== "user" || !it.bubble || it.clampChecked) return false;
|
|
388
|
+
it.clampChecked = true;
|
|
389
|
+
return it.bubble.scrollHeight > USER_CLAMP + 60;
|
|
390
|
+
});
|
|
391
|
+
for (const it of long) {
|
|
392
|
+
const bubble = it.bubble;
|
|
393
|
+
bubble.classList.add("clamped");
|
|
394
|
+
const btn = h(
|
|
395
|
+
"button",
|
|
396
|
+
{
|
|
397
|
+
class: "msg-more",
|
|
398
|
+
onclick: () => {
|
|
399
|
+
// 自己展开 / 收起:别被自动滚动拉回底部
|
|
400
|
+
userScrolled();
|
|
401
|
+
const open = !bubble.classList.toggle("clamped");
|
|
402
|
+
btn.textContent = open ? "收起" : "展开显示";
|
|
403
|
+
// 收起后按钮可能跑到视口上面去了:拉回来
|
|
404
|
+
if (!open) btn.scrollIntoView({ block: "nearest" });
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
"展开显示",
|
|
408
|
+
);
|
|
409
|
+
bubble.after(btn);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 排队中的消息下面:「排队中 · 打断并发送」;停止、会话切换后没发出去的:「没有发出」 */
|
|
414
|
+
function queueNote(item, state) {
|
|
415
|
+
if (state === "dropped") return h("div", { class: "queue-note dropped" }, "没有发出");
|
|
416
|
+
return h(
|
|
417
|
+
"div",
|
|
418
|
+
{ class: "queue-note" },
|
|
419
|
+
h("span", { class: "shimmer" }, "排队中,等上一条回复完"),
|
|
420
|
+
h("button", { class: "queue-jump", title: "停掉正在进行的回复,马上处理这条", onclick: () => void doAction("jump", item.id) }, "打断并发送"),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** state:queued / dropped / null(轮到它了,去掉标记) */
|
|
425
|
+
function setQueueState(item, state) {
|
|
426
|
+
item.queued = state === "queued";
|
|
427
|
+
const next = state ? queueNote(item, state) : null;
|
|
428
|
+
if (item.queueEl && next) item.queueEl.replaceWith(next);
|
|
429
|
+
else if (item.queueEl) item.queueEl.remove();
|
|
430
|
+
else if (next) item.el.insertBefore(next, item.el.querySelector(".user-actions"));
|
|
431
|
+
item.queueEl = next;
|
|
432
|
+
}
|
|
433
|
+
|
|
333
434
|
function renderItem(item) {
|
|
334
435
|
switch (item.type) {
|
|
335
436
|
case "user": {
|
|
@@ -340,7 +441,8 @@ function renderItem(item) {
|
|
|
340
441
|
// 只发了附件、没有文字:「来自飞书」挂在附件上面
|
|
341
442
|
!item.text && origin ? h("span", { class: "origin" }, origin) : null,
|
|
342
443
|
renderUserFiles(item.files),
|
|
343
|
-
item.text ? h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text) : null,
|
|
444
|
+
item.text ? (item.bubble = h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text)) : null,
|
|
445
|
+
(item.queueEl = item.queued ? queueNote(item, "queued") : null),
|
|
344
446
|
item.text
|
|
345
447
|
? h("div", { class: "user-actions" }, h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(item.text) }, icon("copy")))
|
|
346
448
|
: null,
|
|
@@ -361,74 +463,113 @@ function renderItem(item) {
|
|
|
361
463
|
/** 定时任务的 agent 判断这次不用提醒时,回复第一行写的暗号(见 core/task-prompt.ts) */
|
|
362
464
|
const SILENT_MARK = "[[静默]]";
|
|
363
465
|
|
|
466
|
+
/**
|
|
467
|
+
* 回复流式输出时每帧都会更新:不整条重建,只改变了的部分(item.v 记着各部分的元素)。
|
|
468
|
+
* 前面已经输出完的 markdown 块、步骤列表、星星动画都原地不动,选中的文字和滚动位置也就不会丢。
|
|
469
|
+
*/
|
|
364
470
|
function renderAi(item) {
|
|
471
|
+
item.v = {};
|
|
472
|
+
item.el = h("div", { class: "msg-ai" });
|
|
473
|
+
patchAi(item);
|
|
474
|
+
return item.el;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function patchAi(item) {
|
|
478
|
+
const v = item.v;
|
|
365
479
|
const raw = item.committed + (item.partial ? (item.committed ? "\n\n" : "") + item.partial : "");
|
|
366
480
|
const silent = raw.trimStart().startsWith(SILENT_MARK);
|
|
367
481
|
const body = silent ? raw.trimStart().slice(SILENT_MARK.length).trim() : raw;
|
|
368
482
|
const working = !item.done;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
?
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
483
|
+
|
|
484
|
+
if (item.steps.length > 0) {
|
|
485
|
+
if (!v.steps) {
|
|
486
|
+
v.stepsText = h("span", { class: "steps-text" });
|
|
487
|
+
v.stepsList = h("ol", {});
|
|
488
|
+
v.steps = h(
|
|
489
|
+
"details",
|
|
490
|
+
{ class: "steps", open: item.stepsOpen ?? false, ontoggle: (e) => (item.stepsOpen = e.currentTarget.open) },
|
|
491
|
+
h("summary", {}, v.stepsText, h("span", { class: "chev" }, "›")),
|
|
492
|
+
v.stepsList,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
setText(
|
|
496
|
+
v.stepsText,
|
|
497
|
+
working
|
|
498
|
+
? item.steps[item.steps.length - 1].split("\n")[0]
|
|
499
|
+
: `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
|
|
500
|
+
);
|
|
501
|
+
v.stepsText.classList.toggle("shimmer", working);
|
|
502
|
+
// 步骤只会往后加,补上新的就行
|
|
503
|
+
const ol = v.stepsList;
|
|
504
|
+
if (ol.childElementCount > item.steps.length) ol.replaceChildren();
|
|
505
|
+
for (let i = ol.childElementCount; i < item.steps.length; i++) ol.append(h("li", {}, item.steps[i]));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 定时任务这次没什么要说的:折叠成一行,展开能看到它顺手记的检查结果
|
|
509
|
+
const bodyKey = silent ? `silent\n${body}` : body ? "md" : "";
|
|
510
|
+
if (v.bodyKey !== bodyKey) {
|
|
511
|
+
v.bodyKey = bodyKey;
|
|
512
|
+
v.body = silent
|
|
513
|
+
? body
|
|
514
|
+
? h("details", { class: "silent-run" }, h("summary", {}, "🔕 定时任务:这次无需提醒"), renderMarkdown(body))
|
|
515
|
+
: h("div", { class: "silent-run" }, "🔕 定时任务:这次无需提醒")
|
|
516
|
+
: body
|
|
517
|
+
? h("div", { class: "md" })
|
|
518
|
+
: null;
|
|
519
|
+
}
|
|
520
|
+
if (bodyKey === "md") patchMarkdown(v.body, body, { live: working });
|
|
521
|
+
|
|
522
|
+
// 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
|
|
523
|
+
if (working) {
|
|
524
|
+
if (!v.thinking) {
|
|
525
|
+
v.hint = h("span", { class: "shimmer" }, "思考中…");
|
|
526
|
+
v.thinking = h(
|
|
527
|
+
"div",
|
|
528
|
+
{ class: "thinking" },
|
|
529
|
+
starsLoader(),
|
|
530
|
+
item.startAt ? h("span", { class: "ai-elapsed", "data-start": item.startAt }, liveDuration(serverNow() - item.startAt)) : null,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const hint = !body && item.steps.length === 0;
|
|
534
|
+
if (hint && !v.hint.isConnected) v.thinking.firstChild.after(v.hint);
|
|
535
|
+
else if (!hint) v.hint.remove();
|
|
536
|
+
} else v.thinking = null;
|
|
537
|
+
|
|
538
|
+
if (!item.error) v.error = null;
|
|
539
|
+
else if (v.error?.textContent !== item.error) v.error = h("div", { class: "error-box" }, item.error);
|
|
540
|
+
|
|
541
|
+
placeChildren(item.el, [v.steps, v.body, v.thinking, v.error, renderAiActions(item, body)]);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function setText(el, text) {
|
|
545
|
+
if (el.textContent !== text) el.textContent = text;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** 按顺序放好子元素:不要的删掉,缺的插进去,已经在的不挪(挪动会让动画重播、滚动位置复位) */
|
|
549
|
+
function placeChildren(parent, nodes) {
|
|
550
|
+
const want = new Set(nodes.filter(Boolean));
|
|
551
|
+
for (const c of [...parent.childNodes]) if (!want.has(c)) c.remove();
|
|
552
|
+
let ref = parent.firstChild;
|
|
553
|
+
for (const n of want) {
|
|
554
|
+
if (n === ref) ref = ref.nextSibling;
|
|
555
|
+
else parent.insertBefore(n, ref);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function renderAiActions(item, body) {
|
|
560
|
+
return item.done && !item.segment && (body || item.footer)
|
|
561
|
+
? h(
|
|
562
|
+
"div",
|
|
563
|
+
{ class: "ai-actions" },
|
|
564
|
+
body ? h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(body) }, icon("copy")) : null,
|
|
565
|
+
item.anchor && S.convs.get(S.current)?.canFork
|
|
566
|
+
? h("button", { class: "icon-btn", title: "从这里分叉成新对话", onclick: () => forkConversation(item.anchor) }, icon("fork"))
|
|
404
567
|
: null,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
);
|
|
568
|
+
item.footer || item.at
|
|
569
|
+
? h("span", { class: "footer-note" }, [item.at ? clockTime(item.at) : "", tidyFooter(item.footer)].filter(Boolean).join(" "))
|
|
570
|
+
: null,
|
|
571
|
+
)
|
|
572
|
+
: null;
|
|
432
573
|
}
|
|
433
574
|
|
|
434
575
|
/**
|
|
@@ -454,21 +595,38 @@ export function applyEvent(env) {
|
|
|
454
595
|
const ev = env.ev;
|
|
455
596
|
switch (ev.type) {
|
|
456
597
|
case "user": {
|
|
457
|
-
|
|
598
|
+
// 排队的消息:正在进行的那一轮照常在上面接着输出,这条等轮到它(queue 事件)再挂占位
|
|
599
|
+
const queued = !!ev.queued;
|
|
600
|
+
if (!queued && S.turnAi && !S.turnAi.done && S.turnAi.steps.length === 0 && !S.turnAi.committed) {
|
|
458
601
|
// 上一个占位还空着(比如消息被当成了作答),不再多留一个
|
|
459
602
|
S.turnAi.el.remove();
|
|
460
603
|
S.items = S.items.filter((i) => i !== S.turnAi);
|
|
461
604
|
S.turnAi = null;
|
|
462
605
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
606
|
+
if (!queued && (!S.turnAi || S.turnAi.done)) turnStartAt = env.ts;
|
|
607
|
+
const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin, id: ev.id, queued });
|
|
466
608
|
// 这个页面刚发的:顶到最上面
|
|
467
609
|
if (S.pinNext && ev.origin?.channel === "web") {
|
|
468
610
|
S.pinNext = false;
|
|
469
611
|
pinToTop(it.el);
|
|
470
612
|
}
|
|
471
|
-
currentAi(env.ts);
|
|
613
|
+
if (!queued) currentAi(env.ts);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
case "ix": {
|
|
617
|
+
// 这里弹过一张卡:实时的由 WS 的 ix 消息放(upsertInteraction),这里只管重放时把它放回原位
|
|
618
|
+
const ix = S.pending.get(ev.id) ?? replayCards.get(ev.id);
|
|
619
|
+
if (ix) insertCard(ix);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
case "queue": {
|
|
623
|
+
const it = S.items.find((i) => i.type === "user" && i.id === ev.id);
|
|
624
|
+
if (it) setQueueState(it, ev.state === "dropped" ? "dropped" : null);
|
|
625
|
+
// 轮到它了(上一轮刚出结果):挂上「思考中」占位,用时从现在算。被当成提问的回答时那一轮还在跑,不用挂
|
|
626
|
+
if (ev.state === "started" && (!S.turnAi || S.turnAi.done)) {
|
|
627
|
+
turnStartAt = env.ts;
|
|
628
|
+
currentAi(env.ts);
|
|
629
|
+
}
|
|
472
630
|
return;
|
|
473
631
|
}
|
|
474
632
|
case "step": {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { h } from "../lib/dom.js";
|
|
2
|
+
import { S, ui } from "../state.js";
|
|
3
|
+
import { userScrolled } from "./messages.js";
|
|
4
|
+
|
|
5
|
+
// ---------- 消息区右边的快速定位(像网页版 ChatGPT) ----------
|
|
6
|
+
// 每条用户消息一道短横线,竖着排在滚动条旁边,正看到的那条高亮;平时淡淡的,滚动时清楚些。
|
|
7
|
+
// 鼠标移上去展开成列表,显示每条消息的开头,点一下跳过去。用户消息少于 3 条时不显示;窄屏(手机)不显示。
|
|
8
|
+
|
|
9
|
+
const MIN_USERS = 3;
|
|
10
|
+
let entries = [];
|
|
11
|
+
let activeIdx = -1;
|
|
12
|
+
let scheduled = false;
|
|
13
|
+
let scrollRaf = 0;
|
|
14
|
+
let scrollingTimer = 0;
|
|
15
|
+
|
|
16
|
+
export function createNav() {
|
|
17
|
+
ui.navTicks = h("div", { class: "msg-nav-ticks" });
|
|
18
|
+
ui.navPanel = h("div", { class: "msg-nav-panel" });
|
|
19
|
+
ui.nav = h("nav", { class: "msg-nav", hidden: true, "aria-label": "消息快速定位", onpointerenter: revealActiveRow }, ui.navTicks, ui.navPanel);
|
|
20
|
+
ui.messages.addEventListener("scroll", onScroll, { passive: true });
|
|
21
|
+
return ui.nav;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 用户消息变了(发了一条、打开别的会话):下一帧重建 */
|
|
25
|
+
export function scheduleNav() {
|
|
26
|
+
if (scheduled) return;
|
|
27
|
+
scheduled = true;
|
|
28
|
+
requestAnimationFrame(() => {
|
|
29
|
+
scheduled = false;
|
|
30
|
+
build();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function snippet(item) {
|
|
35
|
+
const text = (item.text ?? "").replace(/\s+/g, " ").trim();
|
|
36
|
+
if (text) return text.length > 60 ? `${text.slice(0, 60)}…` : text;
|
|
37
|
+
const names = (item.files ?? []).map((f) => (typeof f === "string" ? f : f.name));
|
|
38
|
+
return names.length ? `[附件] ${names.join("、")}` : "(空消息)";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function build() {
|
|
42
|
+
if (!ui.nav) return;
|
|
43
|
+
const users = S.view ? [] : S.items.filter((it) => it.type === "user" && it.el?.isConnected);
|
|
44
|
+
ui.nav.hidden = users.length < MIN_USERS;
|
|
45
|
+
activeIdx = -1;
|
|
46
|
+
if (ui.nav.hidden) {
|
|
47
|
+
entries = [];
|
|
48
|
+
ui.navTicks.replaceChildren();
|
|
49
|
+
ui.navPanel.replaceChildren();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
entries = users.map((item, i) => {
|
|
53
|
+
const label = snippet(item);
|
|
54
|
+
return {
|
|
55
|
+
item,
|
|
56
|
+
tick: h("button", { class: "msg-nav-tick", "aria-label": `第 ${i + 1} 条:${label}`, onclick: () => jump(item) }, h("i")),
|
|
57
|
+
row: h("button", { class: "msg-nav-row", onclick: () => jump(item) }, label),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
ui.navTicks.replaceChildren(...entries.map((e) => e.tick));
|
|
61
|
+
ui.navPanel.replaceChildren(...entries.map((e) => e.row));
|
|
62
|
+
updateActive();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 顶栏叠在消息区上面:跳过去时让出它的高度 */
|
|
66
|
+
function covered() {
|
|
67
|
+
return Math.max(0, ui.topbar.getBoundingClientRect().bottom - ui.messages.getBoundingClientRect().top);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function jump(item) {
|
|
71
|
+
if (!item.el?.isConnected) return;
|
|
72
|
+
userScrolled();
|
|
73
|
+
ui.messages.scrollTo({ top: Math.max(0, item.el.offsetTop - covered() - 12), behavior: "smooth" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onScroll() {
|
|
77
|
+
if (!entries.length) return;
|
|
78
|
+
ui.nav.classList.add("scrolling");
|
|
79
|
+
clearTimeout(scrollingTimer);
|
|
80
|
+
scrollingTimer = setTimeout(() => ui.nav.classList.remove("scrolling"), 1200);
|
|
81
|
+
if (scrollRaf) return;
|
|
82
|
+
scrollRaf = requestAnimationFrame(() => {
|
|
83
|
+
scrollRaf = 0;
|
|
84
|
+
updateActive();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 正看到的:顶部往下三分之一处以上、最后一条用户消息;已经滚到底就是最后一条 */
|
|
89
|
+
function updateActive() {
|
|
90
|
+
const m = ui.messages;
|
|
91
|
+
const line = m.scrollTop + covered() + m.clientHeight * 0.3;
|
|
92
|
+
let idx = 0;
|
|
93
|
+
if (m.scrollHeight - m.scrollTop - m.clientHeight < 4) idx = entries.length - 1;
|
|
94
|
+
else {
|
|
95
|
+
for (let i = 0; i < entries.length; i++) {
|
|
96
|
+
if (entries[i].item.el.offsetTop <= line) idx = i;
|
|
97
|
+
else break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (idx === activeIdx) return;
|
|
101
|
+
entries[activeIdx]?.tick.classList.remove("on");
|
|
102
|
+
entries[activeIdx]?.row.classList.remove("on");
|
|
103
|
+
activeIdx = idx;
|
|
104
|
+
entries[idx].tick.classList.add("on");
|
|
105
|
+
entries[idx].row.classList.add("on");
|
|
106
|
+
keepVisible(ui.navTicks, entries[idx].tick);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 横线太多、竖条装不下时它自己能滚:让高亮的那道留在看得见的地方(不用 scrollIntoView,免得把外层也带着滚) */
|
|
110
|
+
function keepVisible(box, el) {
|
|
111
|
+
const top = el.offsetTop - box.offsetTop;
|
|
112
|
+
if (top < box.scrollTop) box.scrollTop = top;
|
|
113
|
+
else if (top + el.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + el.offsetHeight - box.clientHeight;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 展开列表时:列表滚到正看到的那条 */
|
|
117
|
+
function revealActiveRow() {
|
|
118
|
+
const row = entries[activeIdx]?.row;
|
|
119
|
+
if (row) ui.navPanel.scrollTop = Math.max(0, row.offsetTop - ui.navPanel.clientHeight / 2);
|
|
120
|
+
}
|
package/web/views/quota.js
CHANGED
|
@@ -101,6 +101,7 @@ function quotaLevel(p) {
|
|
|
101
101
|
export function renderQuota() {
|
|
102
102
|
if (!ui.quota) return;
|
|
103
103
|
const list = S.quotaEnabled ? (quotaState.data ?? []) : [];
|
|
104
|
+
if (ui.sideBottom) ui.sideBottom.hidden = list.length === 0;
|
|
104
105
|
if (list.length === 0) return fill(ui.quota);
|
|
105
106
|
const rows = list.map((q) =>
|
|
106
107
|
h(
|