agent-loft 0.2.28 → 0.2.30
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/package.json +1 -1
- package/src/channels/web/server.ts +1 -1
- package/web/app.js +8 -5555
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +11 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +232 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +44 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +404 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +186 -0
- package/web/views/messages.js +510 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +455 -0
- package/web/views/slash.js +185 -0
- package/web/views/topbar.js +394 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { $app, fill, h, icon, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { desktop, desktopQuota, desktopSettings, S, ui } from "../state.js";
|
|
4
|
+
import { clearAppBadge, WS } from "../lib/ws.js";
|
|
5
|
+
import { showAuth } from "./auth.js";
|
|
6
|
+
import {
|
|
7
|
+
closeSide,
|
|
8
|
+
marquee,
|
|
9
|
+
moveProjectDir,
|
|
10
|
+
projDirs,
|
|
11
|
+
renderSidebar,
|
|
12
|
+
reportDesktop,
|
|
13
|
+
resetProjectOrder,
|
|
14
|
+
setSideWidth,
|
|
15
|
+
sideState,
|
|
16
|
+
startMarquee,
|
|
17
|
+
startSideResize,
|
|
18
|
+
stopMarquee,
|
|
19
|
+
} from "./sidebar.js";
|
|
20
|
+
import { applyDesktopQuota, loadQuota, renderQuota } from "./quota.js";
|
|
21
|
+
import { openBotConversations, openConvContextMenu, usableAgents } from "./topbar.js";
|
|
22
|
+
import { showMenu } from "../lib/overlay.js";
|
|
23
|
+
import { bindScrollWatch, newDraft, openConversation, scrollToBottom } from "./messages.js";
|
|
24
|
+
import { renderComposer } from "./composer.js";
|
|
25
|
+
import { renameConv } from "./conv-actions.js";
|
|
26
|
+
import { releaseForeignPushSub } from "../lib/push.js";
|
|
27
|
+
|
|
28
|
+
// ---------- 主界面 ----------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 设置页的代码占前端的三分之一,平时用不到:第一次打开设置时才加载。
|
|
32
|
+
* 加载期间弹窗还没出来,连点 / 桌面端重复触发都会再开一层,所以加载中的再调用直接忽略。
|
|
33
|
+
*/
|
|
34
|
+
let settingsLoading = false;
|
|
35
|
+
function openSettings() {
|
|
36
|
+
if (settingsLoading) return;
|
|
37
|
+
settingsLoading = true;
|
|
38
|
+
import("../settings/index.js").then(
|
|
39
|
+
(m) => {
|
|
40
|
+
settingsLoading = false;
|
|
41
|
+
return m.openSettings();
|
|
42
|
+
},
|
|
43
|
+
(err) => {
|
|
44
|
+
settingsLoading = false;
|
|
45
|
+
toast(`设置加载失败,刷新页面再试:${err.message}`);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function boot() {
|
|
51
|
+
const me = await api("/api/me");
|
|
52
|
+
if (!me.authed) return showAuth();
|
|
53
|
+
WS.stopped = false;
|
|
54
|
+
WS.epoch = null;
|
|
55
|
+
WS.hellos = 0;
|
|
56
|
+
const data = await api("/api/bootstrap");
|
|
57
|
+
S.user = data.user ?? me.user ?? null;
|
|
58
|
+
S.agents = data.agents;
|
|
59
|
+
S.bots = data.bots;
|
|
60
|
+
S.workspaceRoot = data.workspaceRoot;
|
|
61
|
+
S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
|
|
62
|
+
S.quotaEnabled = data.quotaEnabled !== false;
|
|
63
|
+
S.convs = new Map(data.conversations.map((c) => [c.key, c]));
|
|
64
|
+
S.pending = new Map(data.pending.map((i) => [i.id, i]));
|
|
65
|
+
const enabled = usableAgents().map((a) => a.id);
|
|
66
|
+
if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
|
|
67
|
+
renderLayout();
|
|
68
|
+
reportDesktop();
|
|
69
|
+
WS.connect();
|
|
70
|
+
if (desktopQuota) desktop.onQuota(applyDesktopQuota);
|
|
71
|
+
else void loadQuota();
|
|
72
|
+
const fromHash = decodeURIComponent(location.hash.slice(1));
|
|
73
|
+
// 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
|
|
74
|
+
const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
|
|
75
|
+
if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
|
|
76
|
+
else if (latest) await openConversation(latest.key);
|
|
77
|
+
else newDraft();
|
|
78
|
+
// 点了桌面端的系统通知 / 菜单栏里的待处理项:跳到对应会话
|
|
79
|
+
desktop?.onOpen((key) => {
|
|
80
|
+
if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
|
|
81
|
+
});
|
|
82
|
+
// 桌面端「更多」里点了这台服务器的设置;已经开着就不再叠一层
|
|
83
|
+
if (desktopSettings) {
|
|
84
|
+
desktop.onOpenSettings(() => {
|
|
85
|
+
if (!document.querySelector(".overlay .settings")) void openSettings();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if ("serviceWorker" in navigator) {
|
|
89
|
+
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
90
|
+
clearAppBadge();
|
|
91
|
+
void releaseForeignPushSub().catch(() => {});
|
|
92
|
+
// 点了系统通知、页面本来就开着:跳到通知对应的会话
|
|
93
|
+
navigator.serviceWorker.addEventListener("message", (e) => {
|
|
94
|
+
if (e.data?.type !== "open") return;
|
|
95
|
+
const key = decodeURIComponent(new URL(e.data.url, location.origin).hash.slice(1));
|
|
96
|
+
if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderLayout() {
|
|
102
|
+
ui.convList = h("div", { class: "conv-list" });
|
|
103
|
+
// 双击重命名。第一下单击会打开会话、侧栏整体重绘,元素已经换了,浏览器自己的 dblclick
|
|
104
|
+
// 不一定触发;所以按 data-key 自己判断「同一条、两次按下间隔很短」
|
|
105
|
+
let lastDown = { key: null, at: 0 };
|
|
106
|
+
ui.convList.addEventListener("pointerdown", (e) => {
|
|
107
|
+
if (e.button !== 0) return;
|
|
108
|
+
const key = e.target.closest?.("[data-key]")?.dataset.key;
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
if (key && key === lastDown.key && now - lastDown.at < 400) {
|
|
111
|
+
lastDown = { key: null, at: 0 };
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
renameConv(key);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
lastDown = { key, at: now };
|
|
117
|
+
});
|
|
118
|
+
ui.convList.addEventListener("contextmenu", (e) => {
|
|
119
|
+
// 飞书机器人那一行:右键看这个机器人的所有聊天
|
|
120
|
+
const bot = e.target.closest?.("[data-bot]")?.dataset.bot;
|
|
121
|
+
if (bot) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
showMenu({ x: e.clientX, y: e.clientY }, [{ label: "历史会话…", onclick: () => openBotConversations(bot) }]);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// 项目目录那一行:调整顺序(触屏上拖不动,靠这个菜单)
|
|
127
|
+
const dir = e.target.closest?.("[data-dir]")?.dataset.dir;
|
|
128
|
+
if (dir) {
|
|
129
|
+
const i = projDirs.indexOf(dir);
|
|
130
|
+
const moves = [
|
|
131
|
+
...(i > 0 ? [{ label: "置顶", onclick: () => moveProjectDir(dir, null) }, { label: "上移", onclick: () => moveProjectDir(dir, projDirs[i - 1]) }] : []),
|
|
132
|
+
...(i >= 0 && i < projDirs.length - 1 ? [{ label: "下移", onclick: () => moveProjectDir(dir, projDirs[i + 1], true) }] : []),
|
|
133
|
+
];
|
|
134
|
+
const reset = sideState.order.length ? [{ label: "恢复按最近活跃排序", onclick: resetProjectOrder }] : [];
|
|
135
|
+
const items = moves.length && reset.length ? [...moves, "sep", ...reset] : [...moves, ...reset];
|
|
136
|
+
if (!items.length) return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
showMenu({ x: e.clientX, y: e.clientY }, items);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const key = e.target.closest?.("[data-key]")?.dataset.key;
|
|
142
|
+
if (!key || !S.convs.has(key)) return;
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
openConvContextMenu(key, { x: e.clientX, y: e.clientY });
|
|
145
|
+
});
|
|
146
|
+
ui.convList.addEventListener("pointerover", (e) => {
|
|
147
|
+
const title = e.target.closest?.(".conv-item")?.querySelector(".title");
|
|
148
|
+
if (title && title !== marquee.el) startMarquee(title);
|
|
149
|
+
});
|
|
150
|
+
ui.convList.addEventListener("pointerout", (e) => {
|
|
151
|
+
const item = e.target.closest?.(".conv-item");
|
|
152
|
+
if (item && !item.contains(e.relatedTarget)) stopMarquee();
|
|
153
|
+
});
|
|
154
|
+
ui.sidebar = h(
|
|
155
|
+
"aside",
|
|
156
|
+
{ class: "sidebar" },
|
|
157
|
+
h(
|
|
158
|
+
"div",
|
|
159
|
+
{ class: "side-top" },
|
|
160
|
+
h("button", { class: "btn new-chat", onclick: () => (newDraft(), closeSide()) }, icon("compose"), "新聊天"),
|
|
161
|
+
),
|
|
162
|
+
ui.convList,
|
|
163
|
+
h(
|
|
164
|
+
"div",
|
|
165
|
+
{ class: "side-bottom" },
|
|
166
|
+
(ui.quota = h("div", { class: "quota", onpointerenter: () => renderQuota() })),
|
|
167
|
+
h("button", { class: "icon-btn side-gear", title: "设置", onclick: () => (openSettings(), closeSide()) }, icon("gear")),
|
|
168
|
+
),
|
|
169
|
+
h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
|
|
170
|
+
);
|
|
171
|
+
ui.topbar = h("div", { class: "topbar" });
|
|
172
|
+
// 聊天 / 工作切换在顶栏右上角;先建好,renderTopbar 每次把它放回去
|
|
173
|
+
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
174
|
+
ui.msgInner = h("div", { class: "messages-inner" });
|
|
175
|
+
ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
|
|
176
|
+
ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
|
|
177
|
+
ui.toBottom = h("button", { class: "to-bottom", title: "回到底部", hidden: true, onclick: () => scrollToBottom() }, icon("down"));
|
|
178
|
+
ui.follow = true;
|
|
179
|
+
bindScrollWatch();
|
|
180
|
+
ui.composerWrap = h("div", { class: "composer-wrap" });
|
|
181
|
+
ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, ui.composerWrap);
|
|
182
|
+
ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
|
|
183
|
+
fill($app, ui.layout, h("div", { class: "edge-strip", "aria-hidden": "true" }));
|
|
184
|
+
renderComposer();
|
|
185
|
+
renderSidebar();
|
|
186
|
+
}
|
|
@@ -0,0 +1,510 @@
|
|
|
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
|
+
export function newDraft() {
|
|
17
|
+
if (S.current) WS.raw({ t: "unsub", key: S.current });
|
|
18
|
+
S.current = null;
|
|
19
|
+
S.items = [];
|
|
20
|
+
S.turnAi = null;
|
|
21
|
+
turnStartAt = null;
|
|
22
|
+
S.uploads = [];
|
|
23
|
+
S.pinNext = false;
|
|
24
|
+
ui.pinEl = null;
|
|
25
|
+
ui.follow = true;
|
|
26
|
+
setBusy(false);
|
|
27
|
+
renderScheduled();
|
|
28
|
+
history.replaceState(null, "", location.pathname);
|
|
29
|
+
WS.presence();
|
|
30
|
+
renderTopbar();
|
|
31
|
+
renderEmpty();
|
|
32
|
+
renderChips();
|
|
33
|
+
renderSidebar();
|
|
34
|
+
ui.input?.focus();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderEmpty() {
|
|
38
|
+
const enabled = usableAgents();
|
|
39
|
+
const cwd = S.draftCwd ?? S.workspaceRoot ?? "";
|
|
40
|
+
fill(ui.msgInner,
|
|
41
|
+
h(
|
|
42
|
+
"div",
|
|
43
|
+
{ class: "empty hero" },
|
|
44
|
+
agentIcon(S.draftAgent, "hero-logo"),
|
|
45
|
+
enabled.length
|
|
46
|
+
? h(
|
|
47
|
+
"h2",
|
|
48
|
+
{},
|
|
49
|
+
"你想让我们在 ",
|
|
50
|
+
h("button", { class: "hero-dir", title: `${cwd}\n点击切换工作目录`, onclick: () => changeCwd() }, baseName(cwd) || "工作区"),
|
|
51
|
+
" 中构建什么?",
|
|
52
|
+
)
|
|
53
|
+
: [h("h2", {}, "有什么可以帮你?"), h("div", {}, "还没有启用任何 agent,先去设置里启用。")],
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
renderContext();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function openConversation(key) {
|
|
60
|
+
const token = ++S.loadToken;
|
|
61
|
+
if (S.current && S.current !== key) WS.raw({ t: "unsub", key: S.current });
|
|
62
|
+
S.current = key;
|
|
63
|
+
S.items = [];
|
|
64
|
+
S.turnAi = null;
|
|
65
|
+
turnStartAt = null;
|
|
66
|
+
S.uploads = [];
|
|
67
|
+
S.pinNext = false;
|
|
68
|
+
ui.pinEl = null;
|
|
69
|
+
ui.follow = true;
|
|
70
|
+
renderChips();
|
|
71
|
+
renderScheduled();
|
|
72
|
+
history.replaceState(null, "", `#${encodeURIComponent(key)}`);
|
|
73
|
+
WS.presence();
|
|
74
|
+
renderTopbar();
|
|
75
|
+
renderSidebar();
|
|
76
|
+
fill(ui.msgInner, h("div", { class: "empty" }, starsLoader("lg")));
|
|
77
|
+
let data;
|
|
78
|
+
for (let attempt = 0; ; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
data = await api(`/api/conversations/${encodeURIComponent(key)}/history`);
|
|
81
|
+
break;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (token !== S.loadToken) return;
|
|
84
|
+
// 503 = 会话正忙着变化,服务端拿不到一致的快照:等一下再拉
|
|
85
|
+
if (err.status === 503 && attempt < 10) {
|
|
86
|
+
await new Promise((r) => setTimeout(r, 500 + attempt * 300));
|
|
87
|
+
if (token !== S.loadToken) return;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
fill(ui.msgInner, h("div", { class: "empty" }, err.message));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (token !== S.loadToken) return; // 期间又切到别的会话了
|
|
95
|
+
S.convs.set(key, data.info);
|
|
96
|
+
S.allowedAgents = data.allowedAgents;
|
|
97
|
+
if (Number.isFinite(data.serverTime)) setServerTime(data.serverTime);
|
|
98
|
+
fill(ui.msgInner, );
|
|
99
|
+
for (const m of data.messages) {
|
|
100
|
+
if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
|
|
101
|
+
else addItem({ type: "ai", steps: m.steps ?? [], committed: m.text, partial: "", done: true, footer: m.footer, at: m.ts, anchor: m.anchor });
|
|
102
|
+
}
|
|
103
|
+
S.seq.set(key, 0);
|
|
104
|
+
for (const env of data.live) applyEvent(env);
|
|
105
|
+
S.seq.set(key, Math.max(S.seq.get(key) ?? 0, data.seq));
|
|
106
|
+
for (const ix of data.pending) upsertInteraction(ix);
|
|
107
|
+
if (S.items.length === 0) {
|
|
108
|
+
ui.msgInner.append(
|
|
109
|
+
h(
|
|
110
|
+
"div",
|
|
111
|
+
{ class: "empty" },
|
|
112
|
+
h("h2", {}, convTitle(data.info)),
|
|
113
|
+
h("div", {}, data.info.sessionId ? "这条会话暂时读不到历史记录,直接接着说就行。" : `${agentLabel(data.info.agent)} · ${data.info.cwd}`),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
setBusy(data.info.busy);
|
|
118
|
+
renderTopbar();
|
|
119
|
+
WS.subscribe(key, S.seq.get(key));
|
|
120
|
+
renderScheduled();
|
|
121
|
+
scrollToBottom();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- 消息渲染 ----------
|
|
125
|
+
|
|
126
|
+
// 滚动规则(和 ChatGPT 一样):
|
|
127
|
+
// - 自己发出一条消息后,把它顶到视口最上面,下面留白;回复往下长,长到屏幕底部之后才跟着往下滚。
|
|
128
|
+
// 留白靠消息列表后面的 ui.spacer 撑出来:高度 = 刚好够那条消息滚到顶,回复越长它越矮,到 0 为止。
|
|
129
|
+
// - 回复过程中只要用户自己滚了(滚轮、触摸、拖滚动条、键盘翻页),就不再自动滚,直到下一次发消息。
|
|
130
|
+
// ui.follow 记着现在要不要自动滚。
|
|
131
|
+
|
|
132
|
+
function nearBottom(gap = 120) {
|
|
133
|
+
const m = ui.messages;
|
|
134
|
+
return m.scrollHeight - m.scrollTop - m.clientHeight < gap;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 内容变了之后:重算留白,要跟随的话贴到底(有留白时「底」正好就是被顶上去的那条消息) */
|
|
138
|
+
function afterLayout() {
|
|
139
|
+
if (!ui.messages) return;
|
|
140
|
+
fitSpacer();
|
|
141
|
+
if (ui.follow && performance.now() > (ui.smoothUntil ?? 0)) ui.messages.scrollTop = ui.messages.scrollHeight;
|
|
142
|
+
updateToBottomBtn();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fitSpacer() {
|
|
146
|
+
const pin = ui.pinEl;
|
|
147
|
+
if (!pin?.isConnected) {
|
|
148
|
+
ui.pinEl = null;
|
|
149
|
+
ui.spacer.style.height = "0px";
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// 顶栏是叠在消息区上面的:让出它的高度,再留一点空
|
|
153
|
+
const covered = Math.max(0, ui.topbar.getBoundingClientRect().bottom - ui.messages.getBoundingClientRect().top);
|
|
154
|
+
const top = Math.max(0, pin.offsetTop - covered - 12);
|
|
155
|
+
const need = top + ui.messages.clientHeight - ui.msgInner.offsetHeight;
|
|
156
|
+
ui.spacer.style.height = `${Math.max(0, Math.ceil(need))}px`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 回到跟随模式并贴到底:打开会话、点「回到底部」时用 */
|
|
160
|
+
export function scrollToBottom() {
|
|
161
|
+
ui.follow = true;
|
|
162
|
+
afterLayout();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 把刚发出的这条消息顶到最上面 */
|
|
166
|
+
function pinToTop(el) {
|
|
167
|
+
ui.pinEl = el;
|
|
168
|
+
ui.follow = true;
|
|
169
|
+
fitSpacer();
|
|
170
|
+
ui.smoothUntil = performance.now() + 450;
|
|
171
|
+
ui.messages.scrollTo({ top: ui.messages.scrollHeight, behavior: "smooth" });
|
|
172
|
+
// 平滑滚动期间留白可能又变了(比如输入框清空变矮),滚完再对齐一次
|
|
173
|
+
setTimeout(afterLayout, 460);
|
|
174
|
+
updateToBottomBtn();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 用户自己动了滚动:停止自动滚动 */
|
|
178
|
+
function userScrolled() {
|
|
179
|
+
ui.follow = false;
|
|
180
|
+
ui.smoothUntil = 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function updateToBottomBtn() {
|
|
184
|
+
if (ui.toBottom) ui.toBottom.hidden = nearBottom(200);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function bindScrollWatch() {
|
|
188
|
+
const m = ui.messages;
|
|
189
|
+
m.addEventListener("wheel", userScrolled, { passive: true });
|
|
190
|
+
m.addEventListener("touchmove", userScrolled, { passive: true });
|
|
191
|
+
// 点在滚动条上时事件目标就是滚动容器本身(点在内容上的是里面的元素)
|
|
192
|
+
m.addEventListener("pointerdown", (e) => e.target === m && userScrolled());
|
|
193
|
+
m.addEventListener("keydown", (e) => {
|
|
194
|
+
if (["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "].includes(e.key)) userScrolled();
|
|
195
|
+
});
|
|
196
|
+
m.addEventListener("scroll", updateToBottomBtn, { passive: true });
|
|
197
|
+
// 消息区变高(发送后输入框清空变矮)、内容变高都要重算留白;窗口 resize 管不到元素自己变尺寸
|
|
198
|
+
const ro = new ResizeObserver(() => afterLayout());
|
|
199
|
+
ro.observe(m);
|
|
200
|
+
ro.observe(ui.msgInner);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 只有最后一条回复默认显示底部的操作栏(复制 / 分叉 / 用时…),前面的 hover 才出来 */
|
|
204
|
+
function markLatestAi() {
|
|
205
|
+
let latest = null;
|
|
206
|
+
for (let i = S.items.length - 1; i >= 0; i--) {
|
|
207
|
+
const it = S.items[i];
|
|
208
|
+
if (it.type === "ai" && it.done && !it.segment) {
|
|
209
|
+
latest = it;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const it of S.items) if (it.type === "ai") it.el?.classList.toggle("latest", it === latest);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function addItem(item) {
|
|
217
|
+
ui.msgInner.querySelector(".empty")?.remove();
|
|
218
|
+
item.el = renderItem(item);
|
|
219
|
+
ui.msgInner.append(item.el);
|
|
220
|
+
S.items.push(item);
|
|
221
|
+
markLatestAi();
|
|
222
|
+
updateCtxVisibility();
|
|
223
|
+
afterLayout();
|
|
224
|
+
return item;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const dirty = new Set();
|
|
228
|
+
let flushScheduled = false;
|
|
229
|
+
export function updateItem(item) {
|
|
230
|
+
dirty.add(item);
|
|
231
|
+
if (flushScheduled) return;
|
|
232
|
+
flushScheduled = true;
|
|
233
|
+
requestAnimationFrame(() => {
|
|
234
|
+
flushScheduled = false;
|
|
235
|
+
for (const it of dirty) {
|
|
236
|
+
if (!it.el?.isConnected) continue;
|
|
237
|
+
const next = renderItem(it);
|
|
238
|
+
it.el.replaceWith(next);
|
|
239
|
+
it.el = next;
|
|
240
|
+
}
|
|
241
|
+
dirty.clear();
|
|
242
|
+
markLatestAi();
|
|
243
|
+
afterLayout();
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const fileUrl = (f, download) => `/api/file?path=${encodeURIComponent(f.path)}${download ? "&download=1" : ""}`;
|
|
248
|
+
|
|
249
|
+
function fmtSize(n) {
|
|
250
|
+
if (!n) return "";
|
|
251
|
+
if (n < 1024) return `${n} B`;
|
|
252
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
253
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 非图片附件:卡片(图标 + 文件名 + 大小),点一下下载 */
|
|
257
|
+
function fileCard(f, gone) {
|
|
258
|
+
const ext = (f.name.match(/\.([^.]+)$/)?.[1] ?? "").slice(0, 4).toUpperCase();
|
|
259
|
+
return h(
|
|
260
|
+
gone ? "span" : "a",
|
|
261
|
+
{
|
|
262
|
+
class: `att-file${gone ? " gone" : ""}`,
|
|
263
|
+
...(gone ? { title: "文件已清理(开新会话 / 切换目录时会清掉附件)" } : { href: fileUrl(f, true), download: f.name, title: `下载 ${f.name}` }),
|
|
264
|
+
},
|
|
265
|
+
h("span", { class: "att-ext" }, ext || "FILE"),
|
|
266
|
+
h(
|
|
267
|
+
"span",
|
|
268
|
+
{ class: "att-meta" },
|
|
269
|
+
h("span", { class: "att-name" }, f.name),
|
|
270
|
+
h("span", { class: "att-size" }, gone ? "已清理" : fmtSize(f.size) || "下载"),
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 用户消息带的附件,排在气泡上面,顺序和发送时一致。
|
|
277
|
+
* files:新的是 {kind, name, path, size} 对象;老事件里只有「图片 a.png」这样的标签,只能显示名字
|
|
278
|
+
*/
|
|
279
|
+
function renderUserFiles(files) {
|
|
280
|
+
if (!files?.length) return null;
|
|
281
|
+
return h(
|
|
282
|
+
"div",
|
|
283
|
+
{ class: "msg-atts" },
|
|
284
|
+
files.map((f) => {
|
|
285
|
+
if (typeof f === "string") return h("span", { class: "att-file gone" }, h("span", { class: "att-meta" }, h("span", { class: "att-name" }, f)));
|
|
286
|
+
if (f.kind !== "image") return fileCard(f);
|
|
287
|
+
const box = h("button", { class: "att-img", title: f.name, onclick: () => openLightbox(fileUrl(f), f.name, fileUrl(f, true)) });
|
|
288
|
+
const img = h("img", { src: fileUrl(f), alt: f.name, loading: "lazy" });
|
|
289
|
+
// 文件已经被清理:退回成「已清理」的文件卡片
|
|
290
|
+
img.onerror = () => box.replaceWith(fileCard(f, true));
|
|
291
|
+
box.append(img);
|
|
292
|
+
return box;
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 点缩略图看大图:点任意处或按 Esc 关闭 */
|
|
298
|
+
export function openLightbox(src, name, download) {
|
|
299
|
+
const close = () => {
|
|
300
|
+
overlay.remove();
|
|
301
|
+
document.removeEventListener("keydown", onKey, true);
|
|
302
|
+
};
|
|
303
|
+
const onKey = (e) => {
|
|
304
|
+
if (e.key === "Escape") {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
close();
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const overlay = h(
|
|
310
|
+
"div",
|
|
311
|
+
{ class: "lightbox", onclick: close },
|
|
312
|
+
h("img", { src, alt: name ?? "" }),
|
|
313
|
+
h(
|
|
314
|
+
"div",
|
|
315
|
+
{ class: "lightbox-bar", onclick: (e) => e.stopPropagation() },
|
|
316
|
+
name ? h("span", { class: "lightbox-name" }, name) : null,
|
|
317
|
+
download ? h("a", { class: "lightbox-dl", href: download, download: name ?? "" }, "下载") : null,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
document.body.append(overlay);
|
|
321
|
+
document.addEventListener("keydown", onKey, true);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderItem(item) {
|
|
325
|
+
switch (item.type) {
|
|
326
|
+
case "user": {
|
|
327
|
+
const origin = item.origin && item.origin.channel !== "web" ? `来自${item.origin.name ?? item.origin.channel}` : "";
|
|
328
|
+
return h(
|
|
329
|
+
"div",
|
|
330
|
+
{ class: "msg-user-group" },
|
|
331
|
+
// 只发了附件、没有文字:「来自飞书」挂在附件上面
|
|
332
|
+
!item.text && origin ? h("span", { class: "origin" }, origin) : null,
|
|
333
|
+
renderUserFiles(item.files),
|
|
334
|
+
item.text ? h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text) : null,
|
|
335
|
+
item.text
|
|
336
|
+
? h("div", { class: "user-actions" }, h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(item.text) }, icon("copy")))
|
|
337
|
+
: null,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
case "ai":
|
|
341
|
+
return renderAi(item);
|
|
342
|
+
case "notice":
|
|
343
|
+
return h("div", { class: "notice" }, renderMarkdown(item.text));
|
|
344
|
+
case "error":
|
|
345
|
+
return h("div", { class: "error-box" }, item.text);
|
|
346
|
+
case "ix":
|
|
347
|
+
return renderInteraction(item);
|
|
348
|
+
}
|
|
349
|
+
return h("div");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function renderAi(item) {
|
|
353
|
+
const body = item.committed + (item.partial ? (item.committed ? "\n\n" : "") + item.partial : "");
|
|
354
|
+
const working = !item.done;
|
|
355
|
+
const steps =
|
|
356
|
+
item.steps.length > 0
|
|
357
|
+
? h(
|
|
358
|
+
"details",
|
|
359
|
+
{
|
|
360
|
+
class: "steps",
|
|
361
|
+
open: item.stepsOpen ?? false,
|
|
362
|
+
ontoggle: (e) => (item.stepsOpen = e.currentTarget.open),
|
|
363
|
+
},
|
|
364
|
+
h(
|
|
365
|
+
"summary",
|
|
366
|
+
{},
|
|
367
|
+
h(
|
|
368
|
+
"span",
|
|
369
|
+
{ class: `steps-text${working ? " shimmer" : ""}` },
|
|
370
|
+
working
|
|
371
|
+
? item.steps[item.steps.length - 1].split("\n")[0]
|
|
372
|
+
: `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
|
|
373
|
+
),
|
|
374
|
+
h("span", { class: "chev" }, "›"),
|
|
375
|
+
),
|
|
376
|
+
h("ol", {}, item.steps.map((s) => h("li", {}, s))),
|
|
377
|
+
)
|
|
378
|
+
: null;
|
|
379
|
+
return h(
|
|
380
|
+
"div",
|
|
381
|
+
{ class: "msg-ai" },
|
|
382
|
+
steps,
|
|
383
|
+
body ? renderMarkdown(body) : null,
|
|
384
|
+
// 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
|
|
385
|
+
working
|
|
386
|
+
? h(
|
|
387
|
+
"div",
|
|
388
|
+
{ class: "thinking" },
|
|
389
|
+
starsLoader(),
|
|
390
|
+
!body && item.steps.length === 0 ? h("span", { class: "shimmer" }, "思考中…") : null,
|
|
391
|
+
item.startAt
|
|
392
|
+
? h("span", { class: "ai-elapsed", "data-start": item.startAt }, liveDuration(serverNow() - item.startAt))
|
|
393
|
+
: null,
|
|
394
|
+
)
|
|
395
|
+
: null,
|
|
396
|
+
item.error ? h("div", { class: "error-box" }, item.error) : null,
|
|
397
|
+
item.done && !item.segment && (body || item.footer)
|
|
398
|
+
? h(
|
|
399
|
+
"div",
|
|
400
|
+
{ class: "ai-actions" },
|
|
401
|
+
body ? h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(body) }, icon("copy")) : null,
|
|
402
|
+
item.anchor && S.convs.get(S.current)?.canFork
|
|
403
|
+
? h("button", { class: "icon-btn", title: "从这里分叉成新对话", onclick: () => forkConversation(item.anchor) }, icon("fork"))
|
|
404
|
+
: null,
|
|
405
|
+
item.footer || item.at
|
|
406
|
+
? h("span", { class: "footer-note" }, [item.at ? clockTime(item.at) : "", tidyFooter(item.footer)].filter(Boolean).join(" "))
|
|
407
|
+
: null,
|
|
408
|
+
)
|
|
409
|
+
: null,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 这一轮的开始时间(服务端时钟):从用户发出那条消息算起。中途插了授权 / 提问卡片、
|
|
415
|
+
* 回复拆成好几段时,后面的段接着这个时间算,显示的是整轮的总用时。
|
|
416
|
+
* 没有用户消息就开始的一轮(比如后台任务跑完 agent 自己接着干),从第一个事件算起。
|
|
417
|
+
*/
|
|
418
|
+
let turnStartAt = null;
|
|
419
|
+
|
|
420
|
+
function currentAi(ts) {
|
|
421
|
+
if (!S.turnAi || S.turnAi.done) {
|
|
422
|
+
turnStartAt ??= ts ?? serverNow();
|
|
423
|
+
S.turnAi = addItem({ type: "ai", steps: [], committed: "", partial: "", done: false, startAt: turnStartAt });
|
|
424
|
+
}
|
|
425
|
+
return S.turnAi;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function applyEvent(env) {
|
|
429
|
+
if (env.key !== S.current) return;
|
|
430
|
+
const last = S.seq.get(env.key) ?? 0;
|
|
431
|
+
if (env.seq <= last) return;
|
|
432
|
+
S.seq.set(env.key, env.seq);
|
|
433
|
+
const ev = env.ev;
|
|
434
|
+
switch (ev.type) {
|
|
435
|
+
case "user": {
|
|
436
|
+
if (S.turnAi && !S.turnAi.done && S.turnAi.steps.length === 0 && !S.turnAi.committed) {
|
|
437
|
+
// 上一个占位还空着(比如消息被当成了作答),不再多留一个
|
|
438
|
+
S.turnAi.el.remove();
|
|
439
|
+
S.items = S.items.filter((i) => i !== S.turnAi);
|
|
440
|
+
S.turnAi = null;
|
|
441
|
+
}
|
|
442
|
+
// 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
|
|
443
|
+
if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
|
|
444
|
+
const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
445
|
+
// 这个页面刚发的:顶到最上面
|
|
446
|
+
if (S.pinNext && ev.origin?.channel === "web") {
|
|
447
|
+
S.pinNext = false;
|
|
448
|
+
pinToTop(it.el);
|
|
449
|
+
}
|
|
450
|
+
currentAi(env.ts);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
case "step": {
|
|
454
|
+
const ai = currentAi(env.ts);
|
|
455
|
+
ai.steps.push(ev.text);
|
|
456
|
+
updateItem(ai);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
case "delta": {
|
|
460
|
+
const ai = currentAi(env.ts);
|
|
461
|
+
ai.partial += ev.text;
|
|
462
|
+
updateItem(ai);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
case "text": {
|
|
466
|
+
const ai = currentAi(env.ts);
|
|
467
|
+
ai.committed += (ai.committed ? "\n\n" : "") + ev.text;
|
|
468
|
+
ai.partial = "";
|
|
469
|
+
updateItem(ai);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
case "result": {
|
|
473
|
+
const ai = currentAi(env.ts);
|
|
474
|
+
if (!ai.committed && ev.text) ai.committed = ev.text;
|
|
475
|
+
ai.partial = "";
|
|
476
|
+
ai.done = true;
|
|
477
|
+
ai.footer = ev.footer;
|
|
478
|
+
ai.at = ev.at;
|
|
479
|
+
ai.anchor = ev.anchor;
|
|
480
|
+
updateItem(ai);
|
|
481
|
+
S.turnAi = null;
|
|
482
|
+
turnStartAt = null;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
case "error": {
|
|
486
|
+
if (S.turnAi && !S.turnAi.done) {
|
|
487
|
+
S.turnAi.done = true;
|
|
488
|
+
S.turnAi.error = ev.message;
|
|
489
|
+
updateItem(S.turnAi);
|
|
490
|
+
S.turnAi = null;
|
|
491
|
+
} else addItem({ type: "error", text: ev.message });
|
|
492
|
+
turnStartAt = null;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
case "notice":
|
|
496
|
+
addItem({ type: "notice", text: ev.text });
|
|
497
|
+
return;
|
|
498
|
+
case "state":
|
|
499
|
+
setBusy(ev.busy);
|
|
500
|
+
if (!ev.busy && S.turnAi && !S.turnAi.done) {
|
|
501
|
+
S.turnAi.done = true;
|
|
502
|
+
updateItem(S.turnAi);
|
|
503
|
+
S.turnAi = null;
|
|
504
|
+
}
|
|
505
|
+
if (!ev.busy) turnStartAt = null;
|
|
506
|
+
return;
|
|
507
|
+
case "meta":
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|