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/sidebar.js
CHANGED
|
@@ -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
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
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 (
|
|
357
|
-
projDirs = orderProjectDirs([...
|
|
358
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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:如果鼠标正停在某个标题上,重绘后接着滚
|
package/web/views/topbar.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
|
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
|