agent-loft 0.2.31 → 0.2.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +90 -8
- package/src/agents/claude/session.ts +81 -12
- package/src/agents/codex/session.ts +70 -13
- package/src/channels/feishu/bot.ts +2 -2
- package/src/channels/web/api.ts +72 -2
- package/src/channels/web/conv-pages.ts +68 -0
- package/src/channels/web/http.ts +2 -2
- package/src/channels/web/server.ts +20 -3
- package/src/channels/web/skills-api.ts +225 -0
- package/src/core/chat-mode.ts +3 -3
- package/src/core/command-catalog.ts +8 -1
- package/src/core/commands.ts +3 -0
- package/src/core/hub.ts +168 -18
- package/src/core/personal.ts +3 -1
- package/src/core/skill-runtime.ts +359 -0
- package/src/core/skills-bridge.ts +288 -0
- package/src/core/skills.ts +676 -0
- package/src/core/tasks-bridge.ts +5 -1
- package/src/core/tasks-mcp.ts +11 -0
- package/src/core/types.ts +44 -3
- package/src/core/usage.ts +70 -0
- package/src/core/zip.ts +98 -0
- package/src/main.ts +4 -0
- package/src/mcp/tasks-server.ts +125 -4
- package/web/css/composer.css +0 -25
- package/web/css/messages.css +169 -0
- package/web/css/sidebar.css +5 -5
- package/web/css/skills.css +385 -0
- package/web/css/topbar.css +131 -0
- package/web/index.html +1 -0
- package/web/lib/dom.js +3 -1
- package/web/lib/markdown.js +84 -14
- package/web/lib/ws.js +49 -5
- package/web/settings/index.js +7 -3
- package/web/settings/skills.js +617 -0
- package/web/state.js +4 -3
- package/web/views/composer.js +16 -3
- package/web/views/interaction.js +12 -4
- package/web/views/layout.js +22 -15
- package/web/views/mention.js +193 -0
- 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 -16
- package/web/views/tasks.js +11 -2
- package/web/views/topbar.js +168 -40
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() {
|
|
@@ -281,9 +371,6 @@ export function renderSidebar() {
|
|
|
281
371
|
closeSide();
|
|
282
372
|
if (key !== S.current) void openConversation(key);
|
|
283
373
|
};
|
|
284
|
-
const more = (list, id, expanded) =>
|
|
285
|
-
h("button", { class: "conv-more", onclick: () => toggleSide(list, id) }, expanded ? "收起" : "展开显示");
|
|
286
|
-
|
|
287
374
|
// 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
|
|
288
375
|
const convRow = (c, cls, detail, orphan) =>
|
|
289
376
|
h(
|
|
@@ -297,6 +384,8 @@ export function renderSidebar() {
|
|
|
297
384
|
orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
|
|
298
385
|
// 定时任务的专用对话
|
|
299
386
|
c.taskId ? h("span", { class: "brand-icon lead task-mark", title: "定时任务的对话", html: ICON.clock }) : null,
|
|
387
|
+
// 聊天模式的对话
|
|
388
|
+
c.mode === "chat" ? h("span", { class: "brand-icon lead chat-mark", title: "聊天模式", html: ICON.chat }) : null,
|
|
300
389
|
h("span", { class: "title" }, convTitle(c)),
|
|
301
390
|
status([c.key]),
|
|
302
391
|
);
|
|
@@ -328,13 +417,15 @@ export function renderSidebar() {
|
|
|
328
417
|
});
|
|
329
418
|
}
|
|
330
419
|
recent.sort((a, b) => b.at - a.at);
|
|
331
|
-
const recentAll = sideState.more.includes("@recent");
|
|
332
420
|
const recentFolded = sideState.folded.includes("@recent");
|
|
333
421
|
const nodes = [groupHead("最近", "@recent", recentFolded)];
|
|
334
422
|
if (!recentFolded) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
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);
|
|
338
429
|
}
|
|
339
430
|
|
|
340
431
|
// ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
|
|
@@ -351,10 +442,13 @@ export function renderSidebar() {
|
|
|
351
442
|
localStorage.setItem("aim.side", JSON.stringify(sideState));
|
|
352
443
|
}
|
|
353
444
|
}
|
|
445
|
+
// 目录行要全部显示:服务端给了每个目录最近的活跃时间,加上本地已有的(刚建的、刚变活跃的)
|
|
446
|
+
const dirAt = new Map(pages.dirAt);
|
|
447
|
+
for (const [dir, convs] of byDir) dirAt.set(dir, Math.max(dirAt.get(dir) ?? 0, convs[0].activeAt));
|
|
354
448
|
const projFolded = sideState.folded.includes("@projects");
|
|
355
|
-
if (
|
|
356
|
-
projDirs = orderProjectDirs([...
|
|
357
|
-
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) {
|
|
358
452
|
const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
|
|
359
453
|
const endDrag = () => {
|
|
360
454
|
dirDrag = null;
|
|
@@ -362,9 +456,8 @@ export function renderSidebar() {
|
|
|
362
456
|
if (sidebarStale) renderSidebar();
|
|
363
457
|
};
|
|
364
458
|
for (const dir of projDirs) {
|
|
365
|
-
const convs = byDir.get(dir);
|
|
459
|
+
const convs = byDir.get(dir) ?? [];
|
|
366
460
|
const expanded = sideState.open.includes(dir);
|
|
367
|
-
const showAll = sideState.more.includes(dir);
|
|
368
461
|
// 鼠标在目标行的上半截就插到它前面,下半截插到后面
|
|
369
462
|
const isAfter = (e, row) => {
|
|
370
463
|
const r = row.getBoundingClientRect();
|
|
@@ -435,12 +528,17 @@ export function renderSidebar() {
|
|
|
435
528
|
),
|
|
436
529
|
);
|
|
437
530
|
if (!expanded) continue;
|
|
531
|
+
// 刚展开、还没拉过这个目录:去拉第一页(本地可能已经有几条正在跑的,先显示着)
|
|
532
|
+
const st = dirState(dir);
|
|
533
|
+
const load = (limit) => fetchPage(st, { scope: "dir", dir, limit: String(limit) });
|
|
534
|
+
if (st.next === undefined && !st.loading && !st.failed) void load(st.show);
|
|
438
535
|
// 「展开显示」按主对话算条数,分叉跟着主对话一起出现
|
|
439
|
-
const forest = forkForest(convs);
|
|
440
|
-
for (const node of forest.slice(0,
|
|
536
|
+
const forest = loadedPrefix(st, forkForest(convs));
|
|
537
|
+
for (const node of forest.slice(0, st.show)) {
|
|
441
538
|
nodes.push(renderForkNode(node, (c, orphan) => convRow(c, " sub", agentLabel(c.agent), orphan), " in-proj"));
|
|
442
539
|
}
|
|
443
|
-
|
|
540
|
+
const btn = moreButton(st, forest.length, PROJECT_LIMIT, load);
|
|
541
|
+
if (btn) nodes.push(h("div", { class: "sub-more" }, btn));
|
|
444
542
|
}
|
|
445
543
|
}
|
|
446
544
|
// 重绘会换掉 DOM:如果鼠标正停在某个标题上,重绘后接着滚
|
package/web/views/tasks.js
CHANGED
|
@@ -8,6 +8,7 @@ import { agentLabel, closeSide, renderSidebar } from "./sidebar.js";
|
|
|
8
8
|
import { baseName, renderTopbar, usableAgents } from "./topbar.js";
|
|
9
9
|
import { openConversation } from "./messages.js";
|
|
10
10
|
import { changeCwd } from "./conv-actions.js";
|
|
11
|
+
import { attachMention } from "./mention.js";
|
|
11
12
|
|
|
12
13
|
// ---------- 定时任务页 ----------
|
|
13
14
|
// 到点让 agent 按提示词干活,结果回到一条对话里;「有需要才提醒」时由 agent 判断这次要不要打扰你。
|
|
@@ -281,8 +282,16 @@ export function openTaskForm(task, preset) {
|
|
|
281
282
|
const src = task ?? preset ?? {};
|
|
282
283
|
const agents = usableAgents();
|
|
283
284
|
const title = h("input", { value: src.title ?? "", maxlength: "60", placeholder: "比如:检查 agent-loft 更新" });
|
|
284
|
-
const prompt = h("textarea", { rows: 5, maxlength: "4000", placeholder: "到点让 agent
|
|
285
|
+
const prompt = h("textarea", { rows: 5, maxlength: "4000", placeholder: "到点让 agent 做什么。写成完整的指令:那时你多半不在,它没法再问你。可以用 @ 指定技能。" });
|
|
285
286
|
prompt.value = src.prompt ?? "";
|
|
287
|
+
// @ 技能:和输入框一样,到点时服务端认提示词里的 @name
|
|
288
|
+
const mentionMenu = h("div", { class: "slash-menu below", hidden: true });
|
|
289
|
+
const mention = attachMention(prompt, mentionMenu);
|
|
290
|
+
prompt.addEventListener("input", () => void mention.update());
|
|
291
|
+
prompt.addEventListener("blur", () => setTimeout(mention.close, 150));
|
|
292
|
+
prompt.addEventListener("keydown", (e) => {
|
|
293
|
+
if (mention.keydown(e)) e.preventDefault();
|
|
294
|
+
});
|
|
286
295
|
|
|
287
296
|
// ---- 频率 ----
|
|
288
297
|
const sch = { kind: "daily", time: "09:00", days: [1], day: 1, every: 2, minute: 0, expr: "0 9 * * *", at: Date.now() + 3600_000, ...(src.schedule ?? {}) };
|
|
@@ -405,7 +414,7 @@ export function openTaskForm(task, preset) {
|
|
|
405
414
|
"div",
|
|
406
415
|
{ class: "modal-body task-form" },
|
|
407
416
|
field("名称", title),
|
|
408
|
-
field("要做的事", prompt, "
|
|
417
|
+
field("要做的事", h("div", { class: "mention-wrap" }, prompt, mentionMenu), "需要对比的写清楚怎么判断,比如「和上次的结果比」「版本号比当前的新才算更新」。输入 @ 可以指定这次要用的技能。"),
|
|
409
418
|
field("频率", h("div", { class: "task-freq-row" }, freq, freqBox), freqHint),
|
|
410
419
|
field("提醒", notify),
|
|
411
420
|
target ? field("结果发到", target) : field("结果发到", h("div", { class: "task-static" }, task?.convKey ? `专用对话「${task.convTitle || "对话"}」` : "专用对话(第一次运行时新建,之后都发到这条)")),
|
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
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
|