agent-loft 0.0.0-stage → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/account.ts +122 -0
  6. package/src/agents/claude/driver.ts +216 -0
  7. package/src/agents/claude/errors.ts +129 -0
  8. package/src/agents/claude/permissions.ts +260 -0
  9. package/src/agents/claude/probe.ts +54 -0
  10. package/src/agents/claude/session.ts +487 -0
  11. package/src/agents/codex/account.ts +122 -0
  12. package/src/agents/codex/app-server.ts +346 -0
  13. package/src/agents/codex/driver.ts +210 -0
  14. package/src/agents/codex/errors.ts +158 -0
  15. package/src/agents/codex/protocol.ts +284 -0
  16. package/src/agents/codex/refusals.ts +62 -0
  17. package/src/agents/codex/session.ts +773 -0
  18. package/src/channels/feishu/bot.ts +669 -0
  19. package/src/channels/feishu/cards.ts +360 -0
  20. package/src/channels/feishu/client.ts +185 -0
  21. package/src/channels/feishu/envelope.ts +117 -0
  22. package/src/channels/feishu/index.ts +62 -0
  23. package/src/channels/feishu/message.ts +115 -0
  24. package/src/channels/feishu/register.ts +171 -0
  25. package/src/channels/feishu/slash-sync.ts +192 -0
  26. package/src/channels/feishu/turn-stream.ts +98 -0
  27. package/src/channels/web/api.ts +645 -0
  28. package/src/channels/web/auth.ts +204 -0
  29. package/src/channels/web/http.ts +118 -0
  30. package/src/channels/web/push.ts +218 -0
  31. package/src/channels/web/server.ts +446 -0
  32. package/src/cli/doctor.ts +39 -0
  33. package/src/cli/import.ts +155 -0
  34. package/src/cli/pair.ts +35 -0
  35. package/src/cli/passwd.ts +15 -0
  36. package/src/cli/service.ts +137 -0
  37. package/src/core/agent-env.ts +11 -0
  38. package/src/core/command-catalog.ts +112 -0
  39. package/src/core/commands.ts +104 -0
  40. package/src/core/config.ts +285 -0
  41. package/src/core/dedup.ts +16 -0
  42. package/src/core/footers.ts +88 -0
  43. package/src/core/hub.ts +1152 -0
  44. package/src/core/interactions.ts +273 -0
  45. package/src/core/pairing.ts +156 -0
  46. package/src/core/store.ts +178 -0
  47. package/src/core/types.ts +213 -0
  48. package/src/core/uploads.ts +200 -0
  49. package/src/home.ts +43 -0
  50. package/src/main.ts +68 -0
  51. package/web/app.js +3731 -0
  52. package/web/apple-touch-icon.png +0 -0
  53. package/web/icon-maskable.svg +13 -0
  54. package/web/icon.svg +12 -0
  55. package/web/index.html +23 -0
  56. package/web/manifest.webmanifest +15 -0
  57. package/web/style.css +2604 -0
  58. package/web/sw.js +76 -0
package/web/app.js ADDED
@@ -0,0 +1,3731 @@
1
+ // agent-loft Web 前端。无构建步骤,原生 ES module。
2
+ import { marked } from "/vendor/marked.js";
3
+ import DOMPurify from "/vendor/purify.js";
4
+ import hljs from "/vendor/hljs.js";
5
+
6
+ // ---------- 小工具 ----------
7
+
8
+ const $app = document.getElementById("app");
9
+ const isTouch = matchMedia("(pointer: coarse)").matches;
10
+
11
+ function h(tag, attrs, ...children) {
12
+ const el = document.createElement(tag);
13
+ for (const [k, v] of Object.entries(attrs ?? {})) {
14
+ if (v === undefined || v === null || v === false) continue;
15
+ if (k === "class") el.className = v;
16
+ else if (k === "html") el.innerHTML = v;
17
+ else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
18
+ else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
19
+ else el.setAttribute(k, v === true ? "" : v);
20
+ }
21
+ for (const c of children.flat(Infinity)) {
22
+ if (c === null || c === undefined || c === false) continue;
23
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
24
+ }
25
+ return el;
26
+ }
27
+
28
+ /** replaceChildren 会把 null 变成字面量 "null",统一过滤掉 */
29
+ function fill(el, ...kids) {
30
+ el.replaceChildren(...kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false));
31
+ }
32
+
33
+ const ICON = {
34
+ menu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>',
35
+ edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
36
+ compose: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a2 2 0 0 1 2.9 2.9L12.5 14.3 9 15l.7-3.5Z"/></svg>',
37
+ send: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
38
+ stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
39
+ clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.6-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
40
+ mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
41
+ more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
42
+ down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>',
43
+ gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>',
44
+ fork: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10"/><path d="M18 9c0 5-6 4-11.2 8.6"/></svg>',
45
+ copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
46
+ x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
47
+ plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
48
+ folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h7.4A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z"/><path d="M3 10h18"/></svg>',
49
+ folderOpen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17.5V7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h6.4A2.5 2.5 0 0 1 20 9.7V11"/><path d="M3 17.5 5.6 12a2 2 0 0 1 1.8-1.1h13a1 1 0 0 1 .9 1.4l-2.4 6.2a2.2 2.2 0 0 1-2 1.5H5.5A2.5 2.5 0 0 1 3 17.5Z"/></svg>',
50
+ laptop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/></svg>',
51
+ logo: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 8.5a8 8 0 0 1 13.2 0 8 8 0 0 1 9 9.4 8 8 0 0 1 0 12.2 8 8 0 0 1-9 9.4 8 8 0 0 1-13.2 0 8 8 0 0 1-9-9.4 8 8 0 0 1 0-12.2 8 8 0 0 1 9-9.4Z"/><path d="m17 18 5 5-5 5M25 30h7"/></svg>',
52
+ feishu:
53
+ '<svg viewBox="2 4 44 40"><path fill="#00D6B9" d="M11.6 7H27.6C30.9 9.9 32.8 14.7 33.6 17C30.9 18.3 29 20.6 27 24C22.6 16.4 15.6 11 11.6 7Z"/><path fill="#133C9A" d="M17 29C21 29 25 26.9 28 23.4C36 14 41.4 16.8 44 18C38.5 21 40.5 29.6 33 36C27.5 34.8 21.8 32.2 17 29Z"/><path fill="#3370FF" d="M4 16.2C7.2 20.8 12.4 26 17.4 29.4C23 33.2 28.6 35.7 34 35.2C28.9 39.8 23.8 41 19 41C12.5 41 6.9 37.8 4 35.4Z"/></svg>',
54
+ };
55
+
56
+ /** Claude 的放射状标志:长短不一的圆头射线 */
57
+ ICON.claude = (() => {
58
+ const lens = [10, 8.2, 9.4, 7.6, 10.4, 8.4, 9.2, 7.8, 10, 8.6, 9.6, 7.4];
59
+ const rays = lens
60
+ .map((len, i) => {
61
+ const a = (i / lens.length) * Math.PI * 2 + 0.12;
62
+ const x = (r) => (12 + Math.cos(a) * r).toFixed(2);
63
+ const y = (r) => (12 + Math.sin(a) * r).toFixed(2);
64
+ return `M${x(1.6)} ${y(1.6)}L${x(len)} ${y(len)}`;
65
+ })
66
+ .join("");
67
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="#D97757" stroke-width="2.3" stroke-linecap="round"><path d="${rays}"/></svg>`;
68
+ })();
69
+ ICON.codex = ICON.logo;
70
+
71
+ function agentIcon(id, cls = "agent-icon") {
72
+ return h("span", { class: cls, html: ICON[id] ?? ICON.laptop });
73
+ }
74
+
75
+ function icon(name) {
76
+ return h("span", { html: ICON[name], style: { display: "inline-grid" } });
77
+ }
78
+
79
+ function toast(text, ms = 2600) {
80
+ const el = h("div", { class: "toast" }, text);
81
+ document.body.append(el);
82
+ setTimeout(() => el.remove(), ms);
83
+ }
84
+
85
+ async function api(path, opts = {}) {
86
+ const res = await fetch(path, {
87
+ method: opts.method ?? "GET",
88
+ credentials: "same-origin",
89
+ headers: opts.body !== undefined && !(opts.body instanceof Blob) ? { "Content-Type": "application/json" } : opts.headers,
90
+ body: opts.body === undefined ? undefined : opts.body instanceof Blob ? opts.body : JSON.stringify(opts.body),
91
+ });
92
+ let data = null;
93
+ try {
94
+ data = await res.json();
95
+ } catch {
96
+ // 没有 body
97
+ }
98
+ if (res.status === 401 && !path.startsWith("/api/me") && !path.startsWith("/api/login")) {
99
+ showAuth();
100
+ throw new Error("请先登录");
101
+ }
102
+ if (!res.ok) {
103
+ const err = new Error(data?.error ?? `请求失败(${res.status})`);
104
+ err.status = res.status;
105
+ throw err;
106
+ }
107
+ return data;
108
+ }
109
+
110
+ /** 回复下面的时间:今天只显示时分,今年显示月日,再早带上年份 */
111
+ function clockTime(ts) {
112
+ const d = new Date(ts);
113
+ const now = new Date();
114
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
115
+ if (d.toDateString() === now.toDateString()) return hm;
116
+ const md = `${d.getMonth() + 1}月${d.getDate()}日`;
117
+ return d.getFullYear() === now.getFullYear() ? `${md} ${hm}` : `${d.getFullYear()}年${md} ${hm}`;
118
+ }
119
+
120
+ function relTime(ts) {
121
+ const m = Math.floor((Date.now() - ts) / 60000);
122
+ if (m < 1) return "刚刚";
123
+ if (m < 60) return `${m} 分钟前`;
124
+ const hr = Math.floor(m / 60);
125
+ if (hr < 24) return `${hr} 小时前`;
126
+ const d = Math.floor(hr / 24);
127
+ if (d < 30) return `${d} 天前`;
128
+ return new Date(ts).toLocaleDateString("zh-CN");
129
+ }
130
+
131
+ // ---------- markdown ----------
132
+
133
+ marked.setOptions({ gfm: true, breaks: true });
134
+
135
+ function renderMarkdown(text) {
136
+ const html = DOMPurify.sanitize(marked.parse(text ?? ""), { ADD_ATTR: ["target"] });
137
+ const div = h("div", { class: "md", html });
138
+ for (const a of div.querySelectorAll("a")) {
139
+ a.target = "_blank";
140
+ a.rel = "noopener noreferrer";
141
+ }
142
+ for (const pre of div.querySelectorAll("pre")) {
143
+ const code = pre.querySelector("code");
144
+ if (!code) continue;
145
+ const lang = /language-([\w+-]+)/.exec(code.className)?.[1] ?? "";
146
+ try {
147
+ if (lang && hljs.getLanguage(lang)) hljs.highlightElement(code);
148
+ else if (code.textContent.length < 20000) {
149
+ code.innerHTML = hljs.highlightAuto(code.textContent).value;
150
+ code.classList.add("hljs");
151
+ }
152
+ } catch {
153
+ // 高亮失败就原样显示
154
+ }
155
+ const copy = h("button", { class: "copy-btn", onclick: () => copyText(code.textContent, copy) }, "复制");
156
+ pre.prepend(h("div", { class: "code-head" }, h("span", {}, lang || "code"), copy));
157
+ }
158
+ return div;
159
+ }
160
+
161
+ async function copyText(text, btn) {
162
+ try {
163
+ await navigator.clipboard.writeText(text);
164
+ } catch {
165
+ const ta = h("textarea", { style: { position: "fixed", opacity: "0" } });
166
+ ta.value = text;
167
+ document.body.append(ta);
168
+ ta.select();
169
+ document.execCommand("copy");
170
+ ta.remove();
171
+ }
172
+ if (btn) {
173
+ const old = btn.textContent;
174
+ btn.textContent = "已复制";
175
+ setTimeout(() => (btn.textContent = old), 1200);
176
+ } else toast("已复制");
177
+ }
178
+
179
+ // ---------- 状态 ----------
180
+
181
+ const S = {
182
+ agents: [],
183
+ bots: [],
184
+ convs: new Map(),
185
+ pending: new Map(),
186
+ current: null,
187
+ draftAgent: localStorage.getItem("aim.agent") || null,
188
+ /** 新对话(还没发消息)选的工作目录,null 表示工作区根目录。开新对话时沿用上次选的 */
189
+ draftCwd: null,
190
+ items: [],
191
+ turnAi: null,
192
+ seq: new Map(),
193
+ uploads: [],
194
+ busy: false,
195
+ allowedAgents: [],
196
+ search: "",
197
+ loadToken: 0,
198
+ };
199
+
200
+ const ui = {};
201
+
202
+ // ---------- WebSocket ----------
203
+
204
+ const WS = {
205
+ sock: null,
206
+ rid: 0,
207
+ waiters: new Map(),
208
+ backoff: 500,
209
+ connected: false,
210
+ pingTimer: null,
211
+
212
+ stopped: false,
213
+ epoch: null,
214
+ hellos: 0,
215
+
216
+ connect() {
217
+ if (this.stopped) return;
218
+ if (this.sock && (this.sock.readyState === 0 || this.sock.readyState === 1)) return;
219
+ const proto = location.protocol === "https:" ? "wss" : "ws";
220
+ const sock = new WebSocket(`${proto}://${location.host}/ws`);
221
+ this.sock = sock;
222
+ sock.onopen = () => {
223
+ this.connected = true;
224
+ this.backoff = 500;
225
+ setConnBanner(null);
226
+ clearInterval(this.pingTimer);
227
+ this.pingTimer = setInterval(() => this.raw({ t: "ping" }), 25000);
228
+ };
229
+ sock.onmessage = (e) => onWsMessage(JSON.parse(e.data));
230
+ sock.onclose = async (e) => {
231
+ this.connected = false;
232
+ clearInterval(this.pingTimer);
233
+ for (const w of this.waiters.values()) w.reject(new Error("连接已断开"));
234
+ this.waiters.clear();
235
+ if (this.sock !== sock) return;
236
+ // 登录已失效(改了密码、被踢下线):回登录页,不再重连
237
+ if (e.code === 4401) {
238
+ this.stopped = true;
239
+ showAuth();
240
+ return;
241
+ }
242
+ if (this.stopped) return;
243
+ // 握手被拒(401)在浏览器里只表现为 1006:问一下是不是登录态没了
244
+ try {
245
+ const me = await (await fetch("/api/me", { credentials: "same-origin" })).json();
246
+ if (!me.authed) {
247
+ this.stopped = true;
248
+ showAuth();
249
+ return;
250
+ }
251
+ } catch {
252
+ // 网络不通,照常重连
253
+ }
254
+ if (document.visibilityState === "visible") setConnBanner("连接已断开,正在重连…");
255
+ setTimeout(() => this.connect(), this.backoff);
256
+ this.backoff = Math.min(this.backoff * 2, 10000);
257
+ };
258
+ },
259
+
260
+ raw(msg) {
261
+ if (this.sock?.readyState === 1) this.sock.send(JSON.stringify(msg));
262
+ },
263
+
264
+ request(msg, timeoutMs = 15000) {
265
+ return new Promise((resolve, reject) => {
266
+ if (this.sock?.readyState !== 1) {
267
+ reject(new Error("还没连上服务器,稍等再试"));
268
+ return;
269
+ }
270
+ const rid = ++this.rid;
271
+ this.waiters.set(rid, { resolve, reject });
272
+ this.sock.send(JSON.stringify({ ...msg, rid }));
273
+ setTimeout(() => {
274
+ if (this.waiters.delete(rid)) reject(new Error("服务器没有响应"));
275
+ }, timeoutMs);
276
+ });
277
+ },
278
+
279
+ subscribe(key, since) {
280
+ this.raw({ t: "sub", key, since, epoch: this.epoch });
281
+ },
282
+
283
+ /** 告诉服务器我是否在前台、正看着哪条会话:正看着的会话不推系统通知 */
284
+ presence() {
285
+ const visible = document.visibilityState === "visible" && document.hasFocus();
286
+ this.raw({ t: "presence", visible, key: S.current ?? "" });
287
+ },
288
+ };
289
+
290
+ for (const ev of ["focus", "blur"]) window.addEventListener(ev, () => WS.presence());
291
+
292
+ /** 打开 / 切回页面:清掉桌面图标角标和计数(计数由 sw.js 在收到推送时累加) */
293
+ function clearAppBadge() {
294
+ if (document.visibilityState !== "visible") return;
295
+ navigator.clearAppBadge?.().catch(() => {});
296
+ if ("caches" in window) caches.delete("aim-badge").catch(() => {});
297
+ }
298
+
299
+ document.addEventListener("visibilitychange", () => {
300
+ WS.presence();
301
+ clearAppBadge();
302
+ // 手机切回前台:连接多半已经被系统掐了,立刻重连而不是等退避
303
+ if (document.visibilityState === "visible" && WS.sock?.readyState !== 1) {
304
+ WS.backoff = 300;
305
+ WS.connect();
306
+ }
307
+ });
308
+
309
+ /** 重连后:以服务器上的会话列表和待处理授权为准,清掉本地已失效的 */
310
+ async function resync() {
311
+ try {
312
+ const data = await api("/api/bootstrap");
313
+ S.convs = new Map(data.conversations.map((c) => [c.key, c]));
314
+ const live = new Set(data.pending.map((i) => i.id));
315
+ for (const ix of S.pending.values()) {
316
+ // 快照生成之后才出现的授权,快照里当然没有,不能当成「已在别处处理」
317
+ if (!ix.settled && !live.has(ix.id) && ix.createdAt <= data.serverTime) markGone(ix);
318
+ }
319
+ for (const ix of data.pending) upsertInteraction(ix);
320
+ renderSidebar();
321
+ } catch {
322
+ // 下次重连再说
323
+ }
324
+ }
325
+
326
+ /** 本地以为还挂着、服务器上已经没了的授权:断线期间在别处处理掉了,或者服务重启过 */
327
+ function markGone(ix) {
328
+ upsertInteraction({ ...ix, settled: { settlement: "gone", by: "已在别处处理或已失效", at: Date.now() } });
329
+ }
330
+
331
+ function onWsMessage(msg) {
332
+ switch (msg.t) {
333
+ case "hello": {
334
+ const restarted = WS.epoch && WS.epoch !== msg.epoch;
335
+ WS.epoch = msg.epoch;
336
+ WS.hellos += 1;
337
+ WS.presence();
338
+ if (restarted) {
339
+ // 服务重启过:seq 从头计数了,旧游标全部作废,当前会话整页重拉
340
+ S.seq.clear();
341
+ if (S.current) void openConversation(S.current);
342
+ } else if (S.current) {
343
+ WS.subscribe(S.current, S.seq.get(S.current) ?? 0);
344
+ }
345
+ if (WS.hellos > 1 || restarted) void resync();
346
+ return;
347
+ }
348
+ case "ack": {
349
+ const w = WS.waiters.get(msg.rid);
350
+ if (!w) return;
351
+ WS.waiters.delete(msg.rid);
352
+ if (msg.ok) w.resolve(msg);
353
+ else w.reject(new Error(msg.error ?? "操作失败"));
354
+ return;
355
+ }
356
+ case "ev":
357
+ applyEvent(msg);
358
+ return;
359
+ case "replay":
360
+ if (msg.key !== S.current) return;
361
+ if (msg.reset) {
362
+ void openConversation(msg.key);
363
+ return;
364
+ }
365
+ for (const env of msg.events) applyEvent(env);
366
+ {
367
+ const live = new Set(msg.pending.map((i) => i.id));
368
+ for (const ix of S.pending.values()) {
369
+ if (ix.convKey === msg.key && !ix.settled && !live.has(ix.id)) markGone(ix);
370
+ }
371
+ }
372
+ for (const ix of msg.pending) upsertInteraction(ix);
373
+ setBusy(msg.busy);
374
+ return;
375
+ case "conv":
376
+ if (S.convs.get(msg.info.key)?.busy && !msg.info.busy) scheduleQuota();
377
+ S.convs.set(msg.info.key, msg.info);
378
+ if (msg.info.key === S.current) {
379
+ renderTopbar();
380
+ setBusy(msg.info.busy);
381
+ }
382
+ scheduleSidebar();
383
+ return;
384
+ case "ix":
385
+ upsertInteraction(msg.interaction);
386
+ return;
387
+ }
388
+ }
389
+
390
+ function setConnBanner(text) {
391
+ ui.banner?.remove();
392
+ ui.banner = null;
393
+ if (!text || !ui.main) return;
394
+ ui.banner = h("div", { class: "conn-banner" }, text);
395
+ ui.main.append(ui.banner);
396
+ }
397
+
398
+ // ---------- 登录 ----------
399
+
400
+ async function showAuth() {
401
+ WS.stopped = true;
402
+ WS.sock?.close();
403
+ let me = { needsSetup: false };
404
+ try {
405
+ me = await api("/api/me");
406
+ } catch {
407
+ // 网络问题时按登录处理
408
+ }
409
+ const err = h("div", { class: "error-text" });
410
+ const pw = h("input", { type: "password", placeholder: "密码", autocomplete: me.needsSetup ? "new-password" : "current-password" });
411
+ const token = h("input", { placeholder: "设置码(在启动日志里)", autocomplete: "off" });
412
+ const btn = h("button", { class: "btn primary", type: "submit" }, me.needsSetup ? "设置密码并进入" : "登录");
413
+ const form = h(
414
+ "form",
415
+ {
416
+ class: "auth-card",
417
+ onsubmit: async (e) => {
418
+ e.preventDefault();
419
+ err.textContent = "";
420
+ btn.disabled = true;
421
+ try {
422
+ if (me.needsSetup) await api("/api/setup", { method: "POST", body: { token: token.value, password: pw.value } });
423
+ else await api("/api/login", { method: "POST", body: { password: pw.value } });
424
+ await boot();
425
+ } catch (e2) {
426
+ err.textContent = e2.message;
427
+ } finally {
428
+ btn.disabled = false;
429
+ }
430
+ },
431
+ },
432
+ h("h1", {}, "Agent Loft"),
433
+ h("p", {}, me.needsSetup ? "首次使用:输入启动日志里的设置码,并设一个登录密码(至少 8 位)" : "登录以继续"),
434
+ me.needsSetup ? h("label", { class: "field" }, h("span", {}, "设置码"), token) : null,
435
+ h("label", { class: "field" }, h("span", {}, me.needsSetup ? "新密码" : "密码"), pw),
436
+ btn,
437
+ err,
438
+ );
439
+ fill($app, h("div", { class: "auth" }, form));
440
+ (me.needsSetup ? token : pw).focus();
441
+ }
442
+
443
+ // ---------- 主界面 ----------
444
+
445
+ async function boot() {
446
+ const me = await api("/api/me");
447
+ if (!me.authed) return showAuth();
448
+ WS.stopped = false;
449
+ WS.epoch = null;
450
+ WS.hellos = 0;
451
+ const data = await api("/api/bootstrap");
452
+ S.agents = data.agents;
453
+ S.bots = data.bots;
454
+ S.workspaceRoot = data.workspaceRoot;
455
+ S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
456
+ S.convs = new Map(data.conversations.map((c) => [c.key, c]));
457
+ S.pending = new Map(data.pending.map((i) => [i.id, i]));
458
+ const enabled = S.agents.filter((a) => a.enabled).map((a) => a.id);
459
+ if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
460
+ renderLayout();
461
+ WS.connect();
462
+ void loadQuota();
463
+ const fromHash = decodeURIComponent(location.hash.slice(1));
464
+ if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
465
+ else newDraft();
466
+ if ("serviceWorker" in navigator) {
467
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
468
+ clearAppBadge();
469
+ // 点了系统通知、页面本来就开着:跳到通知对应的会话
470
+ navigator.serviceWorker.addEventListener("message", (e) => {
471
+ if (e.data?.type !== "open") return;
472
+ const key = decodeURIComponent(new URL(e.data.url, location.origin).hash.slice(1));
473
+ if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
474
+ });
475
+ }
476
+ }
477
+
478
+ function renderLayout() {
479
+ ui.convList = h("div", { class: "conv-list" });
480
+ // 双击重命名。第一下单击会打开会话、侧栏整体重绘,元素已经换了,浏览器自己的 dblclick
481
+ // 不一定触发;所以按 data-key 自己判断「同一条、两次按下间隔很短」
482
+ let lastDown = { key: null, at: 0 };
483
+ ui.convList.addEventListener("pointerdown", (e) => {
484
+ if (e.button !== 0) return;
485
+ const key = e.target.closest?.("[data-key]")?.dataset.key;
486
+ const now = Date.now();
487
+ if (key && key === lastDown.key && now - lastDown.at < 400) {
488
+ lastDown = { key: null, at: 0 };
489
+ e.preventDefault();
490
+ renameConv(key);
491
+ return;
492
+ }
493
+ lastDown = { key, at: now };
494
+ });
495
+ ui.convList.addEventListener("pointerover", (e) => {
496
+ const title = e.target.closest?.(".conv-item")?.querySelector(".title");
497
+ if (title && title !== marquee.el) startMarquee(title);
498
+ });
499
+ ui.convList.addEventListener("pointerout", (e) => {
500
+ const item = e.target.closest?.(".conv-item");
501
+ if (item && !item.contains(e.relatedTarget)) stopMarquee();
502
+ });
503
+ ui.sidebar = h(
504
+ "aside",
505
+ { class: "sidebar" },
506
+ h(
507
+ "div",
508
+ { class: "side-top" },
509
+ h("button", { class: "btn new-chat", onclick: () => (newDraft(), closeSide()) }, icon("compose"), "新聊天"),
510
+ ),
511
+ ui.convList,
512
+ h(
513
+ "div",
514
+ { class: "side-bottom" },
515
+ (ui.quota = h("div", { class: "quota", onpointerenter: () => renderQuota() })),
516
+ h("button", { class: "icon-btn side-gear", title: "设置", onclick: () => (openSettings(), closeSide()) }, icon("gear")),
517
+ ),
518
+ h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
519
+ );
520
+ ui.topbar = h("div", { class: "topbar" });
521
+ ui.msgInner = h("div", { class: "messages-inner" });
522
+ ui.messages = h("div", { class: "messages" }, ui.msgInner);
523
+ ui.composerWrap = h("div", { class: "composer-wrap" });
524
+ ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, ui.composerWrap);
525
+ ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
526
+ fill($app, ui.layout, h("div", { class: "edge-strip", "aria-hidden": "true" }));
527
+ renderComposer();
528
+ renderSidebar();
529
+ }
530
+
531
+ // ---------- 侧栏标题跑马灯:名字被省略时,hover 来回滚动显示全名 ----------
532
+
533
+ const marquee = { el: null, raf: 0 };
534
+
535
+ function startMarquee(el) {
536
+ stopMarquee();
537
+ const dist = el.scrollWidth - el.clientWidth;
538
+ if (dist <= 2 || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
539
+ marquee.el = el;
540
+ el.classList.add("marquee");
541
+ const speed = 45; // px/s
542
+ const run = dist / speed * 1000;
543
+ const pause = 700;
544
+ const cycle = (pause + run) * 2;
545
+ let t0 = null;
546
+ const step = (now) => {
547
+ if (marquee.el !== el) return;
548
+ t0 ??= now;
549
+ const t = (now - t0) % cycle;
550
+ // 停 → 滚到末尾 → 停 → 滚回开头
551
+ let x;
552
+ if (t < pause) x = 0;
553
+ else if (t < pause + run) x = ((t - pause) / run) * dist;
554
+ else if (t < pause * 2 + run) x = dist;
555
+ else x = dist - ((t - pause * 2 - run) / run) * dist;
556
+ el.scrollLeft = x;
557
+ marquee.raf = requestAnimationFrame(step);
558
+ };
559
+ marquee.raf = requestAnimationFrame(step);
560
+ }
561
+
562
+ function stopMarquee() {
563
+ cancelAnimationFrame(marquee.raf);
564
+ if (marquee.el) {
565
+ marquee.el.scrollLeft = 0;
566
+ marquee.el.classList.remove("marquee");
567
+ }
568
+ marquee.el = null;
569
+ }
570
+
571
+ // ---------- 侧栏底部的账号额度 ----------
572
+ // 服务端不主动查:只有前端请求 /api/quota 时才去问各 agent,结果缓存 60 秒
573
+ // (60 秒内再请求直接返回缓存;Claude 要临时拉一个 CLI 进程才问得到,不能太勤)。
574
+ // 前端什么时候请求:
575
+ // - 打开页面拉一次;
576
+ // - 空闲时每隔 S.quotaRefreshMin 分钟(设置 → 通用,默认 10,0 = 不自动刷新),只在页面可见时;
577
+ // - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
578
+ // - 切回前台:距上次超过一个刷新间隔就拉;
579
+ // - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
580
+
581
+ const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
582
+ const QUOTA_SERVER_CACHE = 60_000;
583
+ const quotaInterval = () => (S.quotaRefreshMin ?? 10) * 60_000;
584
+
585
+ async function loadQuota() {
586
+ if (quotaState.loading) return;
587
+ quotaState.loading = true;
588
+ ui.quota?.classList.add("loading");
589
+ try {
590
+ const data = await api("/api/quota");
591
+ quotaState.data = data.quotas;
592
+ quotaState.at = Date.now();
593
+ } catch {
594
+ // 保留旧数据
595
+ } finally {
596
+ quotaState.loading = false;
597
+ ui.quota?.classList.remove("loading");
598
+ renderQuota();
599
+ scheduleIdleQuota();
600
+ }
601
+ }
602
+
603
+ /** 空闲定时:每次拉完重新计时,所以刚因为跑完一轮拉过,就不会紧接着又拉 */
604
+ function scheduleIdleQuota() {
605
+ clearTimeout(quotaState.idle);
606
+ if (!quotaInterval()) return;
607
+ quotaState.idle = setTimeout(() => {
608
+ // 页面不可见就先不拉,等切回来再说
609
+ if (document.visibilityState === "visible") void loadQuota();
610
+ }, quotaInterval());
611
+ }
612
+
613
+ /** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
614
+ function scheduleQuota() {
615
+ const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
616
+ clearTimeout(quotaState.timer);
617
+ quotaState.timer = setTimeout(() => void loadQuota(), wait);
618
+ }
619
+
620
+ document.addEventListener("visibilitychange", () => {
621
+ if (document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
622
+ if (Date.now() - quotaState.at >= quotaInterval()) void loadQuota();
623
+ });
624
+
625
+ function untilText(ms) {
626
+ const sec = Math.floor((ms - Date.now()) / 1000);
627
+ if (sec <= 0) return "即将重置";
628
+ const d = Math.floor(sec / 86400);
629
+ const hh = Math.floor((sec % 86400) / 3600);
630
+ const mm = Math.floor((sec % 3600) / 60);
631
+ if (d > 0) return `${d} 天 ${hh} 小时后重置`;
632
+ if (hh > 0) return `${hh} 小时 ${mm} 分后重置`;
633
+ return `${Math.max(1, mm)} 分钟后重置`;
634
+ }
635
+
636
+ function quotaLevel(p) {
637
+ return p >= 90 ? "danger" : p >= 75 ? "warn" : "";
638
+ }
639
+
640
+ function renderQuota() {
641
+ if (!ui.quota) return;
642
+ const list = quotaState.data ?? [];
643
+ if (list.length === 0) return fill(ui.quota);
644
+ const rows = list.map((q) =>
645
+ h(
646
+ "div",
647
+ { class: `quota-row${q.reached ? " reached" : ""}` },
648
+ agentIcon(q.agent, "agent-icon quota-icon"),
649
+ q.windows.slice(0, 2).map((w) =>
650
+ h(
651
+ "span",
652
+ { class: `quota-cell ${quotaLevel(w.percent)}` },
653
+ h("span", { class: "q-label" }, w.short),
654
+ h("span", { class: "q-bar" }, h("i", { style: { width: `${Math.max(2, Math.min(100, w.percent))}%` } })),
655
+ h("span", { class: "q-pct" }, `${Math.round(w.percent)}%`),
656
+ ),
657
+ ),
658
+ ),
659
+ );
660
+ const tip = h(
661
+ "div",
662
+ { class: "quota-tip" },
663
+ list.map((q) =>
664
+ h(
665
+ "div",
666
+ { class: "qt-agent" },
667
+ h("div", { class: "qt-head" }, agentIcon(q.agent), h("b", {}, q.label), q.plan ? h("span", { class: "qt-plan" }, q.plan) : null),
668
+ q.windows.map((w) =>
669
+ h(
670
+ "div",
671
+ { class: "qt-row" },
672
+ h("span", { class: "qt-name" }, w.label),
673
+ h("span", { class: `qt-pct ${quotaLevel(w.percent)}` }, `已用 ${Math.round(w.percent)}%`),
674
+ w.resetsAt ? h("span", { class: "qt-reset" }, untilText(w.resetsAt)) : null,
675
+ ),
676
+ ),
677
+ q.extra ? h("div", { class: "qt-extra" }, q.extra) : null,
678
+ q.reached ? h("div", { class: "qt-extra danger" }, "已达上限,等窗口重置后才能继续") : null,
679
+ ),
680
+ ),
681
+ h("div", { class: "qt-foot" }, `${relTime(quotaState.at)}更新 · 点击刷新`),
682
+ );
683
+ fill(ui.quota, h("button", { class: "quota-btn", onclick: () => void loadQuota() }, rows), tip);
684
+ }
685
+
686
+ // ---------- 侧栏宽度(桌面端可拖动) ----------
687
+
688
+ const SIDE_MIN = 200;
689
+ const SIDE_MAX = 520;
690
+
691
+ function setSideWidth(w) {
692
+ if (w == null) {
693
+ document.documentElement.style.removeProperty("--side-w");
694
+ localStorage.removeItem("aim.sideW");
695
+ return;
696
+ }
697
+ const px = Math.round(Math.max(SIDE_MIN, Math.min(SIDE_MAX, w, innerWidth - 360)));
698
+ document.documentElement.style.setProperty("--side-w", `${px}px`);
699
+ return px;
700
+ }
701
+
702
+ {
703
+ const saved = Number(localStorage.getItem("aim.sideW"));
704
+ if (saved) setSideWidth(saved);
705
+ }
706
+
707
+ function startSideResize(e) {
708
+ if (e.button !== 0) return;
709
+ e.preventDefault();
710
+ const handle = e.currentTarget;
711
+ const left = ui.sidebar.getBoundingClientRect().left;
712
+ let last = null;
713
+ handle.setPointerCapture(e.pointerId);
714
+ document.body.classList.add("side-resizing");
715
+ const move = (ev) => (last = setSideWidth(ev.clientX - left));
716
+ const up = () => {
717
+ handle.removeEventListener("pointermove", move);
718
+ handle.removeEventListener("pointerup", up);
719
+ handle.removeEventListener("pointercancel", up);
720
+ document.body.classList.remove("side-resizing");
721
+ if (last) localStorage.setItem("aim.sideW", String(last));
722
+ };
723
+ handle.addEventListener("pointermove", move);
724
+ handle.addEventListener("pointerup", up);
725
+ handle.addEventListener("pointercancel", up);
726
+ }
727
+
728
+ function openSide() {
729
+ ui.layout.classList.add("side-open");
730
+ nudgeStatusBar();
731
+ }
732
+
733
+ function closeSide() {
734
+ if (!ui.layout?.classList.contains("side-open")) return;
735
+ ui.layout.classList.remove("side-open");
736
+ nudgeStatusBar();
737
+ }
738
+
739
+ /**
740
+ * iOS 26 的 PWA 会按页面顶部的颜色给状态栏上色,而且元素变了不一定重算,
741
+ * 侧栏遮罩关掉后状态栏还是暗的。重设一遍 theme-color 逼它刷新。
742
+ */
743
+ function nudgeStatusBar() {
744
+ requestAnimationFrame(() => {
745
+ for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
746
+ const v = meta.getAttribute("content");
747
+ meta.setAttribute("content", "");
748
+ meta.setAttribute("content", v);
749
+ }
750
+ });
751
+ }
752
+
753
+ // ---------- 侧边栏 ----------
754
+
755
+ let sidebarScheduled = false;
756
+ function scheduleSidebar() {
757
+ if (sidebarScheduled) return;
758
+ sidebarScheduled = true;
759
+ requestAnimationFrame(() => {
760
+ sidebarScheduled = false;
761
+ renderSidebar();
762
+ });
763
+ }
764
+
765
+ /**
766
+ * 把一组会话按分叉关系排成树:父对话也在这一组里的,挂到父对话下面;其余的是根。
767
+ * 节点 { c, kids, at },at 是整棵子树里最近的活跃时间。根和同级都按 at 从新到旧。
768
+ */
769
+ function forkForest(convs) {
770
+ const byKey = new Map(convs.map((c) => [c.key, c]));
771
+ const nodes = new Map(convs.map((c) => [c.key, { c, kids: [], at: c.activeAt }]));
772
+ // 父链里绕回自己(数据坏了)就当根处理,免得整串都不显示
773
+ const parentOf = (c) => {
774
+ if (!c.forkOf || !byKey.has(c.forkOf)) return null;
775
+ for (let p = byKey.get(c.forkOf), n = 0; p && n < 50; p = byKey.get(p.forkOf), n++) {
776
+ if (p.key === c.key) return null;
777
+ }
778
+ return c.forkOf;
779
+ };
780
+ const roots = [];
781
+ for (const c of convs) {
782
+ const parent = parentOf(c);
783
+ (parent ? nodes.get(parent).kids : roots).push(nodes.get(c.key));
784
+ }
785
+ const settle = (node) => {
786
+ for (const k of node.kids) node.at = Math.max(node.at, settle(k));
787
+ node.kids.sort((a, b) => b.at - a.at);
788
+ return node.at;
789
+ };
790
+ for (const r of roots) settle(r);
791
+ return roots.sort((a, b) => b.at - a.at);
792
+ }
793
+
794
+ /** 画一棵分叉树:主对话一行,分叉缩进挂在下面,用虚线连起来(样式见 .fork-node) */
795
+ function renderForkNode(node, row, cls = "") {
796
+ const self = row(node.c, !!node.c.forkOf && !node.parentShown);
797
+ if (node.kids.length === 0) return self;
798
+ return h(
799
+ "div",
800
+ { class: "fork-group" },
801
+ self,
802
+ h(
803
+ "div",
804
+ { class: `fork-children${cls}` },
805
+ node.kids.map((k) => h("div", { class: "fork-node" }, renderForkNode({ ...k, parentShown: true }, row))),
806
+ ),
807
+ );
808
+ }
809
+
810
+ /** 分叉出来的对话:提示里写上从哪条来的 */
811
+ function forkNote(c) {
812
+ if (!c.forkOf) return "";
813
+ const parent = S.convs.get(c.forkOf);
814
+ return `\n分叉自「${parent ? convTitle(parent) : "已删除的对话"}」`;
815
+ }
816
+
817
+ function convTitle(c) {
818
+ if (!c) return "新对话";
819
+ if (c.title) return c.title;
820
+ if (c.channel === "feishu") return `飞书会话 ${c.key.split(":")[2]?.slice(-6) ?? ""}`;
821
+ return "新对话";
822
+ }
823
+
824
+ function agentLabel(id) {
825
+ return S.agents.find((a) => a.id === id)?.label ?? id;
826
+ }
827
+
828
+ function agentShort(id) {
829
+ return id === "claude" ? "Claude" : id === "codex" ? "Codex" : id;
830
+ }
831
+
832
+ /** 侧栏里展开 / 收起的状态:哪些项目目录展开了、「最近」和各目录是否显示全部 */
833
+ const sideState = { open: [], more: [], folded: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
834
+
835
+ /** 「最近」「项目」分组标题:点击折叠 / 展开,hover 时显示箭头 */
836
+ function groupHead(label, id, folded) {
837
+ return h(
838
+ "button",
839
+ { class: `conv-group${folded ? " collapsed" : ""}`, title: folded ? "展开" : "折叠", onclick: () => toggleSide("folded", id) },
840
+ label,
841
+ h("span", { class: "chev", html: ICON.down }),
842
+ );
843
+ }
844
+ function toggleSide(list, id) {
845
+ const set = new Set(sideState[list]);
846
+ set.has(id) ? set.delete(id) : set.add(id);
847
+ sideState[list] = [...set];
848
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
849
+ renderSidebar();
850
+ }
851
+
852
+ let autoOpenedFor = null;
853
+ const RECENT_LIMIT = 10;
854
+ const PROJECT_LIMIT = 5;
855
+
856
+ function renderSidebar() {
857
+ if (!ui.convList) return;
858
+ const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
859
+ const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
860
+ const cur = S.current ? S.convs.get(S.current) : null;
861
+ const status = (keys) =>
862
+ keys.some((k) => askKeys.has(k))
863
+ ? h("span", { class: "dot ask", title: "等你处理" })
864
+ : keys.some((k) => S.convs.get(k)?.busy)
865
+ ? h("span", { class: "dot busy" })
866
+ : null;
867
+ const open = (key) => {
868
+ closeSide();
869
+ if (key !== S.current) void openConversation(key);
870
+ };
871
+ const more = (list, id, expanded) =>
872
+ h("button", { class: "conv-more", onclick: () => toggleSide(list, id) }, expanded ? "收起" : "展开显示");
873
+
874
+ // 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
875
+ const convRow = (c, cls, detail, orphan) =>
876
+ h(
877
+ "button",
878
+ {
879
+ class: `conv-item${cls}${c.key === S.current ? " active" : ""}`,
880
+ "data-key": c.key,
881
+ title: `${convTitle(c)}${forkNote(c)}\n${detail}\n双击重命名`,
882
+ onclick: () => open(c.key),
883
+ },
884
+ orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
885
+ h("span", { class: "title" }, convTitle(c)),
886
+ status([c.key]),
887
+ );
888
+
889
+ // ---- 最近:Web 会话 + 每个飞书机器人一行(点开它最近活跃的聊天),按活跃时间混排 ----
890
+ // 分叉出来的对话挂在主对话下面,整棵树按里面最近活跃的那条排
891
+ const recent = [];
892
+ for (const node of forkForest(visible.filter((c) => c.channel !== "feishu"))) {
893
+ recent.push({ at: node.at, el: renderForkNode(node, (c, orphan) => convRow(c, "", `${agentLabel(c.agent)} · ${c.cwd}`, orphan)) });
894
+ }
895
+ for (const bot of S.bots) {
896
+ const convs = visible.filter((c) => c.channel === "feishu" && c.channelId === bot.id);
897
+ if (bot.enabled === false && convs.length === 0) continue;
898
+ const latest = convs[0];
899
+ recent.push({
900
+ at: latest?.activeAt ?? 0,
901
+ el: h(
902
+ "button",
903
+ {
904
+ class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
905
+ title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
906
+ onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
907
+ },
908
+ h("span", { class: "brand-icon lead", title: "飞书", html: ICON.feishu }),
909
+ h("span", { class: "title" }, bot.name),
910
+ status(convs.map((c) => c.key)),
911
+ ),
912
+ });
913
+ }
914
+ recent.sort((a, b) => b.at - a.at);
915
+ const recentAll = sideState.more.includes("@recent");
916
+ const recentFolded = sideState.folded.includes("@recent");
917
+ const nodes = [groupHead("最近", "@recent", recentFolded)];
918
+ if (!recentFolded) {
919
+ if (recent.length === 0) nodes.push(h("div", { class: "conv-empty" }, "还没有会话"));
920
+ nodes.push(...recent.slice(0, recentAll ? recent.length : RECENT_LIMIT).map((r) => r.el));
921
+ if (recent.length > RECENT_LIMIT) nodes.push(more("more", "@recent", recentAll));
922
+ }
923
+
924
+ // ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
925
+ const byDir = new Map();
926
+ for (const c of visible.filter((c) => c.channel === "web")) {
927
+ if (!byDir.has(c.cwd)) byDir.set(c.cwd, []);
928
+ byDir.get(c.cwd).push(c);
929
+ }
930
+ // 切到某个会话时,把它所在的目录展开一次(之后用户可以自己收起)
931
+ if (cur?.channel === "web" && cur.key !== autoOpenedFor) {
932
+ autoOpenedFor = cur.key;
933
+ if (!sideState.open.includes(cur.cwd)) {
934
+ sideState.open.push(cur.cwd);
935
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
936
+ }
937
+ }
938
+ const projFolded = sideState.folded.includes("@projects");
939
+ if (byDir.size) nodes.push(groupHead("项目", "@projects", projFolded));
940
+ if (byDir.size && !projFolded) {
941
+ for (const [dir, convs] of byDir) {
942
+ const expanded = sideState.open.includes(dir);
943
+ const showAll = sideState.more.includes(dir);
944
+ nodes.push(
945
+ h(
946
+ "div",
947
+ { class: "proj-row" },
948
+ h(
949
+ "button",
950
+ { class: "conv-item proj", title: dir, onclick: () => toggleSide("open", dir) },
951
+ h("span", { class: "proj-icon", html: expanded ? ICON.folderOpen : ICON.folder }),
952
+ h("span", { class: "title" }, baseName(dir)),
953
+ expanded ? null : status(convs.map((c) => c.key)),
954
+ ),
955
+ h(
956
+ "button",
957
+ {
958
+ class: "proj-new",
959
+ title: `在 ${baseName(dir)} 中开新对话`,
960
+ onclick: () => {
961
+ S.draftCwd = dir === S.workspaceRoot ? null : dir;
962
+ newDraft();
963
+ closeSide();
964
+ },
965
+ },
966
+ icon("edit"),
967
+ ),
968
+ ),
969
+ );
970
+ if (!expanded) continue;
971
+ // 「展开显示」按主对话算条数,分叉跟着主对话一起出现
972
+ const forest = forkForest(convs);
973
+ for (const node of forest.slice(0, showAll ? forest.length : PROJECT_LIMIT)) {
974
+ nodes.push(renderForkNode(node, (c, orphan) => convRow(c, " sub", agentLabel(c.agent), orphan), " in-proj"));
975
+ }
976
+ if (forest.length > PROJECT_LIMIT) nodes.push(h("div", { class: "sub-more" }, more("more", dir, showAll)));
977
+ }
978
+ }
979
+ // 重绘会换掉 DOM:如果鼠标正停在某个标题上,重绘后接着滚
980
+ const hovered = marquee.el ? marquee.el.textContent : null;
981
+ stopMarquee();
982
+ fill(ui.convList, ...nodes);
983
+ if (hovered) {
984
+ requestAnimationFrame(() => {
985
+ const el = ui.convList.querySelector(".conv-item:hover .title");
986
+ if (el?.textContent === hovered) startMarquee(el);
987
+ });
988
+ }
989
+ const asks = askKeys.size;
990
+ document.title = asks ? `(${asks}) 待处理 · Agent Loft` : "Agent Loft";
991
+ }
992
+
993
+ // ---------- 顶栏 ----------
994
+
995
+ function renderTopbar() {
996
+ const c = S.current ? S.convs.get(S.current) : null;
997
+ const more = h("button", { class: "icon-btn", title: "更多", onclick: (e) => openMoreMenu(e.currentTarget) }, icon("more"));
998
+ fill(ui.topbar,
999
+ ...[
1000
+ h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
1001
+ h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
1002
+ c ? more : null,
1003
+ ].filter(Boolean),
1004
+ );
1005
+ renderContext();
1006
+ }
1007
+
1008
+ function baseName(p) {
1009
+ return (p ?? "").split(/[\\/]/).filter(Boolean).pop() || p || "";
1010
+ }
1011
+
1012
+ function currentCwd() {
1013
+ const c = S.current ? S.convs.get(S.current) : null;
1014
+ return c ? c.cwd : (S.draftCwd ?? S.workspaceRoot ?? "");
1015
+ }
1016
+
1017
+ function pickAgentMenu(anchor) {
1018
+ const allowed = S.agents.filter((a) => a.enabled).map((a) => a.id);
1019
+ if (allowed.length < 2) return;
1020
+ if (ui.menu?.dataset.for === "agent") return closeMenu();
1021
+ const pop = showPopover(anchor, { align: "left" });
1022
+ pop.dataset.for = "agent";
1023
+ fill(
1024
+ pop,
1025
+ allowed.map((id) =>
1026
+ h(
1027
+ "button",
1028
+ { class: `pop-item${id === S.draftAgent ? " on" : ""}`, onclick: () => (closeMenu(), switchAgent(id)) },
1029
+ agentIcon(id),
1030
+ h("span", { class: "grow" }, agentLabel(id)),
1031
+ id === S.draftAgent ? h("span", { class: "check" }, "✓") : null,
1032
+ ),
1033
+ ),
1034
+ );
1035
+ placePopover(pop, anchor);
1036
+ }
1037
+
1038
+ /** 新对话里选的模型 / 思考程度,按 agent 分开记,创建会话时带过去 */
1039
+ S.draftPick = JSON.parse(localStorage.getItem("aim.draftPick") || "{}");
1040
+ function draftPick(agent = S.draftAgent) {
1041
+ return (S.draftPick[agent] ??= {});
1042
+ }
1043
+ function saveDraftPick() {
1044
+ localStorage.setItem("aim.draftPick", JSON.stringify(S.draftPick));
1045
+ }
1046
+
1047
+ /** 各 agent 的模型列表(新对话用 + 把模型 id 翻成显示名),按 agent 缓存 */
1048
+ const catalogCache = new Map();
1049
+ /** 已经拿到的:agent → 模型列表 / 没显式选时实际用的 { model, effort } */
1050
+ const catalogLoaded = new Map();
1051
+ const catalogDefaults = new Map();
1052
+ function agentCatalog(agent) {
1053
+ if (!catalogCache.has(agent)) {
1054
+ const p = api(`/api/agents/${agent}/models`).then((d) => {
1055
+ catalogLoaded.set(agent, d.models ?? []);
1056
+ catalogDefaults.set(agent, d.defaults ?? {});
1057
+ return d.models ?? [];
1058
+ });
1059
+ p.catch(() => catalogCache.delete(agent));
1060
+ catalogCache.set(agent, p);
1061
+ }
1062
+ return catalogCache.get(agent);
1063
+ }
1064
+ /** 列表还没拿到就去拿,拿到后重画输入框的模型按钮 */
1065
+ function ensureCatalog(agent) {
1066
+ if (!agent || catalogLoaded.has(agent) || catalogCache.has(agent)) return;
1067
+ void agentCatalog(agent).then(() => renderContext()).catch(() => {});
1068
+ }
1069
+
1070
+ /** 设置里勾选的、输入框模型列表要显示的模型;没设置过(空)就全部显示 */
1071
+ function visibleModels(agent, models) {
1072
+ const allow = S.agents.find((a) => a.id === agent)?.models ?? [];
1073
+ if (!allow.length) return models;
1074
+ const shown = models.filter((m) => allow.includes(m.id));
1075
+ return shown.length ? shown : models;
1076
+ }
1077
+
1078
+ // Claude 的 id 有几种写法:别名(opus)、完整名(claude-opus-5-5、带日期的、带 [1m] 的)
1079
+ const bareModelId = (id) => String(id).replace(/\[[^\]]*\]$/, "").replace(/-\d{8}$/, "");
1080
+ /** 「Default (recommended)」那一项实际是哪个模型:Claude 写在说明的开头(「Opus 5.5 · …」) */
1081
+ function defaultEntryName(m) {
1082
+ const head = m.description?.split(" · ")[0]?.trim();
1083
+ return head && head.length <= 24 && head !== m.description ? head : null;
1084
+ }
1085
+ /**
1086
+ * 模型 id → 给人看的名字(Opus 5.5、GPT-6.1-Sol),和选择列表里的写法一致。
1087
+ * id 为空 = 没显式选,按 agent 的默认模型算。都对不上时尽量从 id 拼出来,实在不行才显示 id。
1088
+ */
1089
+ function modelName(agent, id) {
1090
+ const models = catalogLoaded.get(agent) ?? [];
1091
+ if (!id) id = catalogDefaults.get(agent)?.model;
1092
+ if (!id || id === "default") {
1093
+ const def = models.find((m) => m.id === "default") ?? models.find((m) => m.isDefault);
1094
+ if (!def) return null;
1095
+ return def.id === "default" ? (defaultEntryName(def) ?? def.label) : def.label || def.id;
1096
+ }
1097
+ const exact = models.find((m) => m.id === id) ?? models.find((m) => bareModelId(m.id) === bareModelId(id));
1098
+ if (exact) return exact.id === "default" ? (defaultEntryName(exact) ?? exact.label) : exact.label || exact.id;
1099
+ const claude = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?$/.exec(bareModelId(id));
1100
+ if (claude) return `${claude[1][0].toUpperCase()}${claude[1].slice(1)} ${claude[2]}${claude[3] ? `.${claude[3]}` : ""}`;
1101
+ return id;
1102
+ }
1103
+
1104
+ /** 输入框上方的上下文条(目录 / agent)和输入框里的模型按钮 */
1105
+ function renderContext() {
1106
+ if (!ui.ctxBar) return;
1107
+ const c = S.current ? S.convs.get(S.current) : null;
1108
+ const cwd = currentCwd();
1109
+ const multi = S.agents.filter((a) => a.enabled).length > 1;
1110
+ // 会话里不能换 agent(两个 agent 的会话互不相通),只显示;新对话里可以选
1111
+ const agentId = c ? c.agent : S.draftAgent;
1112
+ const agentEl = c || !multi
1113
+ ? h("span", { class: "ctx-item static" }, agentIcon(agentId), h("span", { class: "label" }, agentLabel(agentId ?? "") || "无可用 agent"))
1114
+ : h(
1115
+ "button",
1116
+ { class: "ctx-item", title: "选择 agent", onclick: (e) => pickAgentMenu(e.currentTarget) },
1117
+ agentIcon(agentId),
1118
+ h("span", { class: "label" }, agentLabel(agentId ?? "")),
1119
+ h("span", { class: "chev", html: ICON.down }),
1120
+ );
1121
+ fill(
1122
+ ui.ctxBar,
1123
+ h("button", { class: "ctx-item", title: `工作目录:${cwd}\n点击切换`, onclick: () => changeCwd() }, icon("folder"), h("span", { class: "label" }, baseName(cwd) || "选择目录")),
1124
+ agentEl,
1125
+ );
1126
+ updateCtxVisibility();
1127
+
1128
+ // 直接显示实际的模型名和思考程度(和选择列表里一样的写法),不显示「默认模型」
1129
+ let model;
1130
+ let effort;
1131
+ const modelAgent = c ? c.agent : S.draftAgent;
1132
+ if (modelAgent) {
1133
+ ensureCatalog(modelAgent);
1134
+ const defaults = catalogDefaults.get(modelAgent) ?? {};
1135
+ const pick = c ? null : draftPick();
1136
+ const id = c ? (c.runtimeModel ?? c.model) : pick.model;
1137
+ model = modelName(modelAgent, id) ?? (catalogLoaded.has(modelAgent) ? "选择模型" : "…");
1138
+ effort = (c ? (c.runtimeEffort ?? c.effort) : pick.effort) ?? defaults.effort;
1139
+ }
1140
+ renderCtxRing();
1141
+ fill(
1142
+ ui.modelSlot,
1143
+ model
1144
+ ? h(
1145
+ "button",
1146
+ { class: "model-pill", title: "切换模型和思考程度", onclick: () => void openModelPicker() },
1147
+ h("span", { class: "model-name" }, model),
1148
+ effort ? h("span", { class: "sub" }, effort) : null,
1149
+ icon("down"),
1150
+ )
1151
+ : null,
1152
+ );
1153
+ }
1154
+
1155
+ /** 84321 → 84k,1234567 → 1.2M */
1156
+ function fmtTokens(n) {
1157
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
1158
+ if (n >= 1000) return `${Math.round(n / 1000)}k`;
1159
+ return String(n);
1160
+ }
1161
+
1162
+ /** 输入框里的上下文窗口小圆环,hover 显示详情 */
1163
+ function renderCtxRing() {
1164
+ if (!ui.ctxRing) return;
1165
+ const c = S.current ? S.convs.get(S.current) : null;
1166
+ const ctx = c?.context;
1167
+ if (!ctx) return fill(ui.ctxRing);
1168
+ const pct = Math.max(0, Math.min(100, ctx.percent));
1169
+ const level = pct >= 90 ? "danger" : pct >= 75 ? "warn" : "";
1170
+ const r = 7;
1171
+ const len = 2 * Math.PI * r;
1172
+ fill(
1173
+ ui.ctxRing,
1174
+ h(
1175
+ "span",
1176
+ { class: `ctx-ring ${level}`, tabindex: "0", "aria-label": `上下文已用 ${Math.round(ctx.percent)}%` },
1177
+ h("span", {
1178
+ class: "ring",
1179
+ html: `<svg viewBox="0 0 18 18"><circle cx="9" cy="9" r="${r}" class="track"/><circle cx="9" cy="9" r="${r}" class="bar" stroke-dasharray="${((pct / 100) * len).toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 9 9)"/></svg>`,
1180
+ }),
1181
+ h(
1182
+ "span",
1183
+ { class: "tip" },
1184
+ h("span", { class: "tip-title" }, "上下文窗口:"),
1185
+ h("span", {}, `${Math.round(ctx.percent)}% 已用(剩余 ${Math.max(0, 100 - Math.round(ctx.percent))}%)`),
1186
+ h("span", {}, `已用 ${fmtTokens(ctx.used)} tokens,共 ${fmtTokens(ctx.window)}`),
1187
+ ),
1188
+ ),
1189
+ );
1190
+ }
1191
+
1192
+ /** 发出第一条消息、或会话里已经有内容后,就不再显示上下文条 */
1193
+ const sentKeys = new Set();
1194
+ function updateCtxVisibility() {
1195
+ if (ui.ctxBar) ui.ctxBar.hidden = !!S.current && (S.items.length > 0 || sentKeys.has(S.current));
1196
+ }
1197
+
1198
+ function showMenu(anchor, items) {
1199
+ closeMenu();
1200
+ const menu = h(
1201
+ "div",
1202
+ { class: "menu" },
1203
+ items.map((it) =>
1204
+ it === "sep"
1205
+ ? h("div", { class: "sep" })
1206
+ : h(
1207
+ "button",
1208
+ {
1209
+ class: it.danger ? "btn danger" : "",
1210
+ onclick: () => {
1211
+ closeMenu();
1212
+ it.onclick();
1213
+ },
1214
+ },
1215
+ it.label,
1216
+ ),
1217
+ ),
1218
+ );
1219
+ document.body.append(menu);
1220
+ const r = anchor.getBoundingClientRect();
1221
+ const w = menu.offsetWidth;
1222
+ menu.style.top = `${Math.min(r.bottom + 4, innerHeight - menu.offsetHeight - 8)}px`;
1223
+ menu.style.left = `${Math.max(8, Math.min(r.left, innerWidth - w - 8))}px`;
1224
+ ui.menu = menu;
1225
+ setTimeout(() => document.addEventListener("pointerdown", onOutsideMenu), 0);
1226
+ }
1227
+
1228
+ function onOutsideMenu(e) {
1229
+ // 点的是打开它的那个按钮:交给按钮自己的 onclick 去收起,免得先关再开
1230
+ if (ui.menu && !ui.menu.contains(e.target) && !ui.menu.anchor?.contains(e.target)) closeMenu();
1231
+ }
1232
+
1233
+ function closeMenu() {
1234
+ ui.menu?.remove();
1235
+ ui.menu = null;
1236
+ document.removeEventListener("pointerdown", onOutsideMenu);
1237
+ }
1238
+
1239
+ function openMoreMenu(anchor) {
1240
+ const c = S.convs.get(S.current);
1241
+ if (!c) return;
1242
+ showMenu(anchor, [
1243
+ { label: "历史会话…", onclick: () => openSessions(false) },
1244
+ { label: "开新会话(清空上下文)", onclick: () => doAction("new") },
1245
+ c.canFork
1246
+ ? { label: "分叉成新对话", onclick: () => forkConversation() }
1247
+ : { label: "分叉当前会话", onclick: () => doAction("fork") },
1248
+ { label: "切换工作目录…", onclick: () => changeCwd() },
1249
+ { label: "查看状态", onclick: () => showStatus() },
1250
+ "sep",
1251
+ { label: "重命名", onclick: () => renameConv(S.current) },
1252
+ ...(c.channel === "web" ? [{ label: "从列表删除", danger: true, onclick: () => deleteConv() }] : []),
1253
+ ]);
1254
+ }
1255
+
1256
+ // ---------- 打开会话 ----------
1257
+
1258
+ function newDraft() {
1259
+ if (S.current) WS.raw({ t: "unsub", key: S.current });
1260
+ S.current = null;
1261
+ S.items = [];
1262
+ S.turnAi = null;
1263
+ S.uploads = [];
1264
+ setBusy(false);
1265
+ history.replaceState(null, "", location.pathname);
1266
+ WS.presence();
1267
+ renderTopbar();
1268
+ renderEmpty();
1269
+ renderChips();
1270
+ renderSidebar();
1271
+ ui.input?.focus();
1272
+ }
1273
+
1274
+ function renderEmpty() {
1275
+ const enabled = S.agents.filter((a) => a.enabled);
1276
+ const cwd = S.draftCwd ?? S.workspaceRoot ?? "";
1277
+ fill(ui.msgInner,
1278
+ h(
1279
+ "div",
1280
+ { class: "empty hero" },
1281
+ agentIcon(S.draftAgent, "hero-logo"),
1282
+ enabled.length
1283
+ ? h(
1284
+ "h2",
1285
+ {},
1286
+ "你想让我们在 ",
1287
+ h("button", { class: "hero-dir", title: `${cwd}\n点击切换工作目录`, onclick: () => changeCwd() }, baseName(cwd) || "工作区"),
1288
+ " 中构建什么?",
1289
+ )
1290
+ : [h("h2", {}, "有什么可以帮你?"), h("div", {}, "还没有启用任何 agent,先去设置里启用。")],
1291
+ ),
1292
+ );
1293
+ renderContext();
1294
+ }
1295
+
1296
+ async function openConversation(key) {
1297
+ const token = ++S.loadToken;
1298
+ if (S.current && S.current !== key) WS.raw({ t: "unsub", key: S.current });
1299
+ S.current = key;
1300
+ S.items = [];
1301
+ S.turnAi = null;
1302
+ S.uploads = [];
1303
+ renderChips();
1304
+ history.replaceState(null, "", `#${encodeURIComponent(key)}`);
1305
+ WS.presence();
1306
+ renderTopbar();
1307
+ renderSidebar();
1308
+ fill(ui.msgInner, h("div", { class: "empty" }, h("span", { class: "spinner" })));
1309
+ let data;
1310
+ for (let attempt = 0; ; attempt++) {
1311
+ try {
1312
+ data = await api(`/api/conversations/${encodeURIComponent(key)}/history`);
1313
+ break;
1314
+ } catch (err) {
1315
+ if (token !== S.loadToken) return;
1316
+ // 503 = 会话正忙着变化,服务端拿不到一致的快照:等一下再拉
1317
+ if (err.status === 503 && attempt < 10) {
1318
+ await new Promise((r) => setTimeout(r, 500 + attempt * 300));
1319
+ if (token !== S.loadToken) return;
1320
+ continue;
1321
+ }
1322
+ fill(ui.msgInner, h("div", { class: "empty" }, err.message));
1323
+ return;
1324
+ }
1325
+ }
1326
+ if (token !== S.loadToken) return; // 期间又切到别的会话了
1327
+ S.convs.set(key, data.info);
1328
+ S.allowedAgents = data.allowedAgents;
1329
+ fill(ui.msgInner, );
1330
+ for (const m of data.messages) {
1331
+ if (m.role === "user") addItem({ type: "user", text: m.text, files: [], origin: null });
1332
+ else addItem({ type: "ai", steps: m.steps ?? [], committed: m.text, partial: "", done: true, footer: m.footer, at: m.ts, anchor: m.anchor });
1333
+ }
1334
+ S.seq.set(key, 0);
1335
+ for (const env of data.live) applyEvent(env);
1336
+ S.seq.set(key, Math.max(S.seq.get(key) ?? 0, data.seq));
1337
+ for (const ix of data.pending) upsertInteraction(ix);
1338
+ if (S.items.length === 0) {
1339
+ ui.msgInner.append(
1340
+ h(
1341
+ "div",
1342
+ { class: "empty" },
1343
+ h("h2", {}, convTitle(data.info)),
1344
+ h("div", {}, data.info.sessionId ? "这条会话暂时读不到历史记录,直接接着说就行。" : `${agentLabel(data.info.agent)} · ${data.info.cwd}`),
1345
+ ),
1346
+ );
1347
+ }
1348
+ setBusy(data.info.busy);
1349
+ renderTopbar();
1350
+ WS.subscribe(key, S.seq.get(key));
1351
+ scrollToBottom(true);
1352
+ }
1353
+
1354
+ // ---------- 消息渲染 ----------
1355
+
1356
+ function nearBottom() {
1357
+ const m = ui.messages;
1358
+ return m.scrollHeight - m.scrollTop - m.clientHeight < 120;
1359
+ }
1360
+
1361
+ function scrollToBottom(force) {
1362
+ if (force || ui.stick) ui.messages.scrollTop = ui.messages.scrollHeight;
1363
+ }
1364
+
1365
+ /** 只有最后一条回复默认显示底部的操作栏(复制 / 分叉 / 用时…),前面的 hover 才出来 */
1366
+ function markLatestAi() {
1367
+ let latest = null;
1368
+ for (let i = S.items.length - 1; i >= 0; i--) {
1369
+ const it = S.items[i];
1370
+ if (it.type === "ai" && it.done && !it.segment) {
1371
+ latest = it;
1372
+ break;
1373
+ }
1374
+ }
1375
+ for (const it of S.items) if (it.type === "ai") it.el?.classList.toggle("latest", it === latest);
1376
+ }
1377
+
1378
+ function addItem(item) {
1379
+ ui.msgInner.querySelector(".empty")?.remove();
1380
+ const stick = nearBottom();
1381
+ item.el = renderItem(item);
1382
+ ui.msgInner.append(item.el);
1383
+ S.items.push(item);
1384
+ markLatestAi();
1385
+ updateCtxVisibility();
1386
+ if (stick) scrollToBottom(true);
1387
+ return item;
1388
+ }
1389
+
1390
+ const dirty = new Set();
1391
+ let flushScheduled = false;
1392
+ function updateItem(item) {
1393
+ dirty.add(item);
1394
+ if (flushScheduled) return;
1395
+ flushScheduled = true;
1396
+ requestAnimationFrame(() => {
1397
+ flushScheduled = false;
1398
+ const stick = nearBottom();
1399
+ for (const it of dirty) {
1400
+ if (!it.el?.isConnected) continue;
1401
+ const next = renderItem(it);
1402
+ it.el.replaceWith(next);
1403
+ it.el = next;
1404
+ }
1405
+ dirty.clear();
1406
+ markLatestAi();
1407
+ if (stick) scrollToBottom(true);
1408
+ });
1409
+ }
1410
+
1411
+ function renderItem(item) {
1412
+ switch (item.type) {
1413
+ case "user":
1414
+ return h(
1415
+ "div",
1416
+ { class: "msg-user" },
1417
+ item.origin && item.origin.channel !== "web" ? h("span", { class: "origin" }, `来自${item.origin.name ?? item.origin.channel}`) : null,
1418
+ item.text,
1419
+ item.files?.length ? h("div", { class: "files" }, `📎 ${item.files.join("、")}`) : null,
1420
+ );
1421
+ case "ai":
1422
+ return renderAi(item);
1423
+ case "notice":
1424
+ return h("div", { class: "notice" }, renderMarkdown(item.text));
1425
+ case "error":
1426
+ return h("div", { class: "error-box" }, item.text);
1427
+ case "ix":
1428
+ return renderInteraction(item);
1429
+ }
1430
+ return h("div");
1431
+ }
1432
+
1433
+ function renderAi(item) {
1434
+ const body = item.committed + (item.partial ? (item.committed ? "\n\n" : "") + item.partial : "");
1435
+ const working = !item.done;
1436
+ const steps =
1437
+ item.steps.length > 0
1438
+ ? h(
1439
+ "details",
1440
+ {
1441
+ class: "steps",
1442
+ open: item.stepsOpen ?? false,
1443
+ ontoggle: (e) => (item.stepsOpen = e.currentTarget.open),
1444
+ },
1445
+ h(
1446
+ "summary",
1447
+ {},
1448
+ working ? h("span", { class: "spinner" }) : null,
1449
+ working
1450
+ ? `正在处理… ${item.steps[item.steps.length - 1]}`
1451
+ : `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
1452
+ ),
1453
+ h("ol", {}, item.steps.map((s) => h("li", {}, s))),
1454
+ )
1455
+ : null;
1456
+ return h(
1457
+ "div",
1458
+ { class: "msg-ai" },
1459
+ steps,
1460
+ body ? renderMarkdown(body) : working && item.steps.length === 0 ? h("div", { class: "thinking" }, h("span", { class: "spinner" }), "思考中…") : null,
1461
+ item.error ? h("div", { class: "error-box" }, item.error) : null,
1462
+ item.done && !item.segment && (body || item.footer)
1463
+ ? h(
1464
+ "div",
1465
+ { class: "ai-actions" },
1466
+ body ? h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(body) }, icon("copy")) : null,
1467
+ item.anchor && S.convs.get(S.current)?.canFork
1468
+ ? h("button", { class: "icon-btn", title: "从这里分叉成新对话", onclick: () => forkConversation(item.anchor) }, icon("fork"))
1469
+ : null,
1470
+ item.footer || item.at
1471
+ ? h("span", { class: "footer-note" }, [item.footer, item.at ? clockTime(item.at) : ""].filter(Boolean).join(" "))
1472
+ : null,
1473
+ )
1474
+ : null,
1475
+ );
1476
+ }
1477
+
1478
+ function currentAi() {
1479
+ if (!S.turnAi || S.turnAi.done) {
1480
+ S.turnAi = addItem({ type: "ai", steps: [], committed: "", partial: "", done: false });
1481
+ }
1482
+ return S.turnAi;
1483
+ }
1484
+
1485
+ function applyEvent(env) {
1486
+ if (env.key !== S.current) return;
1487
+ const last = S.seq.get(env.key) ?? 0;
1488
+ if (env.seq <= last) return;
1489
+ S.seq.set(env.key, env.seq);
1490
+ const ev = env.ev;
1491
+ switch (ev.type) {
1492
+ case "user": {
1493
+ if (S.turnAi && !S.turnAi.done && S.turnAi.steps.length === 0 && !S.turnAi.committed) {
1494
+ // 上一个占位还空着(比如消息被当成了作答),不再多留一个
1495
+ S.turnAi.el.remove();
1496
+ S.items = S.items.filter((i) => i !== S.turnAi);
1497
+ S.turnAi = null;
1498
+ }
1499
+ addItem({ type: "user", text: ev.text, files: ev.attachments, origin: ev.origin });
1500
+ currentAi();
1501
+ return;
1502
+ }
1503
+ case "step": {
1504
+ const ai = currentAi();
1505
+ ai.steps.push(ev.text);
1506
+ updateItem(ai);
1507
+ return;
1508
+ }
1509
+ case "delta": {
1510
+ const ai = currentAi();
1511
+ ai.partial += ev.text;
1512
+ updateItem(ai);
1513
+ return;
1514
+ }
1515
+ case "text": {
1516
+ const ai = currentAi();
1517
+ ai.committed += (ai.committed ? "\n\n" : "") + ev.text;
1518
+ ai.partial = "";
1519
+ updateItem(ai);
1520
+ return;
1521
+ }
1522
+ case "result": {
1523
+ const ai = currentAi();
1524
+ if (!ai.committed && ev.text) ai.committed = ev.text;
1525
+ ai.partial = "";
1526
+ ai.done = true;
1527
+ ai.footer = ev.footer;
1528
+ ai.at = ev.at;
1529
+ ai.anchor = ev.anchor;
1530
+ updateItem(ai);
1531
+ S.turnAi = null;
1532
+ return;
1533
+ }
1534
+ case "error": {
1535
+ if (S.turnAi && !S.turnAi.done) {
1536
+ S.turnAi.done = true;
1537
+ S.turnAi.error = ev.message;
1538
+ updateItem(S.turnAi);
1539
+ S.turnAi = null;
1540
+ } else addItem({ type: "error", text: ev.message });
1541
+ return;
1542
+ }
1543
+ case "notice":
1544
+ addItem({ type: "notice", text: ev.text });
1545
+ return;
1546
+ case "state":
1547
+ setBusy(ev.busy);
1548
+ if (!ev.busy && S.turnAi && !S.turnAi.done) {
1549
+ S.turnAi.done = true;
1550
+ updateItem(S.turnAi);
1551
+ S.turnAi = null;
1552
+ }
1553
+ return;
1554
+ case "meta":
1555
+ return;
1556
+ }
1557
+ }
1558
+
1559
+ // ---------- 交互(授权 / 提问) ----------
1560
+
1561
+ function upsertInteraction(ix) {
1562
+ // 只进不退:本地已经知道它处理完了,晚到的旧快照(还写着待处理)不能把它复活
1563
+ const known = S.pending.get(ix.id);
1564
+ if (known?.settled && !ix.settled) return;
1565
+ S.pending.set(ix.id, ix);
1566
+ if (ix.settled) setTimeout(() => S.pending.get(ix.id)?.settled && S.pending.delete(ix.id), 60000);
1567
+ scheduleSidebar();
1568
+ if (ix.convKey !== S.current) {
1569
+ if (!ix.settled && document.visibilityState === "visible") {
1570
+ const c = S.convs.get(ix.convKey);
1571
+ toast(`「${convTitle(c)}」在等你处理:${ix.spec.title}`, 4000);
1572
+ }
1573
+ return;
1574
+ }
1575
+ const existing = S.items.find((i) => i.type === "ix" && i.id === ix.id);
1576
+ if (existing) {
1577
+ existing.ix = ix;
1578
+ updateItem(existing);
1579
+ return;
1580
+ }
1581
+ if (ix.settled) return;
1582
+ // 卡片插在这一轮中间:之前的输出先封口,之后的输出接在卡片下面,顺序才对
1583
+ if (S.turnAi && !S.turnAi.done) {
1584
+ S.turnAi.done = true;
1585
+ S.turnAi.segment = true;
1586
+ updateItem(S.turnAi);
1587
+ S.turnAi = null;
1588
+ }
1589
+ addItem({ type: "ix", id: ix.id, ix });
1590
+ notifyIfHidden(ix);
1591
+ }
1592
+
1593
+ function notifyIfHidden(ix) {
1594
+ if (document.visibilityState === "visible" || !("Notification" in window)) return;
1595
+ if (Notification.permission === "granted") {
1596
+ try {
1597
+ new Notification("Agent Loft 需要你处理", { body: ix.spec.title, tag: ix.id });
1598
+ } catch {
1599
+ // 某些浏览器只允许在 SW 里发
1600
+ }
1601
+ }
1602
+ }
1603
+
1604
+ async function ixOp(id, op, extra = {}) {
1605
+ try {
1606
+ await WS.request({ t: "ix", id, op, ...extra });
1607
+ } catch (err) {
1608
+ toast(err.message);
1609
+ }
1610
+ }
1611
+
1612
+ const SETTLE_LABEL = {
1613
+ "allow-once": "已允许(本次)",
1614
+ "allow-always": "已允许(总是)",
1615
+ "allow-session": "已允许(本会话全部放行)",
1616
+ deny: "已拒绝",
1617
+ timeout: "已超时,按拒绝处理",
1618
+ cancelled: "已取消",
1619
+ answered: "已回答",
1620
+ skipped: "已跳过",
1621
+ gone: "已处理",
1622
+ };
1623
+
1624
+ function renderInteraction(item) {
1625
+ const ix = item.ix;
1626
+ const spec = ix.spec;
1627
+ const settled = ix.settled;
1628
+ if (spec.kind === "approval") {
1629
+ return h(
1630
+ "div",
1631
+ { class: `ix-card${settled ? " settled" : ""}` },
1632
+ h("div", { class: "ix-title" }, settled ? "" : "⚠️", spec.title),
1633
+ h("div", { class: "ix-detail" }, renderMarkdown(spec.detail)),
1634
+ settled
1635
+ ? h("div", { class: "ix-meta" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
1636
+ : h(
1637
+ "div",
1638
+ { class: "ix-buttons" },
1639
+ h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
1640
+ spec.alwaysLabel
1641
+ ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
1642
+ : null,
1643
+ spec.allowSession
1644
+ ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
1645
+ : null,
1646
+ h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
1647
+ ),
1648
+ );
1649
+ }
1650
+ // 提问
1651
+ const needsSubmit = spec.questions.some((q) => q.multiSelect);
1652
+ return h(
1653
+ "div",
1654
+ { class: `ix-card ask${settled ? " settled" : ""}` },
1655
+ h("div", { class: "ix-title" }, settled ? "" : "❓", spec.title),
1656
+ spec.questions.map((q, qi) => {
1657
+ const picked = ix.selections[qi] ?? [];
1658
+ const free = ix.freeText?.[qi];
1659
+ return h(
1660
+ "div",
1661
+ { class: "ix-q" },
1662
+ h("div", { class: "q-head" }, h("b", {}, q.header), q.question, q.multiSelect ? "(可多选)" : ""),
1663
+ q.options.map((opt, oi) => {
1664
+ const on = picked.includes(oi);
1665
+ return h(
1666
+ "button",
1667
+ {
1668
+ class: `opt${on ? " on" : ""}`,
1669
+ disabled: !!settled,
1670
+ onclick: () => ixOp(ix.id, "select", { q: qi, o: oi, v: q.multiSelect ? !on : true }),
1671
+ },
1672
+ `${on ? "✓ " : ""}${opt.label}`,
1673
+ opt.description ? h("small", {}, opt.description) : null,
1674
+ );
1675
+ }),
1676
+ free ? h("div", { class: "ix-meta" }, `→ ${free}`) : null,
1677
+ );
1678
+ }),
1679
+ settled
1680
+ ? h("div", { class: "ix-meta" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
1681
+ : h(
1682
+ "div",
1683
+ { class: "ix-buttons" },
1684
+ needsSubmit ? h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "submit") }, "提交") : null,
1685
+ h("button", { class: "btn", onclick: () => ixOp(ix.id, "skip") }, "跳过"),
1686
+ h("span", { class: "ix-meta" }, "都不合适?直接在下面输入框里回答"),
1687
+ ),
1688
+ );
1689
+ }
1690
+
1691
+ // ---------- 输入框 ----------
1692
+
1693
+ function renderComposer() {
1694
+ cancelVoice();
1695
+ ui.input = h("textarea", {
1696
+ rows: 1,
1697
+ placeholder: "随心输入",
1698
+ oninput: () => {
1699
+ // 识别过程中用户自己动了输入框,以用户为准,停掉识别
1700
+ cancelVoice();
1701
+ autosize();
1702
+ void updateSlashMenu();
1703
+ },
1704
+ onblur: () => setTimeout(closeSlashMenu, 150),
1705
+ onkeydown: (e) => {
1706
+ if (slashKeydown(e)) {
1707
+ e.preventDefault();
1708
+ return;
1709
+ }
1710
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing && !isTouch) {
1711
+ e.preventDefault();
1712
+ void sendMessage();
1713
+ }
1714
+ },
1715
+ onpaste: (e) => {
1716
+ const files = [...(e.clipboardData?.files ?? [])];
1717
+ if (files.length) {
1718
+ e.preventDefault();
1719
+ void addFiles(files);
1720
+ }
1721
+ },
1722
+ });
1723
+ ui.fileInput = h("input", {
1724
+ type: "file",
1725
+ multiple: true,
1726
+ style: { display: "none" },
1727
+ onchange: (e) => {
1728
+ void addFiles([...e.target.files]);
1729
+ e.target.value = "";
1730
+ },
1731
+ });
1732
+ ui.sendBtn = h("button", { class: "send-btn", title: "发送", onclick: () => (S.busy && !hasDraft() ? stopTurn() : sendMessage()) });
1733
+ ui.micBtn = SpeechRec
1734
+ ? h("button", { class: "icon-btn mic-btn", title: "语音输入", onclick: toggleVoice }, icon("mic"))
1735
+ : null;
1736
+ ui.ctxBar = h("div", { class: "ctx-bar" });
1737
+ ui.modelSlot = h("span", { class: "model-slot" });
1738
+ ui.ctxRing = h("span", { class: "ctx-ring-slot" });
1739
+ ui.chips = h("div", { class: "chips" });
1740
+ ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
1741
+ ui.composer = h(
1742
+ "div",
1743
+ {
1744
+ class: "composer",
1745
+ ondragover: (e) => {
1746
+ e.preventDefault();
1747
+ ui.composer.classList.add("drag");
1748
+ },
1749
+ ondragleave: () => ui.composer.classList.remove("drag"),
1750
+ ondrop: (e) => {
1751
+ e.preventDefault();
1752
+ ui.composer.classList.remove("drag");
1753
+ void addFiles([...(e.dataTransfer?.files ?? [])]);
1754
+ },
1755
+ },
1756
+ ui.slashMenu,
1757
+ ui.chips,
1758
+ ui.input,
1759
+ h(
1760
+ "div",
1761
+ { class: "composer-row" },
1762
+ h("button", { class: "icon-btn add-btn", title: "添加附件", onclick: () => ui.fileInput.click() }, icon("plus")),
1763
+ ui.fileInput,
1764
+ h("span", { class: "spacer" }),
1765
+ ui.ctxRing,
1766
+ ui.modelSlot,
1767
+ ui.micBtn,
1768
+ ui.sendBtn,
1769
+ ),
1770
+ );
1771
+ fill(ui.composerWrap,
1772
+ h("div", { class: "composer-box" }, ui.ctxBar, ui.composer),
1773
+ );
1774
+ ui.input.addEventListener("input", updateSendBtn);
1775
+ updateSendBtn();
1776
+ renderContext();
1777
+ }
1778
+
1779
+ // ---------- 语音输入(浏览器自带 Web Speech API) ----------
1780
+ // Chrome / Edge 走各自厂商的在线识别服务,Safari 走系统识别;Firefox 没有这个 API,按钮直接不显示。
1781
+ // 必须是安全上下文(HTTPS 或 localhost)才能拿到麦克风。
1782
+
1783
+ const SpeechRec = window.SpeechRecognition ?? window.webkitSpeechRecognition;
1784
+ // Android Chrome 的 continuous 模式会把已确认的结果重复回吐,改成单句识别、说完自动续上
1785
+ const voiceContinuous = !/Android/i.test(navigator.userAgent);
1786
+
1787
+ const VOICE_ERR = {
1788
+ "not-allowed": "没有麦克风权限(需要 HTTPS,并在浏览器里允许麦克风)",
1789
+ "service-not-allowed": "浏览器不允许使用语音识别服务",
1790
+ "audio-capture": "没找到可用的麦克风",
1791
+ network: "语音识别需要联网,连不上识别服务",
1792
+ "language-not-supported": "不支持当前语言的语音识别",
1793
+ };
1794
+
1795
+ /** rec:当前识别实例;before/after:开始时光标前后的文字;committed:已确认的识别结果 */
1796
+ const voice = { rec: null, want: false, before: "", after: "", committed: "", target: null };
1797
+
1798
+ function toggleVoice() {
1799
+ if (voice.rec) stopVoice();
1800
+ else startVoice();
1801
+ }
1802
+
1803
+ function startVoice() {
1804
+ if (!SpeechRec) return toast("当前浏览器不支持语音输入");
1805
+ const ta = ui.input;
1806
+ voice.target = ta;
1807
+ voice.before = ta.value.slice(0, ta.selectionStart);
1808
+ voice.after = ta.value.slice(ta.selectionEnd);
1809
+ voice.committed = "";
1810
+ voice.want = true;
1811
+ try {
1812
+ listenOnce();
1813
+ } catch (err) {
1814
+ voice.rec = null;
1815
+ voice.want = false;
1816
+ toast(`无法启动语音识别:${err.message}`);
1817
+ }
1818
+ updateMicBtn();
1819
+ }
1820
+
1821
+ function listenOnce() {
1822
+ const rec = new SpeechRec();
1823
+ rec.lang = navigator.language || "zh-CN";
1824
+ rec.continuous = voiceContinuous;
1825
+ rec.interimResults = true;
1826
+ let heard = "";
1827
+ rec.onresult = (e) => {
1828
+ let fin = "";
1829
+ let interim = "";
1830
+ for (const r of e.results) {
1831
+ if (r.isFinal) fin += r[0].transcript;
1832
+ else interim += r[0].transcript;
1833
+ }
1834
+ heard = fin;
1835
+ showVoiceText(joinVoice(voice.committed, fin + interim));
1836
+ };
1837
+ rec.onerror = (e) => {
1838
+ if (e.error === "no-speech" || e.error === "aborted") return;
1839
+ voice.want = false;
1840
+ toast(VOICE_ERR[e.error] ?? `语音识别出错:${e.error}`);
1841
+ };
1842
+ rec.onend = () => {
1843
+ if (voice.rec !== rec) return; // 已被取消
1844
+ voice.committed = joinVoice(voice.committed, heard);
1845
+ showVoiceText(voice.committed);
1846
+ // 这一轮什么都没听到就当说完了,避免麦克风一直开着
1847
+ if (voice.want && heard) {
1848
+ try {
1849
+ listenOnce();
1850
+ return;
1851
+ } catch {
1852
+ // 续不上就结束
1853
+ }
1854
+ }
1855
+ voice.rec = null;
1856
+ voice.want = false;
1857
+ updateMicBtn();
1858
+ };
1859
+ voice.rec = rec;
1860
+ rec.start();
1861
+ }
1862
+
1863
+ /** 用户点停止:让浏览器把最后一句定稿后再结束 */
1864
+ function stopVoice() {
1865
+ voice.want = false;
1866
+ voice.rec?.stop();
1867
+ }
1868
+
1869
+ /** 发送、切会话、用户手动改输入框时:立刻丢掉识别,不再往输入框写 */
1870
+ function cancelVoice() {
1871
+ const rec = voice.rec;
1872
+ if (!rec) return;
1873
+ voice.rec = null;
1874
+ voice.want = false;
1875
+ rec.abort();
1876
+ updateMicBtn();
1877
+ }
1878
+
1879
+ /** 中日韩直接拼接,其它语言之间补空格 */
1880
+ function joinVoice(a, b) {
1881
+ if (!a || !b) return a + b;
1882
+ if (/\s$/.test(a) || /^\s/.test(b) || /^(zh|ja|ko)/i.test(navigator.language)) return a + b;
1883
+ return `${a} ${b}`;
1884
+ }
1885
+
1886
+ function showVoiceText(text) {
1887
+ const ta = voice.target;
1888
+ if (!ta || ta !== ui.input) return;
1889
+ ta.value = voice.before + text + voice.after;
1890
+ const caret = voice.before.length + text.length;
1891
+ ta.setSelectionRange(caret, caret);
1892
+ autosize();
1893
+ updateSendBtn();
1894
+ }
1895
+
1896
+ function updateMicBtn() {
1897
+ if (!ui.micBtn) return;
1898
+ const on = !!voice.rec;
1899
+ ui.micBtn.classList.toggle("recording", on);
1900
+ ui.micBtn.title = on ? "停止语音输入" : "语音输入";
1901
+ }
1902
+
1903
+ function autosize() {
1904
+ ui.input.style.height = "auto";
1905
+ ui.input.style.height = `${Math.min(ui.input.scrollHeight, innerHeight * 0.4)}px`;
1906
+ }
1907
+
1908
+ function hasDraft() {
1909
+ return !!ui.input?.value.trim() || S.uploads.length > 0;
1910
+ }
1911
+
1912
+ function updateSendBtn() {
1913
+ if (!ui.sendBtn) return;
1914
+ const stop = S.busy && !hasDraft();
1915
+ ui.sendBtn.innerHTML = stop ? ICON.stop : ICON.send;
1916
+ ui.sendBtn.title = stop ? "停止" : "发送";
1917
+ ui.sendBtn.disabled = !stop && !hasDraft();
1918
+ }
1919
+
1920
+ function setBusy(busy) {
1921
+ S.busy = !!busy;
1922
+ updateSendBtn();
1923
+ }
1924
+
1925
+ function renderChips() {
1926
+ if (!ui.chips) return;
1927
+ fill(ui.chips,
1928
+ ...S.uploads.map((u) =>
1929
+ h(
1930
+ "span",
1931
+ { class: "chip" },
1932
+ u.uploading ? h("span", { class: "spinner" }) : u.kind === "image" ? "🖼" : "📄",
1933
+ h("span", {}, u.name),
1934
+ h(
1935
+ "button",
1936
+ {
1937
+ title: "移除",
1938
+ onclick: () => {
1939
+ S.uploads = S.uploads.filter((x) => x !== u);
1940
+ renderChips();
1941
+ },
1942
+ },
1943
+ "×",
1944
+ ),
1945
+ ),
1946
+ ),
1947
+ );
1948
+ updateSendBtn();
1949
+ }
1950
+
1951
+ async function ensureConversation() {
1952
+ if (S.current) return S.current;
1953
+ if (!S.draftAgent) throw new Error("没有可用的 agent");
1954
+ let info;
1955
+ try {
1956
+ info = await api("/api/conversations", { method: "POST", body: { agent: S.draftAgent, cwd: S.draftCwd ?? undefined } });
1957
+ } catch (err) {
1958
+ // 选的目录没了(被删、或工作区改了):退回根目录,让用户重新确认
1959
+ if (err.status === 400 && S.draftCwd) {
1960
+ S.draftCwd = null;
1961
+ renderEmpty();
1962
+ }
1963
+ throw err;
1964
+ }
1965
+ // 新对话里选过模型 / 思考程度:建好会话后先切过去再发消息
1966
+ const pick = { ...draftPick() };
1967
+ for (const [action, value] of [["model", pick.model], ["effort", pick.effort]]) {
1968
+ if (!value) continue;
1969
+ try {
1970
+ const res = await api(`/api/conversations/${encodeURIComponent(info.key)}/action`, { method: "POST", body: { action, value } });
1971
+ info = res.info;
1972
+ } catch (err) {
1973
+ toast(`${action === "model" ? "切换模型" : "切换思考程度"}失败:${err.message}`);
1974
+ }
1975
+ }
1976
+ S.convs.set(info.key, info);
1977
+ S.current = info.key;
1978
+ S.allowedAgents = S.agents.filter((a) => a.enabled).map((a) => a.id);
1979
+ S.seq.set(info.key, 0);
1980
+ history.replaceState(null, "", `#${encodeURIComponent(info.key)}`);
1981
+ WS.presence();
1982
+ fill(ui.msgInner, );
1983
+ WS.subscribe(info.key, 0);
1984
+ renderTopbar();
1985
+ renderSidebar();
1986
+ return info.key;
1987
+ }
1988
+
1989
+ async function addFiles(files) {
1990
+ if (!files.length) return;
1991
+ let key;
1992
+ try {
1993
+ key = await ensureConversation();
1994
+ } catch (err) {
1995
+ toast(err.message);
1996
+ return;
1997
+ }
1998
+ for (const file of files) {
1999
+ const u = { name: file.name || "粘贴的图片.png", kind: file.type.startsWith("image/") ? "image" : "file", uploading: true };
2000
+ S.uploads.push(u);
2001
+ renderChips();
2002
+ try {
2003
+ const res = await api(`/api/conversations/${encodeURIComponent(key)}/upload?name=${encodeURIComponent(u.name)}`, {
2004
+ method: "POST",
2005
+ body: file,
2006
+ headers: { "Content-Type": file.type || "application/octet-stream" },
2007
+ });
2008
+ Object.assign(u, { id: res.id, kind: res.kind, uploading: false });
2009
+ } catch (err) {
2010
+ S.uploads = S.uploads.filter((x) => x !== u);
2011
+ toast(`${u.name} 上传失败:${err.message}`);
2012
+ }
2013
+ renderChips();
2014
+ }
2015
+ }
2016
+
2017
+ async function sendMessage() {
2018
+ cancelVoice();
2019
+ const text = ui.input.value;
2020
+ if (!text.trim() && S.uploads.length === 0) return;
2021
+ if (S.uploads.some((u) => u.uploading)) {
2022
+ toast("附件还在上传,稍等");
2023
+ return;
2024
+ }
2025
+ closeSlashMenu();
2026
+ let key;
2027
+ let res;
2028
+ try {
2029
+ key = await ensureConversation();
2030
+ if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
2031
+ // 命令(比如 /status 要现读额度)可能比普通发送慢,多给点时间
2032
+ res = await WS.request({ t: "send", key, text, uploads: S.uploads.map((u) => u.id) }, 45000);
2033
+ } catch (err) {
2034
+ toast(err.message);
2035
+ return;
2036
+ }
2037
+ if (!res?.reply) {
2038
+ sentKeys.add(key);
2039
+ updateCtxVisibility();
2040
+ }
2041
+ ui.input.value = "";
2042
+ autosize();
2043
+ S.uploads = [];
2044
+ renderChips();
2045
+ updateSendBtn();
2046
+ ui.stick = true;
2047
+ if (res?.reply) await showCommandReply(text.trim(), res.reply, res.info);
2048
+ scrollToBottom(true);
2049
+ }
2050
+
2051
+ /** 会改变当前会话(换会话、换目录、换 agent)的命令,执行完要重拉一遍 */
2052
+ const RELOADING_COMMANDS = new Set(["new", "fork", "resume", "agent", "claude", "codex", "cd"]);
2053
+
2054
+ /** agent-loft 命令的结果:只显示给发命令的这个页面,不进会话记录 */
2055
+ async function showCommandReply(text, reply, info) {
2056
+ if (info) S.convs.set(info.key, info);
2057
+ renderTopbar();
2058
+ const head = text.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
2059
+ if (RELOADING_COMMANDS.has(head) && S.current) await openConversation(S.current);
2060
+ const note = (md) => addItem({ type: "notice", text: md });
2061
+ switch (reply.type) {
2062
+ case "text":
2063
+ note(`\`${text}\`\n\n${reply.text}`);
2064
+ return;
2065
+ case "sessions":
2066
+ await openSessions(reply.allDirs);
2067
+ return;
2068
+ case "models":
2069
+ await openModelPicker();
2070
+ return;
2071
+ case "agents":
2072
+ note(
2073
+ `当前 agent:**${agentLabel(reply.current)}**\n\n可切换:${reply.agents.map((a) => `\`/${a.id}\` ${a.label}`).join(" ")}`,
2074
+ );
2075
+ return;
2076
+ case "commands":
2077
+ note(reply.commands.map((c) => `\`${commandUsage(c)}\` ${oneLine(c.description)}`).join("\n\n") || "没有可用命令");
2078
+ return;
2079
+ }
2080
+ }
2081
+
2082
+ // ---------- 斜杠命令补全 ----------
2083
+ //
2084
+ // 输入框里只有「/xxx」(还没打空格)时弹出命令表,顺序就是设置 → 命令里排好的。
2085
+ // 列表按 agent + 会话目录缓存一分钟:Claude 的原生命令要起一次进程才问得到。
2086
+
2087
+ const slash = { open: false, items: [], index: 0, cache: new Map(), token: 0 };
2088
+
2089
+ function commandUsage(c) {
2090
+ return `/${c.name}${c.args ? ` ${c.args}` : ""}`;
2091
+ }
2092
+
2093
+ function oneLine(text, max = 80) {
2094
+ const t = String(text ?? "").replace(/\s+/g, " ").trim();
2095
+ return t.length > max ? `${t.slice(0, max)}…` : t;
2096
+ }
2097
+
2098
+ function currentAgentId() {
2099
+ const c = S.current ? S.convs.get(S.current) : null;
2100
+ return c?.agent ?? S.draftAgent ?? S.agents.find((a) => a.enabled)?.id ?? "claude";
2101
+ }
2102
+
2103
+ async function loadCommands(agent) {
2104
+ const cacheKey = `${agent}|${S.current ?? ""}`;
2105
+ const hit = slash.cache.get(cacheKey);
2106
+ if (hit && Date.now() - hit.at < 60_000) return hit.list;
2107
+ const q = S.current ? `?key=${encodeURIComponent(S.current)}` : "";
2108
+ const data = await api(`/api/agents/${agent}/commands${q}`);
2109
+ const list = data.commands.filter((c) => !c.hidden);
2110
+ slash.cache.set(cacheKey, { at: Date.now(), list });
2111
+ return list;
2112
+ }
2113
+
2114
+ function slashQuery() {
2115
+ const v = ui.input.value;
2116
+ const m = /^[//]([^\s]*)$/.exec(v);
2117
+ return m ? m[1].toLowerCase() : null;
2118
+ }
2119
+
2120
+ /** 名字前缀匹配排最前,其次名字里包含,最后是说明里包含;同档内保持用户排的顺序 */
2121
+ function matchCommands(list, q) {
2122
+ if (!q) return list;
2123
+ const tiers = [[], [], []];
2124
+ for (const c of list) {
2125
+ const name = c.name.toLowerCase();
2126
+ if (name.startsWith(q)) tiers[0].push(c);
2127
+ else if (name.includes(q)) tiers[1].push(c);
2128
+ else if (c.description.toLowerCase().includes(q)) tiers[2].push(c);
2129
+ }
2130
+ return tiers.flat();
2131
+ }
2132
+
2133
+ async function updateSlashMenu() {
2134
+ const q = slashQuery();
2135
+ if (q === null) return closeSlashMenu();
2136
+ const token = ++slash.token;
2137
+ if (!slash.open) renderSlashMenu(null);
2138
+ let list;
2139
+ try {
2140
+ list = await loadCommands(currentAgentId());
2141
+ } catch (err) {
2142
+ if (token === slash.token) renderSlashMenu(null, err.message);
2143
+ return;
2144
+ }
2145
+ // 等待期间又打了字:以最新的那次为准
2146
+ if (token !== slash.token || slashQuery() === null) return;
2147
+ const items = matchCommands(list, slashQuery());
2148
+ if (items.length === 0) return closeSlashMenu();
2149
+ slash.items = items;
2150
+ slash.index = Math.min(slash.index, items.length - 1);
2151
+ renderSlashMenu(items);
2152
+ }
2153
+
2154
+ function renderSlashMenu(items, error) {
2155
+ slash.open = true;
2156
+ ui.slashMenu.hidden = false;
2157
+ if (!items) {
2158
+ fill(ui.slashMenu, h("div", { class: "slash-empty" }, error ? `读取命令失败:${error}` : h("span", { class: "spinner" })));
2159
+ return;
2160
+ }
2161
+ fill(
2162
+ ui.slashMenu,
2163
+ ...items.map((c, i) =>
2164
+ h(
2165
+ "button",
2166
+ {
2167
+ class: `slash-item${i === slash.index ? " on" : ""}`,
2168
+ type: "button",
2169
+ // mousedown 而不是 click:不然输入框先失焦,手机上键盘会闪一下
2170
+ onmousedown: (e) => {
2171
+ e.preventDefault();
2172
+ void pickSlash(c, false);
2173
+ },
2174
+ onmousemove: () => {
2175
+ if (slash.index !== i) {
2176
+ slash.index = i;
2177
+ highlightSlash();
2178
+ }
2179
+ },
2180
+ },
2181
+ h(
2182
+ "div",
2183
+ { class: "slash-head" },
2184
+ h("span", { class: "slash-name" }, `/${c.name}`),
2185
+ c.args ? h("span", { class: "slash-args" }, c.args) : null,
2186
+ c.source === "agent" ? h("span", { class: "badge" }, agentLabel(currentAgentId())) : null,
2187
+ ),
2188
+ h("div", { class: "slash-desc" }, oneLine(c.description, 120) || "(无说明)"),
2189
+ ),
2190
+ ),
2191
+ );
2192
+ highlightSlash();
2193
+ }
2194
+
2195
+ function highlightSlash() {
2196
+ ui.slashMenu.querySelectorAll(".slash-item").forEach((el, i) => el.classList.toggle("on", i === slash.index));
2197
+ ui.slashMenu.querySelector(".slash-item.on")?.scrollIntoView({ block: "nearest" });
2198
+ }
2199
+
2200
+ function closeSlashMenu() {
2201
+ slash.open = false;
2202
+ slash.items = [];
2203
+ slash.index = 0;
2204
+ slash.token++;
2205
+ if (ui.slashMenu) ui.slashMenu.hidden = true;
2206
+ }
2207
+
2208
+ /**
2209
+ * 选中一条命令。fillOnly(Tab)只补全;回车 / 点击时,不需要参数的直接发出去,
2210
+ * 需要参数的补全成「/name 」等用户接着打。
2211
+ */
2212
+ async function pickSlash(c, fillOnly) {
2213
+ const needsArgs = !c.runnable;
2214
+ if (fillOnly || needsArgs) {
2215
+ ui.input.value = `/${c.name}${c.args ? " " : ""}`;
2216
+ closeSlashMenu();
2217
+ autosize();
2218
+ updateSendBtn();
2219
+ ui.input.focus();
2220
+ return;
2221
+ }
2222
+ ui.input.value = `/${c.name}`;
2223
+ closeSlashMenu();
2224
+ await sendMessage();
2225
+ }
2226
+
2227
+ /** 菜单开着时接管方向键 / Tab / 回车 / Esc;返回 true 表示已处理 */
2228
+ function slashKeydown(e) {
2229
+ if (!slash.open || slash.items.length === 0) {
2230
+ if (slash.open && e.key === "Escape") {
2231
+ closeSlashMenu();
2232
+ return true;
2233
+ }
2234
+ return false;
2235
+ }
2236
+ if (e.isComposing) return false;
2237
+ switch (e.key) {
2238
+ case "ArrowDown":
2239
+ slash.index = (slash.index + 1) % slash.items.length;
2240
+ highlightSlash();
2241
+ return true;
2242
+ case "ArrowUp":
2243
+ slash.index = (slash.index - 1 + slash.items.length) % slash.items.length;
2244
+ highlightSlash();
2245
+ return true;
2246
+ case "Tab":
2247
+ void pickSlash(slash.items[slash.index], true);
2248
+ return true;
2249
+ case "Enter":
2250
+ if (e.shiftKey) return false;
2251
+ void pickSlash(slash.items[slash.index], false);
2252
+ return true;
2253
+ case "Escape":
2254
+ closeSlashMenu();
2255
+ return true;
2256
+ }
2257
+ return false;
2258
+ }
2259
+
2260
+ async function stopTurn() {
2261
+ await doAction("stop");
2262
+ }
2263
+
2264
+ // ---------- 会话操作 ----------
2265
+
2266
+ async function doAction(action, value, extra = {}) {
2267
+ if (!S.current) return;
2268
+ try {
2269
+ const res = await api(`/api/conversations/${encodeURIComponent(S.current)}/action`, {
2270
+ method: "POST",
2271
+ body: { action, value, ...extra },
2272
+ });
2273
+ S.convs.set(res.info.key, res.info);
2274
+ renderTopbar();
2275
+ toast(res.text);
2276
+ if (["new", "fork", "resume", "agent", "cd"].includes(action)) await openConversation(S.current);
2277
+ return res;
2278
+ } catch (err) {
2279
+ toast(err.message);
2280
+ }
2281
+ }
2282
+
2283
+ /** 分叉成一条新对话(anchor 给了就到那条回复为止),然后切过去。原对话不动 */
2284
+ async function forkConversation(anchor) {
2285
+ if (!S.current) return;
2286
+ try {
2287
+ const res = await api(`/api/conversations/${encodeURIComponent(S.current)}/fork`, {
2288
+ method: "POST",
2289
+ body: anchor ? { anchor } : {},
2290
+ });
2291
+ S.convs.set(res.info.key, res.info);
2292
+ toast("已分叉成新对话,原对话保留");
2293
+ await openConversation(res.info.key);
2294
+ } catch (err) {
2295
+ toast(err.message);
2296
+ }
2297
+ }
2298
+
2299
+ async function switchAgent(id) {
2300
+ if (!S.current) {
2301
+ S.draftAgent = id;
2302
+ localStorage.setItem("aim.agent", id);
2303
+ renderTopbar();
2304
+ renderEmpty();
2305
+ return;
2306
+ }
2307
+ await doAction("agent", id);
2308
+ }
2309
+
2310
+ function modal({ title, body, foot, wide }) {
2311
+ const close = () => overlay.remove();
2312
+ const overlay = h(
2313
+ "div",
2314
+ { class: "overlay", onclick: (e) => e.target === overlay && close() },
2315
+ h(
2316
+ "div",
2317
+ { class: `modal${wide ? " wide" : ""}` },
2318
+ h("div", { class: "modal-head" }, h("h3", {}, title), h("button", { class: "icon-btn", onclick: close }, icon("x"))),
2319
+ body,
2320
+ foot ?? null,
2321
+ ),
2322
+ );
2323
+ document.body.append(overlay);
2324
+ return close;
2325
+ }
2326
+
2327
+ /** 贴着按钮弹出的小面板(默认在按钮上方、右对齐),点外面关闭。复用 .menu 的关闭逻辑 */
2328
+ function showPopover(anchor, { place = "above", align = "right" } = {}) {
2329
+ closeMenu();
2330
+ const pop = h("div", { class: "menu popover" });
2331
+ pop.anchor = anchor;
2332
+ pop.dataset.place = place;
2333
+ pop.dataset.align = align;
2334
+ document.body.append(pop);
2335
+ ui.menu = pop;
2336
+ setTimeout(() => document.addEventListener("pointerdown", onOutsideMenu), 0);
2337
+ return pop;
2338
+ }
2339
+
2340
+ function placePopover(pop, anchor) {
2341
+ if (!pop.isConnected || !anchor.isConnected) return;
2342
+ const r = anchor.getBoundingClientRect();
2343
+ const w = pop.offsetWidth;
2344
+ const left = pop.dataset.align === "left" ? r.left : r.right - w;
2345
+ pop.style.left = `${Math.max(8, Math.min(left, innerWidth - w - 8))}px`;
2346
+ if (pop.dataset.place === "below") {
2347
+ pop.style.top = `${Math.min(r.bottom + 6, innerHeight - pop.offsetHeight - 8)}px`;
2348
+ pop.style.bottom = "";
2349
+ } else {
2350
+ pop.style.bottom = `${innerHeight - r.top + 6}px`;
2351
+ pop.style.top = "";
2352
+ }
2353
+ }
2354
+
2355
+ /** 模型 / 思考程度选择:已有会话直接切,新对话先记下,发第一条消息时带过去 */
2356
+ async function openModelPicker(anchor = ui.modelSlot) {
2357
+ if (!anchor) return;
2358
+ if (ui.menu?.dataset.for === "model") return closeMenu();
2359
+ const pop = showPopover(anchor);
2360
+ pop.dataset.for = "model";
2361
+ pop.classList.add("model-pop");
2362
+ fill(pop, h("div", { class: "pop-loading" }, h("span", { class: "spinner" })));
2363
+ placePopover(pop, anchor);
2364
+
2365
+ const conv = S.current ? S.convs.get(S.current) : null;
2366
+ let models;
2367
+ let current;
2368
+ let currentEffort;
2369
+ let effortSupported = true;
2370
+ try {
2371
+ if (conv) {
2372
+ // 顺便把这个 agent 的默认模型 / 显示名准备好(缓存过就不会再请求)
2373
+ const [data] = await Promise.all([
2374
+ api(`/api/conversations/${encodeURIComponent(S.current)}/models`),
2375
+ agentCatalog(conv.agent).catch(() => []),
2376
+ ]);
2377
+ ({ models, current, currentEffort, effortSupported } = data);
2378
+ } else {
2379
+ models = await agentCatalog(S.draftAgent);
2380
+ const pick = draftPick();
2381
+ current = pick.model ?? (models.find((m) => m.isDefault) ?? models.find((m) => m.id === "default"))?.id;
2382
+ currentEffort = pick.effort;
2383
+ }
2384
+ } catch (err) {
2385
+ if (ui.menu === pop) fill(pop, h("div", { class: "pop-error" }, err.message));
2386
+ return;
2387
+ }
2388
+ if (ui.menu !== pop) return;
2389
+
2390
+ const choose = async (kind, value) => {
2391
+ if (kind === "model") current = value;
2392
+ else currentEffort = value;
2393
+ if (conv) {
2394
+ await doAction(kind, value);
2395
+ } else {
2396
+ draftPick()[kind] = value;
2397
+ saveDraftPick();
2398
+ renderContext();
2399
+ }
2400
+ if (kind === "effort") closeMenu();
2401
+ else if (ui.menu === pop) render();
2402
+ };
2403
+
2404
+ const agent = conv ? conv.agent : S.draftAgent;
2405
+ // 当前值可能是别名、完整名或空(默认):先按 id 对,对不上再按显示名对,只勾一项
2406
+ const currentItem = (list) => {
2407
+ const byId = list.find((m) => m.id === current);
2408
+ if (byId) return byId;
2409
+ const name = modelName(agent, current);
2410
+ return list.find((m) => m.id !== "default" && (m.label || m.id) === name) ?? null;
2411
+ };
2412
+
2413
+ function render() {
2414
+ const shown = visibleModels(agent, models);
2415
+ const on = currentItem(shown);
2416
+ const efforts = (on ?? currentItem(models))?.efforts ?? models.find((m) => m.efforts?.length)?.efforts ?? [];
2417
+ if (!currentEffort) currentEffort = catalogDefaults.get(agent)?.effort;
2418
+ fill(
2419
+ pop,
2420
+ h("div", { class: "pop-head" }, "模型"),
2421
+ shown.map((m) =>
2422
+ h(
2423
+ "button",
2424
+ { class: `pop-item${m === on ? " on" : ""}`, onclick: () => void choose("model", m.id) },
2425
+ h("span", { class: "grow" }, h("span", { class: "t" }, m.label || m.id), m.description ? h("span", { class: "s" }, m.description) : null),
2426
+ m === on ? h("span", { class: "check" }, "✓") : null,
2427
+ ),
2428
+ ),
2429
+ effortSupported && efforts.length
2430
+ ? [
2431
+ h("div", { class: "sep" }),
2432
+ h("div", { class: "pop-head" }, "思考程度"),
2433
+ h(
2434
+ "div",
2435
+ { class: "effort-seg" },
2436
+ efforts.map((e) => h("button", { class: e === currentEffort ? "on" : "", onclick: () => void choose("effort", e) }, e)),
2437
+ ),
2438
+ ]
2439
+ : null,
2440
+ );
2441
+ placePopover(pop, anchor);
2442
+ }
2443
+ render();
2444
+ }
2445
+
2446
+ async function openSessions(all) {
2447
+ const body = h("div", { class: "modal-body" }, h("span", { class: "spinner" }));
2448
+ const close = modal({ title: "历史会话", body });
2449
+ try {
2450
+ const data = await api(`/api/conversations/${encodeURIComponent(S.current)}/sessions${all ? "?all=1" : ""}`);
2451
+ fill(body,
2452
+ h(
2453
+ "div",
2454
+ { class: "muted" },
2455
+ data.allDirs ? (data.fellBack ? `${data.cwd} 下没有历史会话,已列出所有目录的` : "所有目录的最近会话") : `目录 ${data.cwd}`,
2456
+ !data.allDirs
2457
+ ? h(
2458
+ "button",
2459
+ {
2460
+ class: "btn small",
2461
+ style: { marginLeft: "8px" },
2462
+ onclick: () => {
2463
+ close();
2464
+ void openSessions(true);
2465
+ },
2466
+ },
2467
+ "看所有目录",
2468
+ )
2469
+ : null,
2470
+ ),
2471
+ ...(data.sessions.length === 0
2472
+ ? [h("div", { class: "muted" }, "还没有历史会话")]
2473
+ : data.sessions.map((s) =>
2474
+ h(
2475
+ "button",
2476
+ {
2477
+ class: `list-item${s.id === data.current ? " on" : ""}`,
2478
+ onclick: async () => {
2479
+ if (s.id === data.current) return;
2480
+ close();
2481
+ await doAction("resume", s.id, s.cwd ? { cwd: s.cwd } : {});
2482
+ },
2483
+ },
2484
+ h(
2485
+ "div",
2486
+ { class: "grow" },
2487
+ h("div", { class: "t" }, s.title),
2488
+ h("div", { class: "s" }, `${relTime(s.updatedAt)}${data.allDirs && s.cwd ? ` · ${s.cwd}` : ""}`),
2489
+ ),
2490
+ s.id === data.current ? "当前" : null,
2491
+ ),
2492
+ )),
2493
+ );
2494
+ } catch (err) {
2495
+ fill(body, h("div", { class: "error-text" }, err.message));
2496
+ }
2497
+ }
2498
+
2499
+ /** 目录选择器:一层层点进去选,也能手动输入。只能在 workspaceRoot 之内 */
2500
+ async function changeCwd() {
2501
+ // 还没发消息的新对话:只记下选择,创建会话时带过去
2502
+ const draft = !S.current;
2503
+ const conv = draft ? null : S.convs.get(S.current);
2504
+ const current = draft ? (S.draftCwd ?? S.workspaceRoot ?? "") : (conv?.cwd ?? "");
2505
+ const goLabel = draft ? "使用此目录" : "切换到此目录";
2506
+ let showHidden = localStorage.getItem("aim.cwdHidden") === "1";
2507
+ let listing = null;
2508
+ let seq = 0;
2509
+
2510
+ const crumbs = h("div", { class: "dir-crumbs" });
2511
+ const list = h("div", { class: "dir-list" });
2512
+ const hiddenBox = h("input", { type: "checkbox", checked: showHidden });
2513
+ hiddenBox.onchange = () => {
2514
+ showHidden = hiddenBox.checked;
2515
+ localStorage.setItem("aim.cwdHidden", showHidden ? "1" : "0");
2516
+ renderList();
2517
+ };
2518
+ const manual = h("input", { class: "dir-manual", placeholder: "或直接输入路径(相对工作区,回车跳转)" });
2519
+ manual.onkeydown = (e) => {
2520
+ if (e.key === "Enter" && manual.value.trim()) {
2521
+ e.preventDefault();
2522
+ void load(manual.value.trim());
2523
+ }
2524
+ };
2525
+ const body = h(
2526
+ "div",
2527
+ { class: "modal-body" },
2528
+ crumbs,
2529
+ h("div", { class: "row", style: { gap: "8px", alignItems: "center" } }, manual, h("label", { class: "check" }, hiddenBox, "显示隐藏")),
2530
+ list,
2531
+ );
2532
+ const go = h("button", { class: "btn primary", disabled: true }, goLabel);
2533
+ const note = h("span", { class: "muted grow" }, draft ? "发送第一条消息时在这个目录开会话" : "切换后开新会话");
2534
+ const close = modal({
2535
+ title: draft ? "选择工作目录" : "切换工作目录",
2536
+ body,
2537
+ foot: h("div", { class: "modal-foot", style: { alignItems: "center" } }, note, go),
2538
+ });
2539
+
2540
+ go.onclick = async () => {
2541
+ if (!listing) return;
2542
+ close();
2543
+ if (draft) {
2544
+ S.draftCwd = listing.path === listing.root ? null : listing.path;
2545
+ if (!S.current) renderEmpty();
2546
+ return;
2547
+ }
2548
+ await doAction("cd", listing.path);
2549
+ };
2550
+
2551
+ function renderCrumbs() {
2552
+ const parts = listing.rel ? listing.rel.split(/[\\/]/) : [];
2553
+ const rootName = listing.root.split(/[\\/]/).filter(Boolean).pop() || listing.root;
2554
+ const items = [h("button", { class: "crumb", title: listing.root, onclick: () => load("") }, rootName)];
2555
+ parts.forEach((name, i) => {
2556
+ items.push(h("span", { class: "crumb-sep" }, "/"));
2557
+ const rel = parts.slice(0, i + 1).join("/");
2558
+ items.push(h("button", { class: "crumb", onclick: () => load(rel) }, name));
2559
+ });
2560
+ fill(crumbs, items);
2561
+ }
2562
+
2563
+ function renderList() {
2564
+ if (!listing) return;
2565
+ const join = (name) => (listing.rel ? `${listing.rel}/${name}` : name);
2566
+ const dirs = listing.dirs.filter((d) => showHidden || !d.hidden);
2567
+ const hiddenCount = listing.dirs.length - dirs.length;
2568
+ fill(
2569
+ list,
2570
+ listing.rel
2571
+ ? h(
2572
+ "button",
2573
+ { class: "list-item dir-item", onclick: () => load(listing.rel.split(/[\\/]/).slice(0, -1).join("/")) },
2574
+ h("span", { class: "dir-icon" }, "↩"),
2575
+ h("div", { class: "grow" }, h("div", { class: "t" }, "..")),
2576
+ )
2577
+ : null,
2578
+ dirs.length === 0 ? h("div", { class: "muted" }, hiddenCount ? `没有子目录(${hiddenCount} 个隐藏目录未显示)` : "没有子目录") : null,
2579
+ dirs.map((d) => {
2580
+ const abs = `${listing.path.replace(/[\\/]+$/, "")}/${d.name}`;
2581
+ const isCurrent = abs === current;
2582
+ return h(
2583
+ "button",
2584
+ { class: `list-item dir-item${isCurrent ? " on" : ""}`, onclick: () => load(join(d.name)) },
2585
+ h("span", { class: "dir-icon" }, "📁"),
2586
+ h("div", { class: "grow" }, h("div", { class: "t" }, d.name)),
2587
+ isCurrent ? h("span", { class: "muted" }, "当前") : current.startsWith(`${abs}/`) ? h("span", { class: "muted" }, "当前在内") : null,
2588
+ h("span", { class: "dir-chev" }, "›"),
2589
+ );
2590
+ }),
2591
+ listing.truncated ? h("div", { class: "muted" }, "子目录太多,只列出了一部分,可以手动输入路径") : null,
2592
+ );
2593
+ }
2594
+
2595
+ async function load(rel) {
2596
+ const my = ++seq;
2597
+ go.disabled = true;
2598
+ fill(list, h("span", { class: "spinner" }));
2599
+ try {
2600
+ const data = await api(`/api/dirs?path=${encodeURIComponent(rel)}`);
2601
+ if (my !== seq) return;
2602
+ listing = data;
2603
+ manual.value = "";
2604
+ go.disabled = false;
2605
+ go.textContent = data.path === current ? "已是当前目录" : goLabel;
2606
+ if (data.path === current) go.disabled = true;
2607
+ renderCrumbs();
2608
+ renderList();
2609
+ } catch (err) {
2610
+ if (my !== seq) return;
2611
+ if (!listing) {
2612
+ fill(list, h("div", { class: "error-text" }, err.message));
2613
+ return;
2614
+ }
2615
+ // 进不去就留在原地
2616
+ toast(err.message);
2617
+ go.disabled = listing.path === current;
2618
+ renderList();
2619
+ }
2620
+ }
2621
+
2622
+ // 从当前目录开始(在工作区之内的话),否则从根目录开始
2623
+ const root = S.workspaceRoot ?? "";
2624
+ const startRel = root && (current === root || current.startsWith(`${root.replace(/[\\/]+$/, "")}/`)) ? current.slice(root.replace(/[\\/]+$/, "").length + 1) : "";
2625
+ await load(startRel);
2626
+ }
2627
+
2628
+ async function showStatus() {
2629
+ try {
2630
+ const data = await api(`/api/conversations/${encodeURIComponent(S.current)}/status`);
2631
+ modal({ title: "状态", body: h("div", { class: "modal-body" }, renderMarkdown("```\n" + data.text + "\n```")) });
2632
+ } catch (err) {
2633
+ toast(err.message);
2634
+ }
2635
+ }
2636
+
2637
+ /** 重命名:小弹窗,回车保存、Esc 取消,打开时全选原名 */
2638
+ function renameConv(key = S.current) {
2639
+ const c = S.convs.get(key);
2640
+ if (!c) return;
2641
+ const input = h("input", { class: "rename-input", value: convTitle(c), maxlength: "120", spellcheck: "false" });
2642
+ const save = h("button", { class: "btn primary" }, "保存");
2643
+ const cancel = h("button", { class: "btn ghost" }, "取消");
2644
+ const close = () => {
2645
+ overlay.classList.add("leaving");
2646
+ setTimeout(() => overlay.remove(), 120);
2647
+ document.removeEventListener("keydown", onKey, true);
2648
+ };
2649
+ const submit = async () => {
2650
+ const title = input.value.trim();
2651
+ if (!title || title === convTitle(c)) return close();
2652
+ save.disabled = true;
2653
+ try {
2654
+ const info = await api(`/api/conversations/${encodeURIComponent(key)}`, { method: "PATCH", body: { title } });
2655
+ S.convs.set(info.key, info);
2656
+ renderSidebar();
2657
+ if (key === S.current) renderTopbar();
2658
+ close();
2659
+ } catch (err) {
2660
+ save.disabled = false;
2661
+ toast(err.message);
2662
+ }
2663
+ };
2664
+ const onKey = (e) => {
2665
+ if (e.key === "Escape") {
2666
+ e.preventDefault();
2667
+ close();
2668
+ } else if (e.key === "Enter" && !e.isComposing && document.activeElement === input) {
2669
+ e.preventDefault();
2670
+ void submit();
2671
+ }
2672
+ };
2673
+ save.onclick = () => void submit();
2674
+ cancel.onclick = close;
2675
+ const overlay = h(
2676
+ "div",
2677
+ { class: "overlay dialog-overlay", onclick: (e) => e.target === overlay && close() },
2678
+ h(
2679
+ "div",
2680
+ { class: "dialog" },
2681
+ h("h3", {}, "重命名会话"),
2682
+ h("p", { class: "dialog-sub" }, agentIcon(c.agent), h("span", {}, `${agentLabel(c.agent)} · ${baseName(c.cwd)}`)),
2683
+ input,
2684
+ h("div", { class: "dialog-foot" }, cancel, save),
2685
+ ),
2686
+ );
2687
+ document.body.append(overlay);
2688
+ document.addEventListener("keydown", onKey, true);
2689
+ input.focus();
2690
+ input.select();
2691
+ }
2692
+
2693
+ async function deleteConv() {
2694
+ if (!confirm("从列表里删除这条会话?(agent 那边的记录还在,之后能从「历史会话」恢复)")) return;
2695
+ try {
2696
+ await api(`/api/conversations/${encodeURIComponent(S.current)}`, { method: "DELETE" });
2697
+ const c = S.convs.get(S.current);
2698
+ if (c) c.hidden = true;
2699
+ newDraft();
2700
+ } catch (err) {
2701
+ toast(err.message);
2702
+ }
2703
+ }
2704
+
2705
+ // ---------- 设置 ----------
2706
+
2707
+ async function openSettings() {
2708
+ const nav = h("div", { class: "settings-nav" });
2709
+ const panel = h("div", { class: "settings-body" });
2710
+ const close = modal({ title: "设置", wide: true, body: h("div", { class: "settings" }, nav, panel) });
2711
+ let cfg;
2712
+ try {
2713
+ cfg = await api("/api/config");
2714
+ } catch (err) {
2715
+ panel.append(h("div", { class: "error-text" }, err.message));
2716
+ return;
2717
+ }
2718
+ const tabs = [
2719
+ ["通用", () => generalTab(cfg, panel)],
2720
+ ["Agent", () => agentsTab(cfg, panel)],
2721
+ ["命令", () => commandsTab(cfg, panel)],
2722
+ ["飞书机器人", () => feishuTab(cfg, panel)],
2723
+ ["授权", () => authTab(panel)],
2724
+ ["通知", () => notifyTab(panel)],
2725
+ ["安全", () => securityTab(panel)],
2726
+ ["系统", () => systemTab(panel, close)],
2727
+ ];
2728
+ const select = (i) => {
2729
+ nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
2730
+ fill(panel, );
2731
+ tabs[i][1]();
2732
+ };
2733
+ nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
2734
+ select(0);
2735
+ }
2736
+
2737
+ // ---------- 系统通知(Web Push) ----------
2738
+
2739
+ function pushSupport() {
2740
+ const ios = /iPhone|iPad|iPod/.test(navigator.userAgent);
2741
+ const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
2742
+ if (!window.isSecureContext) return { ok: false, why: "通知需要 HTTPS 访问(localhost 除外)。请通过 HTTPS 的反向代理地址打开。" };
2743
+ if (ios && !standalone) {
2744
+ return { ok: false, why: "iPhone / iPad 上要先在 Safari 里点「分享 → 添加到主屏幕」,再从桌面图标打开,才能开启通知(需 iOS 16.4+)。" };
2745
+ }
2746
+ if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
2747
+ return { ok: false, why: "这个浏览器不支持网页推送通知。" };
2748
+ }
2749
+ return { ok: true };
2750
+ }
2751
+
2752
+ function swReady(ms = 8000) {
2753
+ return Promise.race([
2754
+ navigator.serviceWorker.ready,
2755
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Service Worker 没有就绪,刷新页面再试")), ms)),
2756
+ ]);
2757
+ }
2758
+
2759
+ async function currentPushSub() {
2760
+ if (!pushSupport().ok) return null;
2761
+ try {
2762
+ return await (await swReady()).pushManager.getSubscription();
2763
+ } catch {
2764
+ return null;
2765
+ }
2766
+ }
2767
+
2768
+ async function enablePush() {
2769
+ // 必须在点击里直接请求权限,iOS 不认异步之后再弹的
2770
+ const permission = await Notification.requestPermission();
2771
+ if (permission !== "granted") throw new Error(permission === "denied" ? "通知权限被拒绝了,请到系统设置里给这个应用打开通知" : "没有授予通知权限");
2772
+ const { publicKey } = await api("/api/push");
2773
+ const reg = await swReady();
2774
+ let sub = await reg.pushManager.getSubscription();
2775
+ if (!sub) {
2776
+ const raw = atob(publicKey.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(publicKey.length / 4) * 4, "="));
2777
+ sub = await reg.pushManager.subscribe({
2778
+ userVisibleOnly: true,
2779
+ applicationServerKey: Uint8Array.from(raw, (c) => c.charCodeAt(0)),
2780
+ });
2781
+ }
2782
+ await api("/api/push/subscribe", { method: "POST", body: { subscription: sub.toJSON() } });
2783
+ }
2784
+
2785
+ async function notifyTab(panel) {
2786
+ const support = pushSupport();
2787
+ const box = h("div", { class: "section" }, h("h4", {}, "系统通知"), h("span", { class: "spinner" }));
2788
+ fill(panel, box);
2789
+ const render = async () => {
2790
+ let data = { subscriptions: [] };
2791
+ try {
2792
+ data = await api("/api/push");
2793
+ } catch (err) {
2794
+ fill(box, h("h4", {}, "系统通知"), h("div", { class: "error-text" }, err.message));
2795
+ return;
2796
+ }
2797
+ const mine = await currentPushSub();
2798
+ const here = mine && data.subscriptions.some((d) => d.endpoint === mine.endpoint);
2799
+ const busy = (btn, fn) => async () => {
2800
+ btn.disabled = true;
2801
+ try {
2802
+ await fn();
2803
+ } catch (err) {
2804
+ toast(err.message);
2805
+ }
2806
+ btn.disabled = false;
2807
+ await render();
2808
+ };
2809
+ const onBtn = h("button", { class: "btn primary" }, "在此设备开启通知");
2810
+ onBtn.onclick = busy(onBtn, async () => {
2811
+ await enablePush();
2812
+ toast("已开启,可以发一条测试通知看看");
2813
+ });
2814
+ const offBtn = h("button", { class: "btn" }, "关闭此设备的通知");
2815
+ offBtn.onclick = busy(offBtn, async () => {
2816
+ await api("/api/push/unsubscribe", { method: "POST", body: { endpoint: mine.endpoint } });
2817
+ await mine.unsubscribe().catch(() => {});
2818
+ toast("已关闭");
2819
+ });
2820
+ const testBtn = h("button", { class: "btn" }, "发送测试通知");
2821
+ testBtn.onclick = busy(testBtn, async () => {
2822
+ const r = await api("/api/push/test", { method: "POST" });
2823
+ toast(`已发送到 ${r.sent} 台设备`);
2824
+ });
2825
+ fill(
2826
+ box,
2827
+ h("h4", {}, "系统通知"),
2828
+ h("div", { class: "muted" }, "Web 对话里 agent 回复完成、出错、需要授权或向你提问时,推送到开启了通知的设备。正在前台看着那条对话的时候不推。飞书对话由飞书自己提醒。"),
2829
+ support.ok
2830
+ ? h("div", { class: "row" }, here ? offBtn : onBtn, data.subscriptions.length ? testBtn : null)
2831
+ : h("div", { class: "error-text" }, support.why),
2832
+ support.ok && Notification.permission === "denied"
2833
+ ? h("div", { class: "error-text" }, "这个设备的通知权限已被拒绝,需要到系统设置(iPhone:设置 → 通知 → Agent Loft)里重新打开。")
2834
+ : null,
2835
+ h("h4", {}, "已开启通知的设备"),
2836
+ data.subscriptions.length === 0
2837
+ ? h("div", { class: "muted" }, "还没有")
2838
+ : data.subscriptions.map((d) => {
2839
+ const rm = h("button", { class: "btn small" }, "移除");
2840
+ rm.onclick = busy(rm, async () => {
2841
+ await api("/api/push/unsubscribe", { method: "POST", body: { endpoint: d.endpoint } });
2842
+ if (mine && mine.endpoint === d.endpoint) await mine.unsubscribe().catch(() => {});
2843
+ });
2844
+ return h(
2845
+ "div",
2846
+ { class: "list-item" },
2847
+ h(
2848
+ "div",
2849
+ { class: "grow" },
2850
+ h("div", { class: "t" }, d.label, mine && mine.endpoint === d.endpoint ? "(本设备)" : ""),
2851
+ h("div", { class: "s" }, `开启于 ${relTime(d.createdAt)}${d.lastOkAt ? ` · 上次送达 ${relTime(d.lastOkAt)}` : ""}`),
2852
+ ),
2853
+ rm,
2854
+ );
2855
+ }),
2856
+ );
2857
+ };
2858
+ await render();
2859
+ }
2860
+
2861
+ async function saveConfig(cfg, patch) {
2862
+ try {
2863
+ const next = await api("/api/config", { method: "PUT", body: patch });
2864
+ Object.assign(cfg, next);
2865
+ toast("已保存");
2866
+ // 默认模型 / 可选模型可能改了:模型列表和默认值重新拿
2867
+ catalogCache.clear();
2868
+ catalogLoaded.clear();
2869
+ catalogDefaults.clear();
2870
+ const boot2 = await api("/api/bootstrap");
2871
+ S.agents = boot2.agents;
2872
+ S.bots = boot2.bots;
2873
+ S.workspaceRoot = boot2.workspaceRoot;
2874
+ if (boot2.quotaRefreshMin !== S.quotaRefreshMin) {
2875
+ S.quotaRefreshMin = boot2.quotaRefreshMin ?? 10;
2876
+ scheduleIdleQuota();
2877
+ }
2878
+ renderTopbar();
2879
+ renderSidebar();
2880
+ renderContext();
2881
+ } catch (err) {
2882
+ toast(err.message);
2883
+ }
2884
+ }
2885
+
2886
+ function field(label, input, hint) {
2887
+ return h("label", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
2888
+ }
2889
+
2890
+ function generalTab(cfg, panel) {
2891
+ const root = h("input", { value: cfg.workspaceRoot });
2892
+ const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
2893
+ const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
2894
+ const quotaMin = h("input", { type: "number", min: 0, max: 1440, value: cfg.web?.quotaRefreshMin ?? 10 });
2895
+ panel.append(
2896
+ h(
2897
+ "div",
2898
+ { class: "section" },
2899
+ h("h4", {}, "通用"),
2900
+ field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
2901
+ field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
2902
+ field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
2903
+ field("额度自动刷新间隔(分钟)", quotaMin, "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)"),
2904
+ h(
2905
+ "div",
2906
+ { class: "row" },
2907
+ h(
2908
+ "button",
2909
+ {
2910
+ class: "btn primary",
2911
+ onclick: () =>
2912
+ saveConfig(cfg, {
2913
+ workspaceRoot: root.value,
2914
+ approvalTimeoutMs: Number(approval.value) * 60000,
2915
+ sessionTtlMs: Number(ttl.value) * 3600000,
2916
+ web: { quotaRefreshMin: Number(quotaMin.value) },
2917
+ }),
2918
+ },
2919
+ "保存",
2920
+ ),
2921
+ ),
2922
+ ),
2923
+ );
2924
+ }
2925
+
2926
+ /**
2927
+ * 默认模型 + 默认思考程度的联动下拉框。
2928
+ * 模型列表从 agent 实时拿(Claude 要起一次进程,可能要几秒),拿到前先显示当前值;
2929
+ * 拿不到就退回文本框手填,不挡住保存。思考程度的选项跟着所选模型变。
2930
+ * 已保存但不在列表里的值(比如手填过的完整模型名)会作为「当前值」保留,不会被悄悄清掉。
2931
+ */
2932
+ function modelPicker(agent, model, effort, defaultLabel) {
2933
+ const modelSlot = h("div", { class: "picker" });
2934
+ const effortSlot = h("div", { class: "picker" });
2935
+ let getModel = () => model;
2936
+ let getEffort = () => effort;
2937
+
2938
+ const option = (value, label, selected) => h("option", { value, selected: value === selected }, label);
2939
+ const withCurrent = (values, current) => (current && !values.includes(current) ? [current] : []);
2940
+
2941
+ const asText = (reason) => {
2942
+ const m = h("input", { value: getModel(), placeholder: `留空${defaultLabel}` });
2943
+ const e = h("input", { value: getEffort(), placeholder: "low / medium / high …,留空默认" });
2944
+ fill(modelSlot, m, reason ? h("small", {}, reason) : null);
2945
+ effortSlot.replaceChildren(e);
2946
+ getModel = () => m.value.trim();
2947
+ getEffort = () => e.value.trim();
2948
+ };
2949
+
2950
+ const loading = (text) => {
2951
+ modelSlot.replaceChildren(h("select", { disabled: true }, option("", text, "")));
2952
+ effortSlot.replaceChildren(h("select", { disabled: true }, option("", effort || "默认", "")));
2953
+ };
2954
+
2955
+ const asSelect = (models) => {
2956
+ // Claude 自带一项 "default",和「留空」是一回事,不重复列
2957
+ const listed = models.filter((m) => m.id !== "default");
2958
+ const fallback = models.find((m) => m.id === "default" || m.isDefault);
2959
+ const modelSel = h(
2960
+ "select",
2961
+ {},
2962
+ option("", `默认(${defaultLabel.replace(/^跟随\s*/, "")})`, model),
2963
+ withCurrent(listed.map((m) => m.id), model).map((v) => option(v, `${v}(当前值,不在列表中)`, model)),
2964
+ listed.map((m) => option(m.id, m.label && m.label !== m.id ? `${m.label} · ${m.id}` : m.id, model)),
2965
+ );
2966
+ const effortSel = h("select", {});
2967
+ const renderEfforts = () => {
2968
+ const picked = modelSel.value ? models.find((m) => m.id === modelSel.value) : fallback;
2969
+ const efforts = picked?.efforts ?? [];
2970
+ const keep = effortSel.value || effort;
2971
+ // 子项里有数组:replaceChildren 不展开数组,会把它变成文本节点(select 里直接被忽略)
2972
+ fill(
2973
+ effortSel,
2974
+ option("", "默认", keep),
2975
+ withCurrent(efforts, keep).map((v) => option(v, `${v}(当前值,该模型可能不支持)`, keep)),
2976
+ efforts.map((v) => option(v, v, keep)),
2977
+ );
2978
+ };
2979
+ modelSel.addEventListener("change", renderEfforts);
2980
+ renderEfforts();
2981
+ modelSlot.replaceChildren(modelSel);
2982
+ effortSlot.replaceChildren(effortSel);
2983
+ getModel = () => modelSel.value;
2984
+ getEffort = () => effortSel.value;
2985
+ };
2986
+
2987
+ loading(model ? `${model}(正在读取模型列表…)` : "正在读取模型列表…");
2988
+ api(`/api/agents/${agent}/models`)
2989
+ .then((data) => (data?.models?.length ? asSelect(data.models) : asText("没读到模型列表,请手填")))
2990
+ .catch((err) => asText(err.status === 409 ? err.message : `读取模型列表失败,请手填:${err.message ?? err}`));
2991
+
2992
+ return { modelEl: modelSlot, effortEl: effortSlot, model: () => getModel(), effort: () => getEffort() };
2993
+ }
2994
+
2995
+ /**
2996
+ * 设置 → 命令:每个 agent 一张表,拖动或用箭头排序,取消勾选就不在「/」列表里出现。
2997
+ * 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后。
2998
+ */
2999
+ function commandsTab(cfg, panel) {
3000
+ const agentIds = Object.keys(cfg.agents);
3001
+ let agent = currentAgentId();
3002
+ if (!agentIds.includes(agent)) agent = agentIds[0];
3003
+ let list = [];
3004
+ let filter = "";
3005
+ let dragFrom = -1;
3006
+
3007
+ const tabs = h("div", { class: "row" });
3008
+ const search = h("input", {
3009
+ class: "input",
3010
+ placeholder: "筛选命令(只影响显示,排序仍按整张表)",
3011
+ oninput: () => {
3012
+ filter = search.value.trim().toLowerCase();
3013
+ renderList();
3014
+ },
3015
+ });
3016
+ const listBox = h("div", { class: "cmd-list" });
3017
+
3018
+ const move = (from, to) => {
3019
+ if (to < 0 || to >= list.length || from === to) return;
3020
+ const [item] = list.splice(from, 1);
3021
+ list.splice(to, 0, item);
3022
+ renderList();
3023
+ };
3024
+
3025
+ const renderTabs = () =>
3026
+ fill(
3027
+ tabs,
3028
+ ...agentIds.map((id) =>
3029
+ h(
3030
+ "button",
3031
+ { class: `btn small${id === agent ? " primary" : ""}`, onclick: () => id !== agent && load(id) },
3032
+ agentLabel(id),
3033
+ ),
3034
+ ),
3035
+ );
3036
+
3037
+ const renderList = () => {
3038
+ const rows = list
3039
+ .map((c, i) => ({ c, i }))
3040
+ .filter(({ c }) => !filter || c.name.toLowerCase().includes(filter) || c.description.toLowerCase().includes(filter));
3041
+ fill(
3042
+ listBox,
3043
+ ...rows.map(({ c, i }) => {
3044
+ const shown = h("input", {
3045
+ type: "checkbox",
3046
+ checked: !c.hidden,
3047
+ title: "在「/」列表里显示",
3048
+ onchange: () => {
3049
+ c.hidden = !shown.checked;
3050
+ row.classList.toggle("off", c.hidden);
3051
+ },
3052
+ });
3053
+ const row = h(
3054
+ "div",
3055
+ {
3056
+ class: `cmd-row${c.hidden ? " off" : ""}`,
3057
+ draggable: filter ? false : "true",
3058
+ ondragstart: (e) => {
3059
+ dragFrom = i;
3060
+ e.dataTransfer.effectAllowed = "move";
3061
+ row.classList.add("dragging");
3062
+ },
3063
+ ondragend: () => row.classList.remove("dragging"),
3064
+ ondragover: (e) => {
3065
+ e.preventDefault();
3066
+ e.dataTransfer.dropEffect = "move";
3067
+ },
3068
+ ondrop: (e) => {
3069
+ e.preventDefault();
3070
+ if (dragFrom >= 0) move(dragFrom, i);
3071
+ dragFrom = -1;
3072
+ },
3073
+ },
3074
+ h("span", { class: "cmd-grip", title: filter ? "筛选时不能拖动" : "拖动排序" }, "⋮⋮"),
3075
+ shown,
3076
+ h(
3077
+ "div",
3078
+ { class: "grow" },
3079
+ h(
3080
+ "div",
3081
+ { class: "t" },
3082
+ h("code", {}, commandUsage(c)),
3083
+ c.source === "agent" ? h("span", { class: "badge" }, agentLabel(agent)) : null,
3084
+ ),
3085
+ h("div", { class: "s" }, oneLine(c.description, 100) || "(无说明)"),
3086
+ ),
3087
+ h("button", { class: "icon-btn", title: "置顶", onclick: () => move(i, 0) }, "⤒"),
3088
+ h("button", { class: "icon-btn", title: "上移", onclick: () => move(i, i - 1) }, "↑"),
3089
+ h("button", { class: "icon-btn", title: "下移", onclick: () => move(i, i + 1) }, "↓"),
3090
+ );
3091
+ return row;
3092
+ }),
3093
+ );
3094
+ };
3095
+
3096
+ const save = async (prefs) => {
3097
+ await saveConfig(cfg, { commands: { [agent]: prefs } });
3098
+ slash.cache.clear();
3099
+ // 服务端 3 秒防抖后才开始同步,稍等再看状态
3100
+ setTimeout(() => void refreshFeishu(), 3500);
3101
+ };
3102
+
3103
+ // ---- 飞书指令面板同步状态 ----
3104
+ const feishuBox = h("div", { class: "cmd-feishu" });
3105
+ let feishuTimer = null;
3106
+ const describeSlash = (st) => {
3107
+ if (st.state === "syncing") return "同步中…(重建时每条间隔 1 秒,命令多要一两分钟)";
3108
+ if (st.state === "error") return `同步失败:${st.error}`;
3109
+ if (st.state === "done") {
3110
+ const when = new Date(st.at).toLocaleTimeString();
3111
+ const skipped = st.skipped?.length
3112
+ ? `;${st.skipped.length} 个名字不符合飞书规则(只能小写字母、数字、- _,最长 32)没注册:${st.skipped.slice(0, 8).join("、")}${st.skipped.length > 8 ? " 等" : ""}`
3113
+ : "";
3114
+ return `${when} ${st.changed ? "已同步" : "已是最新"},共 ${st.registered} 条${skipped}。飞书端约 5~8 分钟后生效。`;
3115
+ }
3116
+ return "等待首次同步";
3117
+ };
3118
+ async function refreshFeishu() {
3119
+ let data;
3120
+ try {
3121
+ data = await api("/api/feishu/slash");
3122
+ } catch (err) {
3123
+ fill(feishuBox, h("div", { class: "error-text" }, err.message));
3124
+ return;
3125
+ }
3126
+ fill(
3127
+ feishuBox,
3128
+ data.bots.length === 0
3129
+ ? h("div", { class: "muted" }, "没有运行中的飞书机器人。")
3130
+ : data.bots.map((b) =>
3131
+ h(
3132
+ "div",
3133
+ { class: `muted${b.status.state === "error" ? " error-text" : ""}` },
3134
+ h("b", {}, b.name),
3135
+ ":",
3136
+ describeSlash(b.status),
3137
+ ),
3138
+ ),
3139
+ );
3140
+ clearTimeout(feishuTimer);
3141
+ feishuTimer = data.bots.some((b) => b.status.state === "syncing") ? setTimeout(() => feishuBox.isConnected && void refreshFeishu(), 2000) : null;
3142
+ }
3143
+
3144
+ async function load(id) {
3145
+ agent = id;
3146
+ renderTabs();
3147
+ fill(listBox, h("span", { class: "spinner" }));
3148
+ try {
3149
+ const data = await api(`/api/agents/${id}/commands`);
3150
+ list = data.commands;
3151
+ renderList();
3152
+ } catch (err) {
3153
+ fill(listBox, h("div", { class: "error-text" }, err.message));
3154
+ }
3155
+ }
3156
+
3157
+ panel.append(
3158
+ h(
3159
+ "div",
3160
+ { class: "section" },
3161
+ h("h4", {}, "命令列表"),
3162
+ h(
3163
+ "div",
3164
+ { class: "muted" },
3165
+ "在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),会原样转给 agent。",
3166
+ ),
3167
+ tabs,
3168
+ search,
3169
+ listBox,
3170
+ h(
3171
+ "div",
3172
+ { class: "row" },
3173
+ h(
3174
+ "button",
3175
+ {
3176
+ class: "btn primary",
3177
+ onclick: () => save({ order: list.map((c) => c.name), hidden: list.filter((c) => c.hidden).map((c) => c.name) }),
3178
+ },
3179
+ "保存",
3180
+ ),
3181
+ h(
3182
+ "button",
3183
+ {
3184
+ class: "btn",
3185
+ onclick: async () => {
3186
+ if (!confirm(`把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认?`)) return;
3187
+ await save({ order: [], hidden: [] });
3188
+ await load(agent);
3189
+ },
3190
+ },
3191
+ "恢复默认",
3192
+ ),
3193
+ ),
3194
+ ),
3195
+ h(
3196
+ "div",
3197
+ { class: "section" },
3198
+ h(
3199
+ "h4",
3200
+ {},
3201
+ "飞书指令面板",
3202
+ h("span", { class: "grow" }),
3203
+ h(
3204
+ "button",
3205
+ {
3206
+ class: "btn small",
3207
+ onclick: async () => {
3208
+ try {
3209
+ await api("/api/feishu/slash/sync", { method: "POST" });
3210
+ setTimeout(() => void refreshFeishu(), 500);
3211
+ } catch (err) {
3212
+ toast(err.message);
3213
+ }
3214
+ },
3215
+ },
3216
+ "立即同步",
3217
+ ),
3218
+ ),
3219
+ h(
3220
+ "div",
3221
+ { class: "muted" },
3222
+ "飞书输入框打「/」看到的指令。面板是每个机器人一份:默认 agent 的命令在前,其它 agent 的命令接在后面。启动时、保存命令设置后、以及每 6 小时会自动同步。",
3223
+ ),
3224
+ feishuBox,
3225
+ ),
3226
+ );
3227
+ void load(agent);
3228
+ void refreshFeishu();
3229
+ }
3230
+
3231
+ /**
3232
+ * 设置 → Agent:勾选哪些模型出现在输入框的模型列表里。
3233
+ * 全部勾上存成空数组 = 「全部显示」,以后 agent 新增的模型也会自动出现;
3234
+ * 只勾了一部分就只显示这些。一个都不勾不允许保存。
3235
+ */
3236
+ function modelChecklist(agent, selected) {
3237
+ const box = h("div", { class: "model-checks" }, h("small", {}, "正在读取模型列表…"));
3238
+ let boxes = null;
3239
+ api(`/api/agents/${agent}/models`)
3240
+ .then((data) => {
3241
+ const models = data?.models ?? [];
3242
+ if (!models.length) return fill(box, h("small", {}, "没读到模型列表"));
3243
+ boxes = models.map((m) => ({ id: m.id, el: h("input", { type: "checkbox", checked: !selected.length || selected.includes(m.id) }) }));
3244
+ fill(
3245
+ box,
3246
+ boxes.map(({ el }, i) => {
3247
+ const m = models[i];
3248
+ return h(
3249
+ "label",
3250
+ { class: "model-check" },
3251
+ el,
3252
+ h("span", { class: "t" }, m.label || m.id),
3253
+ h("span", { class: "s" }, m.id === "default" && defaultEntryName(m) ? `跟随默认 · 现在是 ${defaultEntryName(m)}` : m.id),
3254
+ );
3255
+ }),
3256
+ h("small", {}, "只影响输入框里的模型选择列表;全部勾上 = 以后新出的模型也自动显示"),
3257
+ );
3258
+ })
3259
+ .catch((err) => fill(box, h("small", {}, err.status === 409 ? err.message : `读取模型列表失败:${err.message ?? err}`)));
3260
+ return {
3261
+ el: box,
3262
+ /** 列表没读到时原样保留;null = 一个都没勾 */
3263
+ value: () => {
3264
+ if (!boxes) return selected;
3265
+ const on = boxes.filter((b) => b.el.checked).map((b) => b.id);
3266
+ if (on.length === 0) return null;
3267
+ return on.length === boxes.length ? [] : on;
3268
+ },
3269
+ };
3270
+ }
3271
+
3272
+ function agentsTab(cfg, panel) {
3273
+ const a = structuredClone(cfg.agents);
3274
+ const claudeOn = h("input", { type: "checkbox", checked: a.claude.enabled });
3275
+ const claudePick = modelPicker("claude", a.claude.defaultModel, a.claude.defaultEffort, "跟随 Claude Code 默认");
3276
+ const claudeModels = modelChecklist("claude", a.claude.models ?? []);
3277
+ const codexOn = h("input", { type: "checkbox", checked: a.codex.enabled });
3278
+ const codexPath = h("input", { value: a.codex.path });
3279
+ const codexHome = h("input", { value: a.codex.home, placeholder: "留空用 ~/.codex" });
3280
+ const sandbox = h(
3281
+ "select",
3282
+ {},
3283
+ ["read-only", "workspace-write", "danger-full-access"].map((m) => h("option", { value: m, selected: m === a.codex.sandbox }, m)),
3284
+ );
3285
+ const codexPick = modelPicker("codex", a.codex.defaultModel, a.codex.defaultEffort, "跟随 config.toml");
3286
+ const codexModels = modelChecklist("codex", a.codex.models ?? []);
3287
+ // 一组勾选框不能放进 <label>(点哪都会切到第一个),所以不用 field()
3288
+ const checksField = (list) => h("div", { class: "field" }, h("span", {}, "输入框可选模型"), list.el);
3289
+ panel.append(
3290
+ h(
3291
+ "div",
3292
+ { class: "section" },
3293
+ h("h4", {}, "Claude Code", h("span", { class: "grow" }), h("label", { class: "check" }, claudeOn, "启用")),
3294
+ field("默认模型", claudePick.modelEl),
3295
+ field("默认思考程度", claudePick.effortEl),
3296
+ checksField(claudeModels),
3297
+ ),
3298
+ h(
3299
+ "div",
3300
+ { class: "section" },
3301
+ h("h4", {}, "Codex", h("span", { class: "grow" }), h("label", { class: "check" }, codexOn, "启用")),
3302
+ field("codex 可执行文件", codexPath, "以系统服务运行时 PATH 很短,建议写绝对路径"),
3303
+ field("CODEX_HOME", codexHome),
3304
+ field("沙箱", sandbox, "workspace-write:目录内可读写,越界才弹授权。danger-full-access 等于关掉沙箱"),
3305
+ field("默认模型", codexPick.modelEl),
3306
+ field("默认思考程度", codexPick.effortEl),
3307
+ checksField(codexModels),
3308
+ ),
3309
+ h(
3310
+ "div",
3311
+ { class: "row" },
3312
+ h(
3313
+ "button",
3314
+ {
3315
+ class: "btn primary",
3316
+ onclick: () => {
3317
+ const claudeList = claudeModels.value();
3318
+ const codexList = codexModels.value();
3319
+ if (!claudeList || !codexList) return toast(`${!claudeList ? "Claude Code" : "Codex"} 至少勾选一个模型`);
3320
+ return saveConfig(cfg, {
3321
+ agents: {
3322
+ claude: {
3323
+ enabled: claudeOn.checked,
3324
+ defaultModel: claudePick.model(),
3325
+ defaultEffort: claudePick.effort(),
3326
+ models: claudeList,
3327
+ },
3328
+ codex: {
3329
+ enabled: codexOn.checked,
3330
+ path: codexPath.value.trim() || "codex",
3331
+ home: codexHome.value.trim(),
3332
+ sandbox: sandbox.value,
3333
+ defaultModel: codexPick.model(),
3334
+ defaultEffort: codexPick.effort(),
3335
+ models: codexList,
3336
+ },
3337
+ },
3338
+ });
3339
+ },
3340
+ },
3341
+ "保存",
3342
+ ),
3343
+ ),
3344
+ );
3345
+ }
3346
+
3347
+ function feishuTab(cfg, panel) {
3348
+ const bots = structuredClone(cfg.feishu.bots);
3349
+ const statusBox = new Map();
3350
+ const render = () => {
3351
+ fill(panel,
3352
+ h(
3353
+ "div",
3354
+ { class: "row" },
3355
+ h("button", { class: "btn accent", onclick: () => registerBot() }, "扫码创建新机器人"),
3356
+ h(
3357
+ "button",
3358
+ {
3359
+ class: "btn",
3360
+ onclick: () => {
3361
+ bots.push({ id: "", name: "新机器人", enabled: true, appId: "", appSecret: "", domain: "feishu", allowFrom: [], agents: ["claude"], defaultAgent: "claude", _new: true });
3362
+ render();
3363
+ },
3364
+ },
3365
+ "手动添加",
3366
+ ),
3367
+ ),
3368
+ ...bots.map((b, i) => botSection(b, i)),
3369
+ bots.length === 0 ? h("div", { class: "muted" }, "还没有飞书机器人。") : null,
3370
+ );
3371
+ };
3372
+ const botSection = (b, i) => {
3373
+ const name = h("input", { value: b.name });
3374
+ const appId = h("input", { value: b.appId, placeholder: "cli_xxx" });
3375
+ const secret = h("input", { value: b.appSecret, placeholder: "App Secret", type: "password", autocomplete: "off" });
3376
+ const domain = h("select", {}, h("option", { value: "feishu", selected: b.domain !== "lark" }, "飞书"), h("option", { value: "lark", selected: b.domain === "lark" }, "Lark"));
3377
+ const allow = h("textarea", { rows: 2, placeholder: "ou_xxx,一行一个" });
3378
+ allow.value = b.allowFrom.join("\n");
3379
+ const enabled = h("input", { type: "checkbox", checked: b.enabled });
3380
+ const agentChecks = S.agents.map((a) => {
3381
+ const box = h("input", { type: "checkbox", checked: b.agents.includes(a.id), value: a.id });
3382
+ return { id: a.id, box, el: h("label", { class: "check" }, box, a.label) };
3383
+ });
3384
+ const def = h("select", {}, S.agents.map((a) => h("option", { value: a.id, selected: a.id === b.defaultAgent }, a.label)));
3385
+ const status = h("span", { class: "status-tag" }, "…");
3386
+ statusBox.set(b.id, status);
3387
+ const collect = () => ({
3388
+ ...b,
3389
+ name: name.value.trim(),
3390
+ appId: appId.value.trim(),
3391
+ appSecret: secret.value.trim(),
3392
+ domain: domain.value,
3393
+ allowFrom: allow.value.split(/[\s,]+/).filter(Boolean),
3394
+ enabled: enabled.checked,
3395
+ agents: agentChecks.filter((c) => c.box.checked).map((c) => c.id),
3396
+ defaultAgent: def.value,
3397
+ });
3398
+ return h(
3399
+ "div",
3400
+ { class: "section" },
3401
+ h("h4", {}, b.name || "机器人", b._new ? null : status, h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "启用")),
3402
+ field("名称", name),
3403
+ h("div", { class: "row" }, h("div", { style: { flex: 1 } }, field("App ID", appId)), h("div", { style: { flex: 1 } }, field("App Secret", secret))),
3404
+ field("域名", domain),
3405
+ field("可用的 Agent", h("div", { class: "row" }, agentChecks.map((c) => c.el)), "勾一个 = 一个机器人对应一个 agent;勾多个可在会话里用 /agent 切换"),
3406
+ field("默认 Agent", def),
3407
+ field("Owner 白名单(open_id)", allow, "陌生人发消息会拿到配对码,在「授权」里批准即可,不必手填"),
3408
+ h(
3409
+ "div",
3410
+ { class: "row" },
3411
+ h(
3412
+ "button",
3413
+ {
3414
+ class: "btn primary",
3415
+ onclick: async () => {
3416
+ bots[i] = collect();
3417
+ delete bots[i]._new;
3418
+ await saveConfig(cfg, { feishu: { bots } });
3419
+ feishuTab(cfg, (fill(panel, ), panel));
3420
+ },
3421
+ },
3422
+ "保存",
3423
+ ),
3424
+ h(
3425
+ "button",
3426
+ {
3427
+ class: "btn",
3428
+ onclick: async () => {
3429
+ const v = collect();
3430
+ try {
3431
+ const r = await api("/api/feishu/test", { method: "POST", body: { id: b.id, appId: v.appId, appSecret: v.appSecret, domain: v.domain } });
3432
+ toast(r.ok ? "✅ 凭据有效" : `❌ ${r.message}`);
3433
+ } catch (err) {
3434
+ toast(err.message);
3435
+ }
3436
+ },
3437
+ },
3438
+ "测试凭据",
3439
+ ),
3440
+ h(
3441
+ "button",
3442
+ {
3443
+ class: "btn danger",
3444
+ onclick: async () => {
3445
+ if (!confirm(`删除机器人「${b.name}」?`)) return;
3446
+ bots.splice(i, 1);
3447
+ if (!b._new) await saveConfig(cfg, { feishu: { bots } });
3448
+ render();
3449
+ },
3450
+ },
3451
+ "删除",
3452
+ ),
3453
+ ),
3454
+ );
3455
+ };
3456
+ const registerBot = async () => {
3457
+ const nameInput = h("input", { value: "Agent 助手" });
3458
+ const checks = S.agents.filter((a) => a.enabled).map((a) => ({ id: a.id, box: h("input", { type: "checkbox", checked: true }), label: a.label }));
3459
+ const body = h(
3460
+ "div",
3461
+ { class: "modal-body" },
3462
+ h("div", { class: "muted" }, "用飞书扫码,会自动创建一个飞书应用(机器人),并把扫码的你设为 owner。"),
3463
+ field("机器人名称", nameInput),
3464
+ field("可用的 Agent", h("div", { class: "row" }, checks.map((c) => h("label", { class: "check" }, c.box, c.label)))),
3465
+ );
3466
+ const go = h("button", { class: "btn primary" }, "生成二维码");
3467
+ const closeReg = modal({ title: "扫码创建飞书机器人", body, foot: h("div", { class: "modal-foot" }, go) });
3468
+ go.onclick = async () => {
3469
+ go.disabled = true;
3470
+ try {
3471
+ const reg = await api("/api/feishu/register", {
3472
+ method: "POST",
3473
+ body: { name: nameInput.value, agents: checks.filter((c) => c.box.checked).map((c) => c.id) },
3474
+ });
3475
+ const state = h("div", { class: "muted", style: { textAlign: "center" } }, "等待扫码确认…");
3476
+ fill(body,
3477
+ h("div", { class: "qr", html: reg.qrSvg }),
3478
+ h("div", { class: "row", style: { justifyContent: "center" } }, h("a", { href: reg.qrUrl, target: "_blank", rel: "noopener" }, "手机上直接打开链接")),
3479
+ state,
3480
+ );
3481
+ go.remove();
3482
+ const poll = async () => {
3483
+ if (!body.isConnected) return;
3484
+ try {
3485
+ const r = await api(`/api/feishu/register/${reg.id}`);
3486
+ if (r.status === "pending") return setTimeout(poll, 3000);
3487
+ if (r.status === "success") {
3488
+ toast("✅ 机器人已创建并启动");
3489
+ closeReg();
3490
+ const fresh = await api("/api/config");
3491
+ Object.assign(cfg, fresh);
3492
+ await saveConfig(cfg, {});
3493
+ feishuTab(cfg, (fill(panel, ), panel));
3494
+ return;
3495
+ }
3496
+ state.textContent = { access_denied: "已拒绝授权", expired: "二维码已过期", timeout: "超时了" }[r.status] ?? `出错:${r.message ?? r.status}`;
3497
+ } catch (err) {
3498
+ state.textContent = err.message;
3499
+ }
3500
+ };
3501
+ setTimeout(poll, 3000);
3502
+ } catch (err) {
3503
+ toast(err.message);
3504
+ go.disabled = false;
3505
+ }
3506
+ };
3507
+ };
3508
+ render();
3509
+ api("/api/feishu/status")
3510
+ .then((list) => {
3511
+ for (const s of list) {
3512
+ const el = statusBox.get(s.id);
3513
+ if (!el) continue;
3514
+ const label = { connected: "已连接", connecting: "连接中", reconnecting: "重连中", failed: "连接失败", stopped: "已停止" }[s.state] ?? s.state;
3515
+ el.textContent = label;
3516
+ el.className = `status-tag ${s.state === "connected" ? "ok" : s.state === "failed" ? "bad" : ""}`;
3517
+ if (s.error) el.title = s.error;
3518
+ }
3519
+ })
3520
+ .catch(() => {});
3521
+ }
3522
+
3523
+ async function authTab(panel) {
3524
+ const [pairs, allowlist] = await Promise.all([api("/api/pairing"), api("/api/claude/allowlist")]);
3525
+ const botName = (id) => S.bots.find((b) => b.id === id)?.name ?? id;
3526
+ const allowText = h("textarea", { rows: 8 });
3527
+ allowText.value = allowlist.join("\n");
3528
+ panel.append(
3529
+ h(
3530
+ "div",
3531
+ { class: "section" },
3532
+ h("h4", {}, "待批准的配对"),
3533
+ pairs.pending.length === 0
3534
+ ? h("div", { class: "muted" }, "没有待批准的请求。陌生人给机器人发消息时会出现在这里。")
3535
+ : pairs.pending.map(({ channelId, request }) =>
3536
+ h(
3537
+ "div",
3538
+ { class: "list-item" },
3539
+ h("div", { class: "grow" }, h("div", { class: "t" }, `${request.code} · ${botName(channelId)}`), h("div", { class: "s" }, `${request.userId} · ${relTime(request.createdAt)}`)),
3540
+ h(
3541
+ "button",
3542
+ {
3543
+ class: "btn small accent",
3544
+ onclick: async () => {
3545
+ try {
3546
+ await api("/api/pairing/approve", { method: "POST", body: { code: request.code } });
3547
+ toast("已批准");
3548
+ fill(panel, );
3549
+ void authTab(panel);
3550
+ } catch (err) {
3551
+ toast(err.message);
3552
+ }
3553
+ },
3554
+ },
3555
+ "批准",
3556
+ ),
3557
+ ),
3558
+ ),
3559
+ ),
3560
+ h(
3561
+ "div",
3562
+ { class: "section" },
3563
+ h("h4", {}, "已配对的用户"),
3564
+ pairs.approved.length === 0
3565
+ ? h("div", { class: "muted" }, "无")
3566
+ : pairs.approved.map(({ channelId, userId }) =>
3567
+ h(
3568
+ "div",
3569
+ { class: "list-item" },
3570
+ h("div", { class: "grow" }, h("div", { class: "t" }, userId), h("div", { class: "s" }, botName(channelId))),
3571
+ h(
3572
+ "button",
3573
+ {
3574
+ class: "btn small danger",
3575
+ onclick: async () => {
3576
+ await api("/api/pairing/revoke", { method: "POST", body: { channelId, userId } });
3577
+ fill(panel, );
3578
+ void authTab(panel);
3579
+ },
3580
+ },
3581
+ "撤销",
3582
+ ),
3583
+ ),
3584
+ ),
3585
+ ),
3586
+ h(
3587
+ "div",
3588
+ { class: "section" },
3589
+ h("h4", {}, "Claude「总是允许」名单"),
3590
+ h("div", { class: "muted" }, "点过「总是允许」的会记在这里。Bash:git 表示 git 开头的命令都放行;Edit:/路径 表示该目录下的修改都放行。一行一条,删掉即撤销。"),
3591
+ allowText,
3592
+ h(
3593
+ "div",
3594
+ { class: "row" },
3595
+ h(
3596
+ "button",
3597
+ {
3598
+ class: "btn primary",
3599
+ onclick: async () => {
3600
+ await api("/api/claude/allowlist", { method: "PUT", body: { list: allowText.value.split("\n") } });
3601
+ toast("已保存");
3602
+ },
3603
+ },
3604
+ "保存",
3605
+ ),
3606
+ ),
3607
+ ),
3608
+ );
3609
+ }
3610
+
3611
+ async function securityTab(panel) {
3612
+ const old = h("input", { type: "password", autocomplete: "current-password" });
3613
+ const pw = h("input", { type: "password", autocomplete: "new-password" });
3614
+ const sessions = await api("/api/web-sessions");
3615
+ panel.append(
3616
+ h(
3617
+ "div",
3618
+ { class: "section" },
3619
+ h("h4", {}, "修改登录密码"),
3620
+ field("原密码", old),
3621
+ field("新密码(至少 8 位)", pw),
3622
+ h(
3623
+ "div",
3624
+ { class: "row" },
3625
+ h(
3626
+ "button",
3627
+ {
3628
+ class: "btn primary",
3629
+ onclick: async () => {
3630
+ try {
3631
+ await api("/api/password", { method: "POST", body: { old: old.value, password: pw.value } });
3632
+ toast("密码已修改,其它设备已下线");
3633
+ old.value = pw.value = "";
3634
+ } catch (err) {
3635
+ toast(err.message);
3636
+ }
3637
+ },
3638
+ },
3639
+ "修改",
3640
+ ),
3641
+ ),
3642
+ ),
3643
+ h(
3644
+ "div",
3645
+ { class: "section" },
3646
+ h("h4", {}, "已登录的设备"),
3647
+ sessions.map((s) =>
3648
+ h(
3649
+ "div",
3650
+ { class: "list-item" },
3651
+ h("div", { class: "grow" }, h("div", { class: "t" }, s.userAgent.slice(0, 80) || "未知设备"), h("div", { class: "s" }, `${s.ip} · 最近活动 ${relTime(s.lastSeen)}`)),
3652
+ h(
3653
+ "button",
3654
+ {
3655
+ class: "btn small danger",
3656
+ onclick: async () => {
3657
+ await api("/api/web-sessions/revoke", { method: "POST", body: { id: s.id } });
3658
+ fill(panel, );
3659
+ void securityTab(panel);
3660
+ },
3661
+ },
3662
+ "下线",
3663
+ ),
3664
+ ),
3665
+ ),
3666
+ ),
3667
+ h(
3668
+ "div",
3669
+ { class: "row" },
3670
+ h(
3671
+ "button",
3672
+ {
3673
+ class: "btn danger",
3674
+ onclick: async () => {
3675
+ await api("/api/logout", { method: "POST" });
3676
+ location.reload();
3677
+ },
3678
+ },
3679
+ "退出登录",
3680
+ ),
3681
+ ),
3682
+ );
3683
+ }
3684
+
3685
+ async function systemTab(panel) {
3686
+ const sys = await api("/api/system");
3687
+ panel.append(
3688
+ h(
3689
+ "div",
3690
+ { class: "section" },
3691
+ h("h4", {}, "系统"),
3692
+ h("div", {}, `版本 ${sys.version} · Node ${sys.node} · 已运行 ${Math.round(sys.uptimeMs / 60000)} 分钟`),
3693
+ h("div", { class: "muted" }, sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"),
3694
+ h(
3695
+ "div",
3696
+ { class: "row" },
3697
+ h(
3698
+ "button",
3699
+ {
3700
+ class: "btn",
3701
+ disabled: !sys.underSystemd,
3702
+ onclick: async () => {
3703
+ if (!confirm("重启 agent-loft?进行中的对话会被中断。")) return;
3704
+ try {
3705
+ await api("/api/system/restart", { method: "POST" });
3706
+ toast("正在重启…");
3707
+ setTimeout(() => location.reload(), 5000);
3708
+ } catch (err) {
3709
+ toast(err.message);
3710
+ }
3711
+ },
3712
+ },
3713
+ "重启服务",
3714
+ ),
3715
+ "Notification" in window && Notification.permission !== "granted"
3716
+ ? h("button", { class: "btn", onclick: () => Notification.requestPermission() }, "开启浏览器通知")
3717
+ : null,
3718
+ ),
3719
+ ),
3720
+ );
3721
+ }
3722
+
3723
+ // ---------- 启动 ----------
3724
+
3725
+ boot()
3726
+ .then(() => {
3727
+ ui.messages?.addEventListener("scroll", () => (ui.stick = nearBottom()), { passive: true });
3728
+ })
3729
+ .catch((err) => {
3730
+ fill($app, h("div", { class: "auth" }, h("div", { class: "auth-card" }, h("p", {}, `加载失败:${err.message}`))));
3731
+ });