@chenacc/agentbridge 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,2211 @@
1
+ import { createCommandId } from "/command-id.js";
2
+ import { absorbEvent, buildEntries, bumpRenderGeneration, indexDeltas, mergeHistory, trimEvents } from "/transcript.js";
3
+ import { adapterCreationPresentation } from "/adapter-capabilities.js";
4
+ import { buildAgentDrawer, discoverableAdapters, relativeTimeLabel, resolveWorkspaceNavigation } from "/workspace-navigation.js";
5
+
6
+ const $ = (selector) => document.querySelector(selector);
7
+
8
+ const model = {
9
+ state: null,
10
+ events: [],
11
+ selectedId: null,
12
+ selectedWorkspaceId: null,
13
+ lastSeq: 0,
14
+ stream: null,
15
+ // The last stream state the browser reported, before the selected session's own
16
+ // desktop liveness is taken into account (see applyConnection).
17
+ connection: null,
18
+ bootstrap: null,
19
+ stopConfirmUntil: 0,
20
+ shutdownConfirmUntil: 0,
21
+ sessionSignature: "",
22
+ workspaceSignature: "",
23
+ discoveryLoading: false,
24
+ discoveryMessage: "",
25
+ renderTimer: null,
26
+ deltaEvents: new Map(),
27
+ // Per session: "loading" | "loaded" | "failed". History is fetched once per
28
+ // conversation per page load instead of relying on one global replay.
29
+ historyStatus: new Map(),
30
+ // Ids already in `events`. Fetched history and the live stream describe the
31
+ // same event, and either can arrive first, so both paths consult this set.
32
+ eventIds: new Set(),
33
+ // Per session: the seq to page before, and whether anything precedes it.
34
+ historyMeta: new Map(),
35
+ // Session id whose earlier page is being fetched right now.
36
+ historyLoading: null,
37
+ // Set once KaTeX is usable; transcript entries are re-rendered at that point.
38
+ mathRenderer: null,
39
+ // Drawer state: which Agents and projects are open, and what is typed in its
40
+ // search box. `null` means nothing has been remembered yet, so the tree opens
41
+ // along the selected conversation instead of showing a collapsed list.
42
+ drawer: { open: false, query: "", expandedAgents: null, collapsedProjects: [] },
43
+ // 深搜:抽屉先按标题/项目本地过滤,再向服务端问"所有会话里提到过什么"
44
+ deepSearch: { query: "", loading: false, hits: [], error: "", truncated: false },
45
+ deepSearchTimer: null,
46
+ pendingAttachments: [],
47
+ };
48
+
49
+ const DRAWER_KEY = "agentbridge.drawer.expansion";
50
+
51
+ function loadDrawerExpansion() {
52
+ try {
53
+ const stored = JSON.parse(localStorage.getItem(DRAWER_KEY) ?? "null");
54
+ if (!stored) return { expandedAgents: null, collapsedProjects: [] };
55
+ return {
56
+ expandedAgents: Array.isArray(stored.agents) ? stored.agents : null,
57
+ collapsedProjects: Array.isArray(stored.collapsedProjects) ? stored.collapsedProjects : [],
58
+ };
59
+ } catch {
60
+ return { expandedAgents: null, collapsedProjects: [] };
61
+ }
62
+ }
63
+
64
+ function saveDrawerExpansion() {
65
+ const { expandedAgents, collapsedProjects } = model.drawer;
66
+ if (expandedAgents === null) return;
67
+ try {
68
+ localStorage.setItem(DRAWER_KEY, JSON.stringify({ agents: expandedAgents, collapsedProjects }));
69
+ } catch {
70
+ // Private browsing may block storage; expansion simply resets next time.
71
+ }
72
+ }
73
+
74
+ function toggleDrawerExpansion(kind, id) {
75
+ if (kind === "agent") {
76
+ // Materialise "nothing remembered" into a real list on the first tap, or the
77
+ // cold-start default would immediately re-open what was just closed.
78
+ const current = model.drawer.expandedAgents ?? [];
79
+ model.drawer.expandedAgents = current.includes(id)
80
+ ? current.filter((entry) => entry !== id)
81
+ : [...current, id];
82
+ } else {
83
+ const current = model.drawer.collapsedProjects;
84
+ model.drawer.collapsedProjects = current.includes(id)
85
+ ? current.filter((entry) => entry !== id)
86
+ : [...current, id];
87
+ }
88
+ saveDrawerExpansion();
89
+ renderDrawer();
90
+ }
91
+
92
+ function setDrawerOpen(open) {
93
+ const drawer = $("#agent-drawer");
94
+ const mask = $("#drawer-mask");
95
+ const toggle = $("#drawer-toggle");
96
+ if (!drawer || !toggle) return;
97
+ // On a wide window the drawer is the permanent sidebar: "closing" it would
98
+ // leave the console with no navigation at all.
99
+ if (drawerIsPermanent()) {
100
+ model.drawer.open = true;
101
+ drawer.classList.add("open");
102
+ drawer.removeAttribute("inert");
103
+ mask.classList.remove("open");
104
+ mask.hidden = true;
105
+ toggle.setAttribute("aria-expanded", "true");
106
+ renderDrawer();
107
+ return;
108
+ }
109
+ model.drawer.open = open;
110
+ drawer.classList.toggle("open", open);
111
+ if (open) {
112
+ drawer.removeAttribute("inert");
113
+ mask.hidden = false;
114
+ requestAnimationFrame(() => mask.classList.add("open"));
115
+ toggle.setAttribute("aria-expanded", "true");
116
+ renderDrawer();
117
+ $("#drawer-query")?.focus({ preventScroll: true });
118
+ return;
119
+ }
120
+ drawer.setAttribute("inert", "");
121
+ mask.classList.remove("open");
122
+ mask.hidden = true;
123
+ toggle.setAttribute("aria-expanded", "false");
124
+ toggle.focus({ preventScroll: true });
125
+ }
126
+
127
+ function drawerSessionRow(session, currentSessionId) {
128
+ const active = session.id === currentSessionId;
129
+ return `<button class="drawer-session${active ? " active" : ""}" data-session="${escapeHtml(session.id)}" type="button"${active ? ' aria-current="true"' : ""}>
130
+ <span class="status-mark ${escapeHtml(session.status)}" aria-label="${escapeHtml(statusLabel(session.status))}"></span>
131
+ <span class="drawer-session-body">
132
+ <span class="drawer-session-title">${escapeHtml(session.title)}</span>
133
+ <span class="drawer-session-meta">${escapeHtml(session.workspaceName)} · ${escapeHtml(relativeTimeLabel(session.lastActiveAt))}</span>
134
+ </span>
135
+ </button>`;
136
+ }
137
+
138
+ // The drawer is the phone's only navigation surface, so it is rebuilt from the
139
+ // same catalog the desktop rail uses — Agent first, because which tool owns a
140
+ // conversation is the choice people actually have to make.
141
+ function renderDrawer() {
142
+ const tree = $("#drawer-tree");
143
+ if (!tree || !model.state) return;
144
+ tree.dataset.query = model.drawer.query;
145
+ const groups = buildAgentDrawer({
146
+ sessions: model.state.sessions ?? [],
147
+ workspaces: model.state.workspaces ?? [],
148
+ adapters: model.state.adapters ?? [],
149
+ selectedSessionId: model.selectedId,
150
+ query: model.drawer.query,
151
+ expandedAgents: model.drawer.expandedAgents,
152
+ collapsedProjects: model.drawer.collapsedProjects,
153
+ });
154
+ const session = currentSession();
155
+ $("#drawer-title").textContent = session?.title ?? "会话";
156
+ renderDrawerNotifications();
157
+ $("#drawer-subtitle").textContent = session
158
+ ? `${session.adapterId} · ${session.projectAlias}`
159
+ : "按 Agent 与项目浏览";
160
+ $("#drawer-discover").hidden = discoverableAdapters(model.state.adapters ?? []).length === 0;
161
+ $("#drawer-refresh").disabled = false;
162
+
163
+ if (!groups.length) {
164
+ tree.innerHTML = `<p class="drawer-empty">没有匹配的会话。试试项目名或 Agent 名。</p>`;
165
+ return;
166
+ }
167
+
168
+ tree.innerHTML = groups.map((group) => {
169
+ const badgeClass = group.capability === "attach" ? " attach" : group.capability === "unavailable" ? " unavailable" : "";
170
+ const head = `<button class="drawer-agent" data-agent="${escapeHtml(group.id)}" type="button" aria-expanded="${group.expanded ? "true" : "false"}">
171
+ <svg class="drawer-chevron" aria-hidden="true"><use href="#icon-chevron"/></svg>
172
+ <span class="drawer-agent-label">${escapeHtml(group.label)}</span>
173
+ <span class="drawer-agent-badge${badgeClass}">${escapeHtml(group.capabilityLabel)}</span>
174
+ <span class="drawer-agent-count">${group.sessionCount || ""}</span>
175
+ </button>`;
176
+ if (group.empty) {
177
+ return `<div class="drawer-group">${head}<p class="drawer-empty">${escapeHtml(group.note ?? "没有可用的会话记录")}</p></div>`;
178
+ }
179
+ if (!group.expanded) return `<div class="drawer-group">${head}</div>`;
180
+ const projects = group.workspaces.map((project) => {
181
+ const rows = project.sessions.map((entry) => drawerSessionRow(entry, model.selectedId)).join("");
182
+ if (project.skipProjectRow) {
183
+ return `<div class="drawer-sessions" style="padding-left:8px">${rows}</div>`;
184
+ }
185
+ return `<div class="drawer-project-block">
186
+ <button class="drawer-project" data-project="${escapeHtml(project.id)}" type="button" aria-expanded="${project.expanded ? "true" : "false"}">
187
+ <svg class="drawer-chevron" aria-hidden="true"><use href="#icon-chevron"/></svg>
188
+ <span class="drawer-project-label">${escapeHtml(project.name)}</span>
189
+ <span class="drawer-project-count">${project.sessionCount}</span>
190
+ </button>
191
+ ${project.expanded ? `<div class="drawer-sessions">${rows}</div>` : ""}
192
+ </div>`;
193
+ }).join("");
194
+ return `<div class="drawer-group">${head}<div class="drawer-projects">${projects}</div></div>`;
195
+ }).join("");
196
+ }
197
+
198
+ // Formulas are the only block type needing a real typesetting engine, and most
199
+ // conversations have none, so KaTeX is fetched on demand from this same bridge
200
+ // (never a CDN) rather than shipped with the app shell.
201
+ function loadKatex() {
202
+ if (model.katexPromise) return model.katexPromise;
203
+ model.katexPromise = new Promise((resolve) => {
204
+ const stylesheet = document.createElement("link");
205
+ stylesheet.rel = "stylesheet";
206
+ stylesheet.href = "/vendor/katex/katex.min.css";
207
+ document.head.append(stylesheet);
208
+ const script = document.createElement("script");
209
+ script.src = "/vendor/katex/katex.min.js";
210
+ script.async = true;
211
+ script.onload = () => {
212
+ const katex = window.katex;
213
+ if (!katex) return resolve(null);
214
+ resolve((tex, display) => katex.renderToString(tex, {
215
+ displayMode: display === true,
216
+ throwOnError: false,
217
+ strict: "ignore",
218
+ // `trust: false` is what keeps \href and \htmlClass from becoming an
219
+ // injection route through model-produced TeX.
220
+ trust: false,
221
+ output: "html",
222
+ }));
223
+ };
224
+ script.onerror = () => resolve(null);
225
+ document.head.append(script);
226
+ });
227
+ return model.katexPromise;
228
+ }
229
+
230
+ async function enableMathRendering() {
231
+ const renderer = await loadKatex();
232
+ if (!renderer) return;
233
+ model.mathRenderer = renderer;
234
+ bumpRenderGeneration();
235
+ renderedEntries = [];
236
+ scheduleRender();
237
+ }
238
+
239
+ // The transcript is rebuilt at most this often instead of once per event. A
240
+ // streamed turn can deliver hundreds of events per second; re-rendering for
241
+ // each one is what made the phone stall while an answer was arriving.
242
+ const RENDER_INTERVAL_MS = 80;
243
+ const LOG_EMPTY_STATE = `<div class="empty-state"><div class="empty-rule"></div><h2>准备好了</h2><p>从下方发送第一条指令。真实 Agent 可能请求访问文件或运行命令。</p></div>`;
244
+ const LOG_LOADING_STATE = `<div class="empty-state"><div class="empty-rule"></div><h2>正在载入历史</h2><p>正在向电脑读取这段会话的记录。</p></div>`;
245
+ const LOG_NO_HISTORY_STATE = `<div class="empty-state"><div class="empty-rule"></div><h2>这段会话还没有记录</h2><p>电脑上暂时没有可显示的事件;发送一条消息后会从这里继续。</p></div>`;
246
+ // A conversation's history is read on demand; the initial stream only carries the
247
+ // newest slice of the global log.
248
+ const HISTORY_LIMIT = 300;
249
+
250
+ async function ensureHistory(sessionId) {
251
+ if (!sessionId) return;
252
+ const status = model.historyStatus.get(sessionId);
253
+ if (status === "loading" || status === "loaded") return;
254
+ model.historyStatus.set(sessionId, "loading");
255
+ scheduleRender();
256
+ try {
257
+ const history = await api(`/api/sessions/${encodeURIComponent(sessionId)}/history?limit=${HISTORY_LIMIT}`);
258
+ mergeHistory(model.events, model.deltaEvents, history.events, model.eventIds);
259
+ model.historyMeta.set(sessionId, {
260
+ oldestSeq: history.oldestSeq ?? null,
261
+ hasMore: history.hasMore === true,
262
+ });
263
+ if (trimEvents(model.events, model.deltaEvents, { keepSessionId: model.selectedId })) {
264
+ // Trimming drops events, so the seen-id set is rebuilt from what survived
265
+ // instead of growing forever.
266
+ model.eventIds = new Set(model.events.map((event) => event.eventId).filter(Boolean));
267
+ }
268
+ model.historyStatus.set(sessionId, "loaded");
269
+ } catch (error) {
270
+ model.historyStatus.set(sessionId, "failed");
271
+ toast(`历史载入失败:${error.message}`);
272
+ }
273
+ scheduleRender();
274
+ }
275
+
276
+ function scheduleRender() {
277
+ if (model.renderTimer || !model.state) return;
278
+ model.renderTimer = setTimeout(() => {
279
+ model.renderTimer = null;
280
+ renderState();
281
+ }, RENDER_INTERVAL_MS);
282
+ }
283
+
284
+ function commandId() {
285
+ return createCommandId();
286
+ }
287
+
288
+ // 回答提问与批准工具调用都要先持有控制权。桌面端执行 /phone 时会替电脑占住那份租约,
289
+ // 于是手机上点「提交回答」会被拒——而这张卡片本身没有接管入口,用户只能自己去顶栏找
290
+ // 「接管输入」。点按本身就是明确的驱动意图,所以这里自动接管一次再重试。
291
+ // 每次尝试都要新的 commandId:桥按 commandId 幂等缓存(连失败一起缓存),复用同一个
292
+ // id 只会把上一次的拒绝原样回放。
293
+ async function resolveRequest(requestId, body) {
294
+ const post = () => api(`/api/requests/${encodeURIComponent(requestId)}`, {
295
+ method: "POST",
296
+ body: JSON.stringify({ ...body, commandId: commandId() }),
297
+ });
298
+ try {
299
+ return await post();
300
+ } catch (error) {
301
+ if (error.code !== "CONTROL_REQUIRED") throw error;
302
+ await api(`/api/sessions/${encodeURIComponent(model.selectedId)}/control`, {
303
+ method: "POST",
304
+ body: JSON.stringify({ commandId: commandId(), force: true }),
305
+ });
306
+ const result = await post();
307
+ toast("已接管输入");
308
+ return result;
309
+ }
310
+ }
311
+
312
+ async function api(path, options = {}) {
313
+ const response = await fetch(path, {
314
+ credentials: "same-origin",
315
+ ...options,
316
+ headers: options.body ? { "content-type": "application/json", ...(options.headers ?? {}) } : options.headers,
317
+ });
318
+ const body = response.headers.get("content-type")?.includes("application/json") ? await response.json() : null;
319
+ if (!response.ok) {
320
+ const error = new Error(body?.error ?? `请求失败 (${response.status})`);
321
+ error.status = response.status;
322
+ error.code = body?.code;
323
+ error.diagnostics = body?.diagnostics;
324
+ throw error;
325
+ }
326
+ return body;
327
+ }
328
+
329
+ function toast(message) {
330
+ const element = $("#toast");
331
+ element.textContent = message;
332
+ element.hidden = false;
333
+ clearTimeout(toast.timer);
334
+ toast.timer = setTimeout(() => { element.hidden = true; }, 4200);
335
+ }
336
+
337
+ // WeChat's webview does not always expose the async clipboard API, so a hidden
338
+ // textarea plus execCommand remains the fallback for both copy affordances.
339
+ async function copyText(value) {
340
+ try {
341
+ await navigator.clipboard.writeText(value);
342
+ return;
343
+ } catch {
344
+ const input = document.createElement("textarea");
345
+ input.value = value;
346
+ input.setAttribute("readonly", "");
347
+ input.style.position = "fixed";
348
+ input.style.opacity = "0";
349
+ document.body.append(input);
350
+ input.select();
351
+ document.execCommand("copy");
352
+ input.remove();
353
+ }
354
+ }
355
+
356
+ function setConnection(status, label) {
357
+ model.connection = { status, label };
358
+ applyConnection();
359
+ }
360
+
361
+ /**
362
+ * Show the fact that actually decides whether typing does anything.
363
+ *
364
+ * The stream being open only means the phone can hear *this bridge*; a DSH Desktop
365
+ * conversation also needs its companion polling, and for the whole time that link
366
+ * was dead the chip still read 实时同步 — the exact reassuring lie that kept a
367
+ * person waiting on a turn that had never started. So the session's own desktop
368
+ * liveness wins over the stream state while it applies.
369
+ */
370
+ function applyConnection() {
371
+ const element = $("#connection-status");
372
+ const session = currentSession();
373
+ const desktopDown = session?.desktopConnected === false;
374
+ const state = desktopDown ? { status: "offline", label: "桌面端未连接" } : model.connection ?? { status: "", label: "正在连接" };
375
+ element.className = `connection ${state.status}`;
376
+ element.lastElementChild.textContent = state.label;
377
+ }
378
+
379
+ async function pairFromUrl() {
380
+ const code = new URLSearchParams(location.search).get("code");
381
+ if (!code) return {};
382
+ $("#pairing-screen").hidden = false;
383
+ try {
384
+ const result = await api("/api/pairings/exchange", {
385
+ method: "POST",
386
+ body: JSON.stringify({ code, deviceName: /MicroMessenger/i.test(navigator.userAgent) ? "微信手机" : "移动浏览器" }),
387
+ });
388
+ history.replaceState({}, "", "/");
389
+ $("#pairing-message").textContent = "配对成功,正在载入安全控制台。";
390
+ return result;
391
+ } catch (error) {
392
+ $("#pairing-message").textContent = `${error.message}。请回到电脑刷新二维码后重试。`;
393
+ $(".loader").hidden = true;
394
+ throw error;
395
+ }
396
+ }
397
+
398
+ // A pairing QR may carry ?key=... when the access-key gate is enabled. Swap it
399
+ // for an HttpOnly cookie before anything else, then strip it from the URL so it
400
+ // never lingers in browser history.
401
+ async function storeAccessKeyFromUrl() {
402
+ const params = new URLSearchParams(location.search);
403
+ const key = params.get("key");
404
+ if (!key) return;
405
+ await api("/api/access", { method: "POST", body: JSON.stringify({ key }) });
406
+ params.delete("key");
407
+ const query = params.toString();
408
+ history.replaceState({}, "", `${location.pathname}${query ? `?${query}` : ""}`);
409
+ }
410
+
411
+ function showAccessScreen(message) {
412
+ $("#pairing-screen").hidden = true;
413
+ $("#app").hidden = true;
414
+ $("#access-message").textContent = message;
415
+ $("#access-screen").hidden = false;
416
+ }
417
+
418
+ async function ensureSession() {
419
+ try {
420
+ return await api("/api/state");
421
+ } catch (error) {
422
+ if (/Access key/.test(error.message)) {
423
+ showAccessScreen("此实例已开启公网保护。请重新扫描电脑上的二维码,或输入启动器窗口中显示的访问密钥。");
424
+ throw error;
425
+ }
426
+ if (!/Authentication/.test(error.message)) throw error;
427
+ if (!isLoopbackPage()) {
428
+ throw new Error("此手机尚未配对,或登录已过期。请扫描电脑上的一次性二维码后再试。");
429
+ }
430
+ await api("/api/local-session", { method: "POST" });
431
+ return api("/api/state");
432
+ }
433
+ }
434
+
435
+ function isLoopbackPage() {
436
+ return ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
437
+ }
438
+
439
+ function returnUrl() {
440
+ if (model.bootstrap?.pairing?.url) return `${new URL(model.bootstrap.pairing.url).origin}/`;
441
+ return `${location.origin}/`;
442
+ }
443
+
444
+ async function rememberSession(sessionId = model.selectedId) {
445
+ if (!sessionId) return;
446
+ await api("/api/recent-session", { method: "POST", body: JSON.stringify({ sessionId }) });
447
+ if (model.state?.device) model.state.device.lastSessionId = sessionId;
448
+ }
449
+
450
+ function renderReturnEntry() {
451
+ const url = returnUrl();
452
+ $("#return-entry").hidden = false;
453
+ try {
454
+ applyReturnDetails(localStorage.getItem(RETURN_DETAILS_KEY) === "1");
455
+ } catch {
456
+ applyReturnDetails(false);
457
+ }
458
+ $("#return-url").textContent = url;
459
+ if (model.bootstrap) {
460
+ $("#desktop-return-url").textContent = url;
461
+ $("#return-qr").src = `/api/return-qr.svg?t=${Date.now()}`;
462
+ }
463
+ }
464
+
465
+ async function loadBootstrap() {
466
+ try {
467
+ model.bootstrap = await api("/api/bootstrap");
468
+ $("#desktop-pairing").hidden = false;
469
+ $("#pairing-url").textContent = model.bootstrap.pairing.url;
470
+ $("#pairing-qr").src = `/api/pairing/qr.svg?t=${Date.now()}`;
471
+ await loadDevices();
472
+ renderNotifications();
473
+ renderIntegrationStatus();
474
+ renderDrawerNotifications();
475
+ } catch {
476
+ model.bootstrap = null;
477
+ }
478
+ }
479
+
480
+ function deviceRow(device, currentDeviceId) {
481
+ const row = document.createElement("li");
482
+ row.className = "device-row";
483
+ const label = document.createElement("span");
484
+ label.className = "device-name";
485
+ const expires = new Date(device.expiresAt).toLocaleDateString();
486
+ label.textContent = device.id === currentDeviceId
487
+ ? `${device.deviceName}(本设备)`
488
+ : `${device.deviceName} · ${expires} 到期`;
489
+ row.append(label);
490
+ if (device.id !== currentDeviceId) {
491
+ const button = document.createElement("button");
492
+ button.className = "text-button device-revoke";
493
+ button.type = "button";
494
+ button.textContent = "撤销";
495
+ button.addEventListener("click", async () => {
496
+ button.disabled = true;
497
+ try {
498
+ await api(`/api/devices/${encodeURIComponent(device.id)}/revoke`, { method: "POST" });
499
+ toast(`已撤销 ${device.deviceName} 的访问权限`);
500
+ await loadDevices();
501
+ } catch (error) {
502
+ button.disabled = false;
503
+ toast(error.message);
504
+ }
505
+ });
506
+ row.append(button);
507
+ }
508
+ return row;
509
+ }
510
+
511
+ function renderNotifications() {
512
+ const notifications = model.state?.notifications;
513
+ const note = $("#notify-note");
514
+ const button = $("#notify-test");
515
+ if (!note || !button) return;
516
+ if (notifications?.enabled) {
517
+ note.textContent = `已开启(${notifications.format}):Agent 需要批准、等你回答、或回合结束时都会推送。`;
518
+ button.hidden = false;
519
+ } else {
520
+ note.textContent = "未配置。设置 AGENTBRIDGE_NOTIFY_WEBHOOK 后,Agent 需要批准或跑完时会推送到你的聊天工具。";
521
+ button.hidden = true;
522
+ }
523
+ }
524
+
525
+ async function loadDevices() {
526
+ try {
527
+ const { devices, currentDeviceId } = await api("/api/devices");
528
+ const list = $("#device-list");
529
+ if (!devices.length) {
530
+ const empty = document.createElement("li");
531
+ empty.className = "device-empty";
532
+ empty.textContent = "暂无已配对设备。手机扫码配对后会显示在这里。";
533
+ list.replaceChildren(empty);
534
+ return;
535
+ }
536
+ list.replaceChildren(...devices.map((device) => deviceRow(device, currentDeviceId)));
537
+ } catch {
538
+ // Devices section is informational; leave the list empty on failure.
539
+ }
540
+ }
541
+
542
+ function currentSession() {
543
+ return model.state?.sessions.find((session) => session.id === model.selectedId) ?? null;
544
+ }
545
+
546
+ function currentWorkspace() {
547
+ return model.state?.workspaces?.find((workspace) => workspace.id === model.selectedWorkspaceId) ?? null;
548
+ }
549
+
550
+
551
+ function statusLabel(status) {
552
+ return ({ starting: "启动中", ready: "就绪", running: "执行中", stopping: "停止中", failed: "失败", offline: "离线" })[status] ?? status;
553
+ }
554
+
555
+ function eventStatusLabel(kind) {
556
+ return ({
557
+ "session.created": "会话已创建",
558
+ "session.ready": "Agent 已就绪",
559
+ "turn.started": "本轮已开始",
560
+ "turn.completed": "本轮已完成",
561
+ "turn.cancelled": "本轮已取消",
562
+ "control.granted": "输入控制权已更新",
563
+ "turn.stop_requested": "已请求停止本轮",
564
+ "model.changed": "模型已切换",
565
+ })[kind] ?? kind;
566
+ }
567
+
568
+ function renderState() {
569
+ if (!model.state) return;
570
+ $("#machine-name").textContent = model.state.machine.name;
571
+ const navigation = resolveWorkspaceNavigation({
572
+ workspaces: model.state.workspaces,
573
+ sessions: model.state.sessions,
574
+ selectedWorkspaceId: model.selectedWorkspaceId,
575
+ selectedSessionId: model.selectedId,
576
+ });
577
+ model.selectedWorkspaceId = navigation.workspaceId;
578
+ model.selectedId = navigation.sessionId;
579
+ // The drawer is the only navigation surface now. It is rebuilt from the same
580
+ // catalog every time, because a status change is exactly what a reader needs
581
+ // to see reflected in the list.
582
+ renderDrawer();
583
+ renderSession();
584
+ }
585
+
586
+ function selectedSessionModel() {
587
+ try { return JSON.parse($("#model-select").value); } catch { return null; }
588
+ }
589
+
590
+ function renderReasoningOptions(directory) {
591
+ const selected = selectedSessionModel();
592
+ const target = directory?.groups.find((group) => group.id === selected?.provider)?.models.find((entry) => entry.id === selected?.model);
593
+ const control = $("#reasoning-control");
594
+ const select = $("#reasoning-select");
595
+ const efforts = target?.reasoning?.efforts ?? [];
596
+ control.hidden = efforts.length === 0;
597
+ select.innerHTML = `<option value="">使用 DSH 默认推理强度</option>${efforts.map((effort) => `<option value="${escapeHtml(effort.id)}" ${directory.current?.reasoningEffort === effort.id ? "selected" : ""}>${escapeHtml(effort.name)}</option>`).join("")}`;
598
+ renderPicker("reasoning");
599
+ }
600
+
601
+ function adapterEntry(adapterId) {
602
+ return (model.state?.adapters ?? []).find((entry) => entry.id === adapterId) ?? null;
603
+ }
604
+
605
+ function renderModelControl(session, ownsControl) {
606
+ const control = $("#model-control");
607
+ const directory = session.modelDirectory;
608
+ const adapter = adapterEntry(session.adapterId);
609
+ // The control used to disappear entirely when an Agent had not reported a
610
+ // directory yet, which reads as "the feature is gone". Show it whenever the
611
+ // Agent can switch models at all, and say why it is locked.
612
+ const supported = adapter ? adapter.capabilities?.selectModel !== false : true;
613
+ control.hidden = !supported;
614
+ if (!supported) return;
615
+ const isCcSwitch = directory?.source === "cc-switch";
616
+ const label = isCcSwitch ? "CC Switch 模型" : session.adapterId === "deepseek-harness-desktop" ? "DSH 模型" : session.adapterId === "codex" ? "Codex 模型" : "会话模型";
617
+ $("#model-label").textContent = label;
618
+ $("#model-select").setAttribute("aria-label", label);
619
+ const select = $("#model-select");
620
+ const choices = (directory?.groups ?? []).flatMap((group) => group.models.map((entry) => ({ provider: group.id, model: entry.id, label: `${group.name} · ${entry.name}` })));
621
+ select.innerHTML = choices.length
622
+ ? choices.map((entry) => {
623
+ const value = JSON.stringify({ provider: entry.provider, model: entry.model });
624
+ const selected = directory?.current?.provider === entry.provider && directory?.current?.model === entry.model;
625
+ return `<option value="${escapeHtml(value)}" ${selected ? "selected" : ""}>${escapeHtml(entry.label)}</option>`;
626
+ }).join("")
627
+ : `<option value="">${directory ? "Agent 未提供可切换模型" : "还没有模型目录"}</option>`;
628
+ renderReasoningOptions(directory);
629
+ renderPicker("model");
630
+ const isIdle = session.status !== "running" && session.status !== "stopping";
631
+ const offline = session.status === "offline" || session.status === "failed";
632
+ const enabled = choices.length > 0 && ownsControl && isIdle && !offline;
633
+ select.disabled = !enabled;
634
+ $("#reasoning-select").disabled = !enabled;
635
+ $("#apply-model").disabled = !enabled;
636
+ renderPicker("model");
637
+ renderPicker("reasoning");
638
+ const failure = directory?.failures?.[0]?.message;
639
+ // An offline Agent outranks every other reason: taking control cannot help until
640
+ // the desktop side is running again, so that is what the note has to say.
641
+ $("#model-note").textContent = offline
642
+ ? directory
643
+ ? "这个会话的 Agent 当前离线:在 DSH Desktop 里运行 /phone 后即可切换模型。"
644
+ : "这个会话的 Agent 当前离线,也还没有上报模型目录:在 DSH Desktop 里运行 /phone 后再看。"
645
+ : !directory
646
+ ? "Agent 还没有上报模型目录,连上之后这里会解锁。"
647
+ : failure
648
+ ? `模型目录部分不可用:${failure}`
649
+ : !directory.current ? `${isCcSwitch ? "CC Switch 未报告可识别的当前模型;可以从当前厂商目录选择一个模型。" : "Agent 未报告当前模型;可以从已配置目录选择一个模型。"}`
650
+ : !ownsControl ? "接管输入后才能切换模型。"
651
+ : !isIdle ? "等待当前 Agent 回合结束后再切换模型。"
652
+ : isCcSwitch ? "仅影响此手机会话后续的 Claude 回合;不会改动 CC Switch 厂商、密钥或代理。"
653
+ : session.adapterId === "codex" ? "模型与推理档位来自本机 Codex 当前账户;仅影响此会话后续回合。"
654
+ : directory.routable ? "模型列表来自当前 DSH Desktop 配置。" : "当前 Agent 模型不可路由。";
655
+ }
656
+
657
+ function renderSession() {
658
+ const session = currentSession();
659
+ $("#empty-state").hidden = Boolean(session);
660
+ $("#session-view").hidden = !session;
661
+ if (!session) {
662
+ const hasSessions = (model.state?.sessions ?? []).length > 0;
663
+ $("#empty-title").textContent = hasSessions ? "选择一个会话" : "还没有会话";
664
+ $("#empty-description").textContent = hasSessions
665
+ ? "点左上角 ☰ 按 Agent 与项目挑选会话。"
666
+ : "点左上角 ☰ 后选 + 新建会话,或在抽屉底部用「同步历史」读取 Agent 的公开会话。";
667
+ applyConnection();
668
+ renderQuickNav();
669
+ return;
670
+ }
671
+
672
+ $("#session-title-heading").textContent = session.title;
673
+ $("#session-adapter").textContent = session.adapterId;
674
+ $("#session-project").textContent = session.projectAlias;
675
+ // A DSH Desktop conversation only exists inside the desktop app, so without its
676
+ // companion channel there is nothing to send to. Saying that up front — and in words
677
+ // that name the one action that fixes it — beats a red "执行失败" for what is really
678
+ // "the desktop side is not connected right now".
679
+ const desktopOffline = session.desktopConnected === false;
680
+ const sessionHint = desktopOffline
681
+ ? "桌面端还没连上这个会话:在这台电脑的 DSH Desktop 里打开该会话并执行 /phone。之后手机会自动接回,不用重新扫码。"
682
+ : "";
683
+ const sessionError = $("#session-error");
684
+ sessionError.hidden = !sessionHint && !session.lastError;
685
+ sessionError.classList.toggle("hint", Boolean(sessionHint));
686
+ sessionError.setAttribute("role", sessionHint ? "status" : "alert");
687
+ sessionError.textContent = sessionHint || (session.lastError ? `执行失败:${session.lastError}` : "");
688
+ const ownsControl = session.controller?.deviceId === model.state.device.id;
689
+ // A running turn no longer locks the composer: the bridge queues what you type and
690
+ // sends it the moment the turn ends (`.queue-item` rows above the input show it).
691
+ // The only reasons not to accept text are a desktop that is not connected and a
692
+ // turn that has not even been started yet.
693
+ const busy = session.status === "running" || session.status === "stopping";
694
+ const canSend = !desktopOffline && session.status !== "starting" && (!session.controller || ownsControl);
695
+ $("#control-status").textContent = ownsControl
696
+ ? `当前由此设备控制,租约至 ${new Date(session.controller.expiresAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
697
+ : session.controller ? `当前由 ${session.controller.deviceName} 控制` : "发送首条消息时自动获得控制权";
698
+ $("#take-control").hidden = ownsControl || !session.controller;
699
+ $("#composer").querySelector("button").disabled = !canSend;
700
+ $("#message-input").disabled = !canSend;
701
+ $("#message-input").placeholder = desktopOffline
702
+ ? "桌面端未连接:先在电脑上那个会话里执行 /phone…"
703
+ : session.status === "stopping"
704
+ ? "正在停止本轮,结束后自动发送…"
705
+ : session.status === "running"
706
+ ? "Agent 正在执行:现在发送会排队,本轮结束后自动发出…"
707
+ : !session.controller ? "发送第一条指令并取得控制权…" : ownsControl ? "继续给 Agent 指令…" : "先接管输入再发送…";
708
+ const stopButton = $("#stop-session");
709
+ // 停止中 keeps the control visible: it is the only place that says the request is
710
+ // out, and it must not look like the turn simply finished.
711
+ stopButton.hidden = !busy;
712
+ stopButton.disabled = !ownsControl || session.status === "stopping";
713
+ // A pending "点击确认" must survive a re-render, or the second tap looks like the
714
+ // first one again.
715
+ if (Date.now() > (model.stopConfirmUntil ?? 0)) {
716
+ stopButton.querySelector("span").textContent = session.status === "stopping" ? "停止中…" : "停止";
717
+ }
718
+ stopButton.title = !ownsControl
719
+ ? "只有当前控制设备可以停止执行"
720
+ : session.status === "stopping" ? "停止请求已发出,等 Agent 结束本轮" : "停止当前执行";
721
+ renderModelControl(session, ownsControl);
722
+ renderAutoApprove(session);
723
+ renderQueuedMessages(session);
724
+ renderUsage(session);
725
+ applyConnection();
726
+ void ensureHistory(session.id);
727
+ renderHistoryMore(session.id);
728
+ renderEvents();
729
+ renderQuickNav();
730
+ }
731
+
732
+ // A conversation longer than one page keeps its oldest page one tap away. The
733
+ // control lives above the log so prepending earlier entries never hides it, and
734
+ // it is hidden entirely once the computer reports nothing precedes the page.
735
+ function renderHistoryMore(sessionId) {
736
+ const control = $("#history-more");
737
+ const meta = model.historyMeta.get(sessionId);
738
+ const loading = model.historyLoading === sessionId;
739
+ control.hidden = !sessionId || (!loading && meta?.hasMore !== true);
740
+ const button = $("#load-earlier");
741
+ button.disabled = loading;
742
+ button.querySelector("span").textContent = loading ? "正在加载…" : "加载更早";
743
+ }
744
+
745
+ async function loadEarlierHistory() {
746
+ const sessionId = model.selectedId;
747
+ if (!sessionId || model.historyLoading) return;
748
+ const meta = model.historyMeta.get(sessionId);
749
+ if (!meta?.oldestSeq) return;
750
+ model.historyLoading = sessionId;
751
+ renderHistoryMore(sessionId);
752
+ const log = $("#event-log");
753
+ const heightBefore = log.scrollHeight;
754
+ const scrollBefore = log.scrollTop;
755
+ try {
756
+ const page = await api(`/api/sessions/${encodeURIComponent(sessionId)}/history?limit=${HISTORY_LIMIT}&before=${meta.oldestSeq}`);
757
+ const added = mergeHistory(model.events, model.deltaEvents, page.events, model.eventIds);
758
+ model.historyMeta.set(sessionId, {
759
+ oldestSeq: page.oldestSeq ?? meta.oldestSeq,
760
+ hasMore: page.hasMore === true,
761
+ });
762
+ if (trimEvents(model.events, model.deltaEvents, { keepSessionId: sessionId })) {
763
+ model.eventIds = new Set(model.events.map((event) => event.eventId).filter(Boolean));
764
+ }
765
+ if (!added) toast("没有更早的记录了");
766
+ renderHistoryMore(sessionId);
767
+ renderEvents();
768
+ // Keep the reader on the same line: the earlier page lands above it, so the
769
+ // scroll offset grows by exactly the height that was inserted.
770
+ log.scrollTop = scrollBefore + (log.scrollHeight - heightBefore);
771
+ } catch (error) {
772
+ toast(`加载更早失败:${error.message}`);
773
+ } finally {
774
+ model.historyLoading = null;
775
+ renderHistoryMore(sessionId);
776
+ }
777
+ }
778
+
779
+ let renderedEntries = [];
780
+
781
+ // Entries are append-only except for the reply that is still streaming, so the
782
+ // DOM is diffed by key: unchanged entries are never touched, and only the
783
+ // growing reply is rewritten. Rebuilding all of innerHTML per event was the
784
+ // main reason a long conversation felt frozen on a phone.
785
+ function applyEntries(log, entries) {
786
+ if (!entries.length) {
787
+ if (log.childElementCount === 0 || renderedEntries.length) log.innerHTML = LOG_EMPTY_STATE;
788
+ renderedEntries = [];
789
+ return;
790
+ }
791
+
792
+ let common = 0;
793
+ while (common < renderedEntries.length && common < entries.length && renderedEntries[common].key === entries[common].key) common += 1;
794
+
795
+ if (common === entries.length && common === renderedEntries.length && common > 0) {
796
+ const node = log.children[common - 1];
797
+ if (node && node.lastEntryHtml !== entries[common - 1].html) {
798
+ node.lastEntryHtml = entries[common - 1].html;
799
+ node.innerHTML = entries[common - 1].html;
800
+ }
801
+ renderedEntries = entries;
802
+ return;
803
+ }
804
+
805
+ const children = [...log.children];
806
+ for (let index = common; index < children.length; index += 1) children[index].remove();
807
+ const fragment = document.createDocumentFragment();
808
+ for (let index = common; index < entries.length; index += 1) {
809
+ const node = document.createElement("article");
810
+ node.className = `event-entry ${entries[index].className}`.trim();
811
+ node.dataset.entryKey = entries[index].key;
812
+ node.lastEntryHtml = entries[index].html;
813
+ node.innerHTML = entries[index].html;
814
+ fragment.append(node);
815
+ }
816
+ log.append(fragment);
817
+ renderedEntries = entries;
818
+ }
819
+
820
+ function renderEvents() {
821
+ const log = $("#event-log");
822
+ const followsTail = !log.childElementCount || log.scrollHeight - log.scrollTop - log.clientHeight < 80;
823
+ const relevant = model.events.filter((event) => event.sessionId === model.selectedId);
824
+ if (!relevant.length && model.selectedId) {
825
+ // "准备好了" is only true for a conversation with nothing in it. While its
826
+ // history is being fetched, or when the computer has nothing to show, saying
827
+ // so is what stops an empty log from reading as a failed load.
828
+ const status = model.historyStatus.get(model.selectedId);
829
+ log.innerHTML = status === "loading" ? LOG_LOADING_STATE : LOG_NO_HISTORY_STATE;
830
+ renderedEntries = [];
831
+ renderQuickNav();
832
+ return;
833
+ }
834
+ applyEntries(log, buildEntries(relevant, { statusLabel: eventStatusLabel, math: model.mathRenderer }));
835
+ // Formulas start as escaped TeX placeholders; the typesetting engine is fetched
836
+ // only once a message actually contains one, so a conversation without math
837
+ // never pays for it.
838
+ if (!model.mathRenderer && !model.katexPromise && log.querySelector(".math-tex")) void enableMathRendering();
839
+ if (followsTail) log.scrollTop = log.scrollHeight;
840
+ renderQuickNav();
841
+ }
842
+
843
+ function escapeHtml(value) {
844
+ return String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
845
+ }
846
+
847
+ async function refreshState() {
848
+ model.state = await api("/api/state");
849
+ renderState();
850
+ }
851
+
852
+ function connectEvents() {
853
+ model.stream?.close();
854
+ setConnection("", "正在连接");
855
+ // No explicit `after` cursor: the browser resends the last event id it saw, so
856
+ // an automatic reconnect resumes from where it stopped. Pinning `after=0` in
857
+ // the URL made every reconnect replay the whole buffered history, which is why
858
+ // the transcript used to crawl back in over the tunnel.
859
+ const stream = new EventSource("/api/events");
860
+ model.stream = stream;
861
+ stream.addEventListener("open", () => setConnection("online", "实时同步"));
862
+ stream.addEventListener("error", () => setConnection("offline", "正在重连"));
863
+ stream.addEventListener("agent-event", (message) => {
864
+ const event = JSON.parse(message.data);
865
+ if (event.seq <= model.lastSeq) return;
866
+ model.lastSeq = event.seq;
867
+ // A conversation whose history was fetched before the stream connected is
868
+ // replayed again by the server; without this the whole transcript rendered
869
+ // twice.
870
+ if (event.eventId && model.eventIds.has(event.eventId)) return;
871
+ if (event.eventId) model.eventIds.add(event.eventId);
872
+ absorbEvent(model.events, model.deltaEvents, event);
873
+ // Announce only completed messages and recognized status changes; deltas
874
+ // and tool chatter would spam the polite live region on every token.
875
+ if (event.kind === "message.completed") {
876
+ $("#event-announcer").textContent = `${event.payload.role === "user" ? "你" : "Agent"}:${String(event.payload.text ?? "").slice(0, 180)}`;
877
+ } else if (event.kind === "approval.requested") {
878
+ $("#event-announcer").textContent = "收到一条需要批准的请求";
879
+ } else if (event.kind !== "message.delta") {
880
+ const label = eventStatusLabel(event.kind);
881
+ if (label !== event.kind) $("#event-announcer").textContent = label;
882
+ }
883
+ if (trimEvents(model.events, model.deltaEvents, { keepSessionId: model.selectedId })) {
884
+ model.eventIds = new Set(model.events.map((entry) => entry.eventId).filter(Boolean));
885
+ }
886
+ const session = model.state.sessions.find((item) => item.id === event.sessionId);
887
+ if (session) {
888
+ if (event.kind === "turn.started") session.status = "running";
889
+ if (event.kind === "turn.completed" || event.kind === "turn.cancelled") session.status = "ready";
890
+ if (event.kind === "turn.failed") session.status = "failed";
891
+ // The cancel is only a request until the Agent ends the turn, so the phone
892
+ // shows a distinct state instead of either pretending it worked or leaving
893
+ // the session looking untouched.
894
+ if (event.kind === "turn.stop_requested") session.status = "stopping";
895
+ if (event.kind === "control.granted") session.controller = event.payload.controller;
896
+ if (event.kind === "model.changed" && session.modelDirectory) session.modelDirectory.current = event.payload.selected;
897
+ if (event.kind === "approval.policy") session.autoApprove = event.payload.autoApprove ?? null;
898
+ }
899
+ if (event.kind.startsWith("session.") || event.kind === "conversation.discovered") void refreshState().catch((error) => toast(error.message));
900
+ else scheduleRender();
901
+ });
902
+ }
903
+
904
+ $("#composer").addEventListener("submit", async (event) => {
905
+ event.preventDefault();
906
+ const input = $("#message-input");
907
+ const text = input.value.trim();
908
+ const attachments = model.pendingAttachments.map((attachment) => attachment.id);
909
+ // An image on its own is a complete message ("what is wrong here?").
910
+ if ((!text && !attachments.length) || !model.selectedId) return;
911
+ input.value = "";
912
+ input.style.height = "auto";
913
+ try {
914
+ const result = await api(`/api/sessions/${model.selectedId}/messages`, {
915
+ method: "POST",
916
+ body: JSON.stringify({ text, ...(attachments.length ? { attachments } : {}), commandId: commandId() }),
917
+ });
918
+ model.pendingAttachments = [];
919
+ renderPendingAttachments();
920
+ if (result?.queued) {
921
+ toast(`Agent 正在忙,已排队(第 ${result.position} 位)`);
922
+ await refreshState();
923
+ }
924
+ await rememberSession();
925
+ } catch (error) {
926
+ input.value = text;
927
+ toast(error.message);
928
+ }
929
+ });
930
+
931
+ $("#message-input").addEventListener("input", (event) => {
932
+ event.target.style.height = "auto";
933
+ event.target.style.height = `${Math.min(event.target.scrollHeight, 320)}px`;
934
+ });
935
+
936
+ $("#take-control").addEventListener("click", async () => {
937
+ try {
938
+ await api(`/api/sessions/${model.selectedId}/control`, { method: "POST", body: JSON.stringify({ commandId: commandId(), force: true }) });
939
+ await rememberSession();
940
+ await refreshState();
941
+ } catch (error) { toast(error.message); }
942
+ });
943
+
944
+ $("#model-select").addEventListener("change", () => {
945
+ renderReasoningOptions(currentSession()?.modelDirectory);
946
+ });
947
+
948
+ $("#apply-model").addEventListener("click", async () => {
949
+ const session = currentSession();
950
+ const selected = selectedSessionModel();
951
+ if (!session || !selected) return;
952
+ const button = $("#apply-model");
953
+ button.disabled = true;
954
+ try {
955
+ const result = await api(`/api/sessions/${session.id}/model`, {
956
+ method: "POST",
957
+ body: JSON.stringify({
958
+ ...selected,
959
+ ...( $("#reasoning-select").value ? { reasoningEffort: $("#reasoning-select").value } : {}),
960
+ commandId: commandId(),
961
+ }),
962
+ });
963
+ if (session.modelDirectory) session.modelDirectory.current = result.selected;
964
+ await refreshState();
965
+ toast(`已切换到 ${result.selected.model}`);
966
+ } catch (error) { toast(error.message); }
967
+ finally { renderSession(); }
968
+ });
969
+
970
+ $("#stop-session").addEventListener("click", async () => {
971
+ const button = $("#stop-session");
972
+ if (Date.now() > model.stopConfirmUntil) {
973
+ model.stopConfirmUntil = Date.now() + 5000;
974
+ button.classList.add("confirming");
975
+ button.querySelector("span").textContent = "再次点击确认";
976
+ setTimeout(() => {
977
+ if (Date.now() > model.stopConfirmUntil) {
978
+ button.classList.remove("confirming");
979
+ button.querySelector("span").textContent = "停止";
980
+ }
981
+ }, 5100);
982
+ return;
983
+ }
984
+ model.stopConfirmUntil = 0;
985
+ button.classList.remove("confirming");
986
+ button.querySelector("span").textContent = "停止";
987
+ try {
988
+ const result = await api(`/api/sessions/${model.selectedId}/stop`, { method: "POST", body: JSON.stringify({ commandId: commandId() }) });
989
+ // A stop that could not be delivered is still a stop: the bridge ended the turn
990
+ // locally, and the person deserves to know that is what happened.
991
+ if (result?.forced) toast("桌面端没回应停止请求,已直接结束这一轮");
992
+ await refreshState();
993
+ } catch (error) { toast(error.message); }
994
+ });
995
+
996
+ // Read the answers out of one rendered question card. Every question in the
997
+ // batch is sent, in order, with an empty selection for the ones the user left
998
+ // alone: the agent matches its pending question by position, so a partial batch
999
+ // would be refused outright.
1000
+ function collectAnswers(card) {
1001
+ return [...card.querySelectorAll("[data-input-question]")].map((block) => {
1002
+ const selected = [...block.querySelectorAll("[data-input-option]")]
1003
+ .filter((input) => input.checked)
1004
+ .map((input) => input.value);
1005
+ const multi = block.dataset.inputMulti === "1";
1006
+ const custom = block.querySelector("[data-input-custom]")?.value?.trim() ?? "";
1007
+ return {
1008
+ id: block.dataset.inputQuestion,
1009
+ selected: multi ? selected : selected.slice(0, 1),
1010
+ ...(custom ? { custom } : {}),
1011
+ };
1012
+ });
1013
+ }
1014
+
1015
+ $("#load-earlier").addEventListener("click", () => { void loadEarlierHistory(); });
1016
+
1017
+ $("#event-log").addEventListener("click", async (event) => {
1018
+ const toolCopy = event.target.closest("[data-copy-tool]");
1019
+ if (toolCopy) {
1020
+ try {
1021
+ await copyText(toolCopy.dataset.copyTool ?? "");
1022
+ toolCopy.textContent = "已复制";
1023
+ setTimeout(() => { toolCopy.textContent = "复制命令"; }, 1600);
1024
+ } catch {
1025
+ toast("复制失败,请长按手动选择");
1026
+ }
1027
+ return;
1028
+ }
1029
+ const copy = event.target.closest("[data-copy-code]");
1030
+ if (copy) {
1031
+ // Copy the source text, not the highlighted markup: textContent is already
1032
+ // unescaped, so what lands on the clipboard is what the agent wrote.
1033
+ const code = copy.closest(".md-code")?.querySelector("code");
1034
+ if (!code) return;
1035
+ try {
1036
+ await copyText(code.textContent ?? "");
1037
+ copy.textContent = "已复制";
1038
+ setTimeout(() => { copy.textContent = "复制"; }, 1600);
1039
+ } catch {
1040
+ toast("复制失败,请长按代码手动选择");
1041
+ }
1042
+ return;
1043
+ }
1044
+ const inputSubmit = event.target.closest("[data-input-submit]");
1045
+ const inputCancel = event.target.closest("[data-input-cancel]");
1046
+ if (inputSubmit || inputCancel) {
1047
+ const card = (inputSubmit ?? inputCancel).closest(".input-entry");
1048
+ const requestId = (inputSubmit ?? inputCancel).dataset[inputSubmit ? "inputSubmit" : "inputCancel"];
1049
+ if (!card || !requestId) return;
1050
+ const buttons = [...card.querySelectorAll("[data-input-submit], [data-input-cancel]")];
1051
+ for (const button of buttons) button.disabled = true;
1052
+ // Read the answers before the first attempt: a failed attempt re-renders the
1053
+ // transcript, which would detach this card.
1054
+ const answers = inputCancel ? undefined : collectAnswers(card);
1055
+ const body = inputCancel ? { cancel: true } : { answers };
1056
+ try {
1057
+ await resolveRequest(requestId, body);
1058
+ toast(inputCancel ? "已跳过这个问题" : "回答已发送");
1059
+ } catch (error) {
1060
+ for (const button of buttons) button.disabled = false;
1061
+ toast(error.message);
1062
+ }
1063
+ return;
1064
+ }
1065
+ const button = event.target.closest("[data-approval]");
1066
+ if (!button) return;
1067
+ button.disabled = true;
1068
+ try {
1069
+ // 批准同样需要控制权,走和回答提问一样的"自动接管一次"路径。
1070
+ const decide = () => api(`/api/approvals/${button.dataset.approval}`, {
1071
+ method: "POST",
1072
+ body: JSON.stringify({ commandId: commandId(), decision: button.dataset.decision }),
1073
+ });
1074
+ try {
1075
+ await decide();
1076
+ } catch (error) {
1077
+ if (error.code !== "CONTROL_REQUIRED") throw error;
1078
+ await api(`/api/sessions/${encodeURIComponent(model.selectedId)}/control`, {
1079
+ method: "POST",
1080
+ body: JSON.stringify({ commandId: commandId(), force: true }),
1081
+ });
1082
+ await decide();
1083
+ toast("已接管输入");
1084
+ }
1085
+ } catch (error) { button.disabled = false; toast(error.message); }
1086
+ });
1087
+
1088
+ $("#rotate-pairing").addEventListener("click", async () => {
1089
+ try {
1090
+ const result = await api("/api/pairing/rotate", { method: "POST" });
1091
+ model.bootstrap.pairing = result.pairing;
1092
+ $("#pairing-url").textContent = result.pairing.url;
1093
+ $("#pairing-qr").src = `/api/pairing/qr.svg?t=${Date.now()}`;
1094
+ } catch (error) { toast(error.message); }
1095
+ });
1096
+
1097
+ $("#notify-test").addEventListener("click", async () => {
1098
+ const button = $("#notify-test");
1099
+ button.disabled = true;
1100
+ try {
1101
+ const result = await api("/api/local/notify-test", { method: "POST" });
1102
+ toast(result.sent ? `测试通知已发送(${result.format})` : `没有发出去:${result.reason}`);
1103
+ } catch (error) {
1104
+ toast(error.message);
1105
+ } finally {
1106
+ button.disabled = false;
1107
+ }
1108
+ });
1109
+
1110
+ $("#safe-shutdown").addEventListener("click", async () => {
1111
+ const button = $("#safe-shutdown");
1112
+ const label = button.querySelector("span");
1113
+ const note = $("#shutdown-note");
1114
+ if (Date.now() > model.shutdownConfirmUntil) {
1115
+ model.shutdownConfirmUntil = Date.now() + 5000;
1116
+ button.classList.add("confirming");
1117
+ label.textContent = "再次点击确认";
1118
+ note.textContent = "5 秒内再次点击,确认关闭本机 AgentBridge Node 服务。";
1119
+ setTimeout(() => {
1120
+ if (Date.now() > model.shutdownConfirmUntil && !button.disabled) {
1121
+ button.classList.remove("confirming");
1122
+ label.textContent = "安全停止";
1123
+ note.textContent = "仅关闭 AgentBridge Node 服务,不会关闭 Cloudflare 或 ZeroTier。运行中的任务会阻止关闭。";
1124
+ }
1125
+ }, 5100);
1126
+ return;
1127
+ }
1128
+
1129
+ model.shutdownConfirmUntil = 0;
1130
+ button.classList.remove("confirming");
1131
+ button.disabled = true;
1132
+ label.textContent = "正在安全停止…";
1133
+ note.textContent = "正在释放 Agent 连接并停止接受新请求,请稍候。";
1134
+ try {
1135
+ await api("/api/local/shutdown", { method: "POST", body: "{}" });
1136
+ model.stream?.close();
1137
+ model.stream = null;
1138
+ setConnection("offline", "已停止");
1139
+ label.textContent = "AgentBridge 已停止";
1140
+ note.textContent = "Node 服务已收到安全停止指令。可关闭此页面;重新使用时请在电脑上运行启动器。";
1141
+ } catch (error) {
1142
+ button.disabled = false;
1143
+ label.textContent = "安全停止";
1144
+ if (error.code === "ACTIVE_SESSIONS") {
1145
+ const count = error.diagnostics?.count ?? 1;
1146
+ note.textContent = `仍有 ${count} 个 Agent 任务运行中。请先在对应会话点击“停止”,确认结束后再关闭 Bridge。`;
1147
+ } else {
1148
+ note.textContent = `停止失败:${error.message}`;
1149
+ }
1150
+ toast(error.message);
1151
+ }
1152
+ });
1153
+
1154
+ // The return strip is one line on a phone so the conversation starts higher; the
1155
+ // full instructions stay one tap away and the choice is remembered.
1156
+ const RETURN_DETAILS_KEY = "agentbridge.return.details";
1157
+
1158
+ function applyReturnDetails(expanded) {
1159
+ const entry = $("#return-entry");
1160
+ const toggle = $("#toggle-return-details");
1161
+ if (!entry || !toggle) return;
1162
+ entry.classList.toggle("expanded", expanded);
1163
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
1164
+ toggle.textContent = expanded ? "收起" : "详情";
1165
+ }
1166
+
1167
+ $("#toggle-return-details").addEventListener("click", () => {
1168
+ const expanded = !$("#return-entry").classList.contains("expanded");
1169
+ applyReturnDetails(expanded);
1170
+ try {
1171
+ localStorage.setItem(RETURN_DETAILS_KEY, expanded ? "1" : "0");
1172
+ } catch {
1173
+ // Storage may be unavailable in private browsing; the state simply resets.
1174
+ }
1175
+ });
1176
+ $("#copy-return-url").addEventListener("click", async () => {
1177
+ await copyText(returnUrl());
1178
+ toast("入口已复制;也可在微信右上角“…”中收藏本页。");
1179
+ });
1180
+
1181
+ $("#access-form").addEventListener("submit", async (event) => {
1182
+ event.preventDefault();
1183
+ const key = $("#access-key-input").value.trim();
1184
+ if (!key) return;
1185
+ try {
1186
+ await api("/api/access", { method: "POST", body: JSON.stringify({ key }) });
1187
+ location.reload();
1188
+ } catch (error) {
1189
+ $("#access-message").textContent = `${error.message}。请核对后重试。`;
1190
+ }
1191
+ });
1192
+
1193
+ // ---------- 抽屉交互 ----------
1194
+ // A wide window keeps the drawer as a permanent sidebar, so closing it there would
1195
+ // hide the only navigation the console has.
1196
+ function drawerIsPermanent() {
1197
+ return window.matchMedia("(min-width: 781px)").matches;
1198
+ }
1199
+
1200
+ function selectDrawerSession(sessionId) {
1201
+ if (!sessionId) return;
1202
+ model.selectedId = sessionId;
1203
+ const navigation = resolveWorkspaceNavigation({
1204
+ workspaces: model.state?.workspaces ?? [],
1205
+ sessions: model.state?.sessions ?? [],
1206
+ selectedWorkspaceId: model.selectedWorkspaceId,
1207
+ selectedSessionId: sessionId,
1208
+ });
1209
+ model.selectedWorkspaceId = navigation.workspaceId;
1210
+ model.selectedId = navigation.sessionId;
1211
+ model.sessionSignature = "";
1212
+ renderState();
1213
+ void rememberSession(sessionId).catch((error) => toast(error.message));
1214
+ setDrawerOpen(false);
1215
+ }
1216
+
1217
+ $("#drawer-toggle").addEventListener("click", () => setDrawerOpen(true));
1218
+ $("#drawer-close").addEventListener("click", () => setDrawerOpen(false));
1219
+ $("#drawer-mask").addEventListener("click", () => setDrawerOpen(false));
1220
+
1221
+ document.addEventListener("keydown", (event) => {
1222
+ if (event.key !== "Escape" || !model.drawer.open) return;
1223
+ event.preventDefault();
1224
+ setDrawerOpen(false);
1225
+ });
1226
+
1227
+ $("#drawer-query").addEventListener("input", (event) => {
1228
+ model.drawer.query = event.target.value;
1229
+ renderDrawer();
1230
+ scheduleDeepSearch(model.drawer.query);
1231
+ });
1232
+
1233
+ function scheduleDeepSearch(query) {
1234
+ if (model.deepSearchTimer) clearTimeout(model.deepSearchTimer);
1235
+ const trimmed = String(query ?? "").trim();
1236
+ if (trimmed.length < 2) {
1237
+ model.deepSearch = { query: "", loading: false, hits: [], error: "", truncated: false };
1238
+ renderDeepSearch();
1239
+ return;
1240
+ }
1241
+ model.deepSearch = { ...model.deepSearch, query: trimmed, loading: true, error: "" };
1242
+ renderDeepSearch();
1243
+ model.deepSearchTimer = setTimeout(async () => {
1244
+ try {
1245
+ const result = await api(`/api/search?q=${encodeURIComponent(trimmed)}&limit=30`);
1246
+ if (model.deepSearch.query !== trimmed) return;
1247
+ model.deepSearch = { query: trimmed, loading: false, hits: result.hits ?? [], error: "", truncated: Boolean(result.truncated) };
1248
+ } catch (error) {
1249
+ if (model.deepSearch.query !== trimmed) return;
1250
+ model.deepSearch = { query: trimmed, loading: false, hits: [], error: error.message, truncated: false };
1251
+ }
1252
+ renderDeepSearch();
1253
+ }, 260);
1254
+ }
1255
+
1256
+ function renderDeepSearch() {
1257
+ const target = $("#drawer-deep-search");
1258
+ if (!target) return;
1259
+ const state = model.deepSearch;
1260
+ if (!state.query) {
1261
+ target.hidden = true;
1262
+ target.innerHTML = "";
1263
+ return;
1264
+ }
1265
+ target.hidden = false;
1266
+ if (state.loading) {
1267
+ target.innerHTML = `<p class="drawer-deep-status">正在所有会话中搜索「${escapeHtml(state.query)}」…</p>`;
1268
+ return;
1269
+ }
1270
+ if (state.error) {
1271
+ target.innerHTML = `<p class="drawer-deep-status">搜索失败:${escapeHtml(state.error)}</p>`;
1272
+ return;
1273
+ }
1274
+ if (!state.hits.length) {
1275
+ target.innerHTML = `<p class="drawer-deep-status">所有会话里都没有提到「${escapeHtml(state.query)}」。</p>`;
1276
+ return;
1277
+ }
1278
+ const rows = state.hits.map((hit) => {
1279
+ const when = hit.at ? new Date(hit.at).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
1280
+ const role = hit.role === "user" ? "你" : hit.role === "tool" ? "工具" : "Agent";
1281
+ return `<button class="drawer-deep-hit" type="button" data-deep-session="${escapeHtml(hit.sessionId)}">
1282
+ <span class="drawer-deep-title">${escapeHtml(hit.sessionTitle ?? "未命名会话")}<em>${escapeHtml(role)} · ${escapeHtml(when)}</em></span>
1283
+ <span class="drawer-deep-snippet">${escapeHtml(hit.snippet)}</span>
1284
+ </button>`;
1285
+ }).join("");
1286
+ target.innerHTML = `<p class="drawer-deep-head">在对话内容里找到 ${state.hits.length} 处${state.truncated ? "(日志过长,只搜了最近部分)" : ""}</p>${rows}`;
1287
+ }
1288
+
1289
+ $("#drawer-deep-search")?.addEventListener("click", (event) => {
1290
+ const hit = event.target.closest("[data-deep-session]");
1291
+ if (!hit) return;
1292
+ selectDrawerSession(hit.dataset.deepSession);
1293
+ });
1294
+
1295
+ $("#drawer-tree").addEventListener("click", (event) => {
1296
+ const agent = event.target.closest("[data-agent]");
1297
+ if (agent) {
1298
+ toggleDrawerExpansion("agent", agent.dataset.agent);
1299
+ return;
1300
+ }
1301
+ const project = event.target.closest("[data-project]");
1302
+ if (project) {
1303
+ toggleDrawerExpansion("project", project.dataset.project);
1304
+ return;
1305
+ }
1306
+ const session = event.target.closest("[data-session]");
1307
+ if (session) selectDrawerSession(session.dataset.session);
1308
+ });
1309
+
1310
+ // A left swipe is the expected way to dismiss a drawer on a phone.
1311
+ (() => {
1312
+ let startX = null;
1313
+ let startY = null;
1314
+ const drawer = $("#agent-drawer");
1315
+ drawer.addEventListener("touchstart", (event) => {
1316
+ const touch = event.touches[0];
1317
+ startX = touch.clientX;
1318
+ startY = touch.clientY;
1319
+ }, { passive: true });
1320
+ drawer.addEventListener("touchend", (event) => {
1321
+ if (startX === null) return;
1322
+ const touch = event.changedTouches[0];
1323
+ const dx = touch.clientX - startX;
1324
+ const dy = Math.abs(touch.clientY - startY);
1325
+ startX = null;
1326
+ startY = null;
1327
+ if (dx < -60 && dy < 40) setDrawerOpen(false);
1328
+ }, { passive: true });
1329
+ })();
1330
+
1331
+ // "刷新状态" re-reads the catalog; it is the only footer action that can change
1332
+ // what the tree shows without leaving the drawer.
1333
+ // ---------- 会话创建与历史同步(抽屉驱动) ----------
1334
+ async function createSession({ adapterId, projectAlias, title }) {
1335
+ const button = $("#create-form").querySelector("button[type=submit]");
1336
+ const label = button.textContent;
1337
+ button.disabled = true;
1338
+ // A DSH Desktop conversation is opened inside the desktop app, so say so instead
1339
+ // of implying this process is starting an Agent.
1340
+ button.textContent = adapterId === "deepseek-harness-desktop" ? "正在桌面端创建…" : "正在启动…";
1341
+ try {
1342
+ const result = await api("/api/sessions", {
1343
+ method: "POST",
1344
+ body: JSON.stringify({
1345
+ adapterId,
1346
+ projectAlias,
1347
+ ...(title ? { title } : {}),
1348
+ commandId: commandId(),
1349
+ }),
1350
+ });
1351
+ model.selectedId = result.session.id;
1352
+ closeCreateSheet();
1353
+ setDrawerOpen(false);
1354
+ await rememberSession(result.session.id);
1355
+ await refreshState();
1356
+ toast("会话已创建");
1357
+ return true;
1358
+ } catch (error) {
1359
+ toast(error.message);
1360
+ return false;
1361
+ } finally {
1362
+ button.textContent = label;
1363
+ syncCreateAdapterNote();
1364
+ }
1365
+ }
1366
+
1367
+ // Which Agent reads which project is the user's call, not a guess: the sheet lists
1368
+ // only Agents that can actually publish a conversation list.
1369
+ async function syncHistory({ adapterId, workspaceId } = {}) {
1370
+ const adapter = (model.state?.adapters ?? []).find((entry) => entry.id === adapterId);
1371
+ const workspace = (model.state?.workspaces ?? []).find((entry) => entry.id === workspaceId && entry.enabled !== false);
1372
+ if (!adapter || !workspace) {
1373
+ toast("请选择要同步的 Agent 与项目");
1374
+ return false;
1375
+ }
1376
+ if (model.discoveryLoading) return false;
1377
+ model.discoveryLoading = true;
1378
+ renderDrawer();
1379
+ toast(`正在读取 ${adapter.label} 在「${workspace.name}」的公开会话列表…`);
1380
+ try {
1381
+ const result = await api(`/api/conversations?workspaceId=${encodeURIComponent(workspace.id)}&adapterId=${encodeURIComponent(adapter.id)}&refresh=1`);
1382
+ toast(result.discovery.created || result.discovery.updated
1383
+ ? `同步完成:新增 ${result.discovery.created} 个,更新 ${result.discovery.updated} 个会话。`
1384
+ : "已是最新,没有发现新的会话。");
1385
+ await refreshState();
1386
+ return true;
1387
+ } catch (error) {
1388
+ toast(`同步失败:${error.message}`);
1389
+ return false;
1390
+ } finally {
1391
+ model.discoveryLoading = false;
1392
+ renderDrawer();
1393
+ }
1394
+ }
1395
+
1396
+ function openSyncSheet() {
1397
+ const adapters = discoverableAdapters(model.state?.adapters ?? []);
1398
+ const projects = (model.state?.workspaces ?? []).filter((workspace) => workspace.enabled !== false);
1399
+ if (!adapters.length || !projects.length) {
1400
+ toast("当前没有可公开同步历史的 Agent");
1401
+ return;
1402
+ }
1403
+ $("#sync-adapter").innerHTML = adapters
1404
+ .map((adapter) => `<option value="${escapeHtml(adapter.id)}">${escapeHtml(adapter.label)}</option>`)
1405
+ .join("");
1406
+ $("#sync-project").innerHTML = projects
1407
+ .map((workspace) => `<option value="${escapeHtml(workspace.id)}">${escapeHtml(workspace.name)}</option>`)
1408
+ .join("");
1409
+ // Sensible defaults, still changeable: the conversation in view decides the
1410
+ // project, and the first Agent that can publish a list is the common case.
1411
+ const session = currentSession();
1412
+ const sessionWorkspace = (model.state?.workspaces ?? []).find((workspace) => workspace.alias === session?.projectAlias)
1413
+ ?? currentWorkspace();
1414
+ if (sessionWorkspace) $("#sync-project").value = sessionWorkspace.id;
1415
+ syncAdapterNote();
1416
+ const sheet = $("#sync-sheet");
1417
+ sheet.removeAttribute("inert");
1418
+ sheet.classList.add("open");
1419
+ $("#sync-mask").hidden = false;
1420
+ $("#sync-adapter").focus({ preventScroll: true });
1421
+ }
1422
+
1423
+ function closeSyncSheet() {
1424
+ const sheet = $("#sync-sheet");
1425
+ sheet.classList.remove("open");
1426
+ sheet.setAttribute("inert", "");
1427
+ $("#sync-mask").hidden = true;
1428
+ $("#drawer-discover").focus({ preventScroll: true });
1429
+ }
1430
+
1431
+ function syncAdapterNote() {
1432
+ const adapter = (model.state?.adapters ?? []).find((entry) => entry.id === $("#sync-adapter").value);
1433
+ $("#sync-adapter-note").textContent = adapter
1434
+ ? `${adapter.label}:只登记它能公开列举的历史会话,不读取私有转写文件,也不会启动对话。`
1435
+ : "";
1436
+ }
1437
+ $("#drawer-refresh").addEventListener("click", async () => {
1438
+ const button = $("#drawer-refresh");
1439
+ if (button.disabled) return;
1440
+ button.disabled = true;
1441
+ try {
1442
+ await refreshState();
1443
+ toast("状态已刷新");
1444
+ } catch (error) {
1445
+ toast(`刷新失败:${error.message}`);
1446
+ }
1447
+ button.disabled = false;
1448
+ });
1449
+
1450
+ $("#drawer-discover").addEventListener("click", () => {
1451
+ setDrawerOpen(false);
1452
+ openSyncSheet();
1453
+ });
1454
+ $("#sync-close").addEventListener("click", () => closeSyncSheet());
1455
+ $("#sync-mask").addEventListener("click", () => closeSyncSheet());
1456
+ $("#sync-adapter").addEventListener("change", () => syncAdapterNote());
1457
+ $("#sync-form").addEventListener("submit", async (event) => {
1458
+ event.preventDefault();
1459
+ const adapterId = $("#sync-adapter").value;
1460
+ const workspaceId = $("#sync-project").value;
1461
+ closeSyncSheet();
1462
+ await syncHistory({ adapterId, workspaceId });
1463
+ });
1464
+
1465
+ // ---------- 新建会话(抽屉头部 +) ----------
1466
+ // Creating a conversation starts by choosing an Agent, so the picker shows every
1467
+ // Agent with its real capability: an attach-only or missing tool stays listed and
1468
+ // disabled with the reason, instead of vanishing from the list.
1469
+ function openCreateSheet() {
1470
+ const adapters = model.state?.adapters ?? [];
1471
+ const picker = $("#create-adapter");
1472
+ picker.innerHTML = adapters.map((adapter) => {
1473
+ const creation = adapterCreationPresentation(adapter);
1474
+ return `<option value="${escapeHtml(adapter.id)}"${creation.selectable ? "" : " disabled"}>${escapeHtml(adapter.label + creation.suffix)}</option>`;
1475
+ }).join("");
1476
+ const firstUsable = adapters.find((adapter) => adapterCreationPresentation(adapter).selectable);
1477
+ if (firstUsable) picker.value = firstUsable.id;
1478
+ const projects = (model.state?.workspaces ?? []).filter((workspace) => workspace.enabled !== false);
1479
+ $("#create-project").innerHTML = projects
1480
+ .map((workspace) => `<option value="${escapeHtml(workspace.alias)}">${escapeHtml(workspace.name)}</option>`)
1481
+ .join("");
1482
+ $("#create-title-input").value = "";
1483
+ syncCreateAdapterNote();
1484
+ const sheet = $("#create-sheet");
1485
+ sheet.removeAttribute("inert");
1486
+ sheet.classList.add("open");
1487
+ $("#create-mask").hidden = false;
1488
+ $("#create-adapter").focus({ preventScroll: true });
1489
+ }
1490
+
1491
+ function closeCreateSheet() {
1492
+ const sheet = $("#create-sheet");
1493
+ sheet.classList.remove("open");
1494
+ sheet.setAttribute("inert", "");
1495
+ $("#create-mask").hidden = true;
1496
+ $("#drawer-create").focus({ preventScroll: true });
1497
+ }
1498
+
1499
+ function syncCreateAdapterNote() {
1500
+ const adapter = model.state?.adapters.find((item) => item.id === $("#create-adapter").value);
1501
+ const creation = adapter ? adapterCreationPresentation(adapter) : { enabled: false };
1502
+ const submit = $("#create-form").querySelector("button[type=submit]");
1503
+ $("#create-adapter-note").textContent = creation.blocked
1504
+ ? creation.blocked === "桌面端离线"
1505
+ ? "这个 Agent 的会话由桌面端创建:先在 DSH Desktop 的对话里运行 /phone,再回来新建。"
1506
+ : `这个 Agent 现在不能新建:${creation.blocked}。`
1507
+ : adapter?.availability?.note
1508
+ ?? (creation.enabled ? "会话会在所选项目目录中启动。" : "这个 Agent 不能从手机新建会话。");
1509
+ submit.disabled = !creation.enabled;
1510
+ submit.textContent = creation.enabled ? "创建会话" : "此 Agent 不能新建";
1511
+ }
1512
+
1513
+ $("#drawer-create").addEventListener("click", () => {
1514
+ if (!model.state) return;
1515
+ openCreateSheet();
1516
+ });
1517
+ $("#create-close").addEventListener("click", () => closeCreateSheet());
1518
+ $("#create-mask").addEventListener("click", () => closeCreateSheet());
1519
+ $("#create-adapter").addEventListener("change", () => syncCreateAdapterNote());
1520
+
1521
+ $("#create-form").addEventListener("submit", (event) => {
1522
+ event.preventDefault();
1523
+ const adapterId = $("#create-adapter").value;
1524
+ const projectAlias = $("#create-project").value;
1525
+ const title = $("#create-title-input").value.trim();
1526
+ if (!adapterId || !projectAlias) return;
1527
+ void createSession({ adapterId, projectAlias, title });
1528
+ });
1529
+
1530
+ document.addEventListener("keydown", (event) => {
1531
+ if (event.key !== "Escape" || $("#create-sheet").hidden || !$("#create-sheet").classList.contains("open")) return;
1532
+ event.preventDefault();
1533
+ closeCreateSheet();
1534
+ });
1535
+
1536
+ async function init() {
1537
+ await storeAccessKeyFromUrl();
1538
+ const pairingResult = await pairFromUrl().catch((error) => {
1539
+ if (/Access key/.test(error.message)) {
1540
+ showAccessScreen("此实例已开启公网保护。请重新扫描电脑上的二维码,或输入启动器窗口中显示的访问密钥。");
1541
+ }
1542
+ return null;
1543
+ });
1544
+ if (pairingResult === null) return;
1545
+ model.state = await ensureSession();
1546
+ const requestedSessionId = pairingResult.targetSessionId
1547
+ || new URLSearchParams(location.search).get("session")
1548
+ || model.state.device.lastSessionId;
1549
+ if (requestedSessionId && model.state.sessions.some((session) => session.id === requestedSessionId)) {
1550
+ model.selectedId = requestedSessionId;
1551
+ }
1552
+ model.lastSeq = 0;
1553
+ model.events = [];
1554
+ model.eventIds = new Set();
1555
+ indexDeltas(model.events, model.deltaEvents);
1556
+ renderedEntries = [];
1557
+ model.sessionSignature = "";
1558
+ model.workspaceSignature = "";
1559
+ await loadBootstrap();
1560
+ renderReturnEntry();
1561
+ $("#pairing-screen").hidden = true;
1562
+ $("#app").hidden = false;
1563
+ // The drawer is the navigation on every width: wide screens keep it open as a
1564
+ // sidebar (which is what the body class reserves space for), phones start with
1565
+ // it closed so the conversation keeps the whole screen.
1566
+ document.body.classList.add("has-drawer");
1567
+ setDrawerOpen(drawerIsPermanent());
1568
+ renderState();
1569
+ connectEvents();
1570
+ }
1571
+
1572
+ init().catch((error) => {
1573
+ if (/Access key/.test(error.message)) {
1574
+ showAccessScreen("此实例已开启公网保护。请重新扫描电脑上的二维码,或输入启动器窗口中显示的访问密钥。");
1575
+ return;
1576
+ }
1577
+ $("#pairing-screen").hidden = false;
1578
+ $("#pairing-message").textContent = error.message;
1579
+ $(".loader").hidden = true;
1580
+ });
1581
+
1582
+ // ---- 自动放行(限时批量批准) ----
1583
+ // The countdown is the honest part of this feature: a blanket approval must show
1584
+ // how much of its window is left, and must be one tap away from being cancelled.
1585
+
1586
+ function formatRemaining(until) {
1587
+ const seconds = Math.max(0, Math.round((until - Date.now()) / 1000));
1588
+ const minutes = Math.floor(seconds / 60);
1589
+ return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
1590
+ }
1591
+
1592
+ function renderAutoApprove(session) {
1593
+ const toggle = $("#auto-approve-toggle");
1594
+ const panel = $("#auto-approve-panel");
1595
+ const pill = $("#auto-approve-off");
1596
+ const policy = session?.autoApprove ?? null;
1597
+ const supported = Boolean(session) && (model.state?.adapters ?? [])
1598
+ .some((adapter) => adapter.id === session.adapterId && adapter.capabilities?.approve !== false);
1599
+ toggle.hidden = !supported;
1600
+ if (!supported) {
1601
+ panel.hidden = true;
1602
+ pill.hidden = true;
1603
+ if (model.autoApproveTimer) {
1604
+ clearInterval(model.autoApproveTimer);
1605
+ model.autoApproveTimer = null;
1606
+ }
1607
+ return;
1608
+ }
1609
+ if (policy) {
1610
+ panel.hidden = true;
1611
+ toggle.setAttribute("aria-expanded", "false");
1612
+ pill.hidden = false;
1613
+ const scope = policy.tools?.includes("*") ? "全部工具" : (policy.tools ?? []).join("、");
1614
+ pill.textContent = `自动放行 ${formatRemaining(policy.until)} · ${scope} ✕`;
1615
+ pill.title = "点击立即取消自动放行";
1616
+ if (!model.autoApproveTimer) {
1617
+ model.autoApproveTimer = setInterval(() => {
1618
+ const current = currentSession();
1619
+ if (!current?.autoApprove) {
1620
+ clearInterval(model.autoApproveTimer);
1621
+ model.autoApproveTimer = null;
1622
+ renderAutoApprove(currentSession());
1623
+ return;
1624
+ }
1625
+ pill.textContent = `自动放行 ${formatRemaining(current.autoApprove.until)} · ${current.autoApprove.tools?.includes("*") ? "全部工具" : current.autoApprove.tools.join("、")} ✕`;
1626
+ }, 1000);
1627
+ }
1628
+ return;
1629
+ }
1630
+ pill.hidden = true;
1631
+ if (model.autoApproveTimer) {
1632
+ clearInterval(model.autoApproveTimer);
1633
+ model.autoApproveTimer = null;
1634
+ }
1635
+ }
1636
+
1637
+ function closeAutoApprovePanel() {
1638
+ $("#auto-approve-panel").hidden = true;
1639
+ $("#auto-approve-toggle").setAttribute("aria-expanded", "false");
1640
+ renderQuickNav();
1641
+ }
1642
+
1643
+ async function submitAutoApprove(minutes) {
1644
+ if (!model.selectedId) return;
1645
+ const tools = $("#auto-approve-all").checked ? ["*"] : ["shell"];
1646
+ try {
1647
+ const result = await api(`/api/sessions/${model.selectedId}/auto-approve`, {
1648
+ method: "POST",
1649
+ body: JSON.stringify({ minutes, tools, commandId: commandId() }),
1650
+ });
1651
+ const session = currentSession();
1652
+ if (session) session.autoApprove = result.autoApprove ?? null;
1653
+ closeAutoApprovePanel();
1654
+ renderAutoApprove(session);
1655
+ renderQueuedMessages(session);
1656
+ renderUsage(session);
1657
+ toast(result.autoApprove
1658
+ ? `已放行 ${minutes} 分钟(${tools.includes("*") ? "全部工具" : tools.join("、")})`
1659
+ : "已取消自动放行");
1660
+ } catch (error) {
1661
+ toast(error.message);
1662
+ }
1663
+ }
1664
+
1665
+ $("#auto-approve-toggle").addEventListener("click", () => {
1666
+ const panel = $("#auto-approve-panel");
1667
+ const toggle = $("#auto-approve-toggle");
1668
+ const open = panel.hidden;
1669
+ panel.hidden = !open;
1670
+ toggle.setAttribute("aria-expanded", String(open));
1671
+ if (open) placePopover(panel, toggle);
1672
+ renderQuickNav();
1673
+ });
1674
+
1675
+ $("#auto-approve-arm").addEventListener("click", async () => {
1676
+ const chosen = document.querySelector('input[name="auto-approve-minutes"]:checked');
1677
+ await submitAutoApprove(Number(chosen?.value ?? 10));
1678
+ });
1679
+
1680
+ $("#auto-approve-off").addEventListener("click", async () => {
1681
+ await submitAutoApprove(0);
1682
+ });
1683
+ // ---- 附件:拍照/截图/文件直接发给 Agent ----
1684
+ // Uploads go out as raw bytes (no base64 inflation, no multipart parser on the
1685
+ // server) and are only attached to the message when the user actually sends it.
1686
+
1687
+ const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
1688
+
1689
+ function renderPendingAttachments() {
1690
+ const box = $("#composer-attachments");
1691
+ if (!box) return;
1692
+ if (!model.pendingAttachments.length) {
1693
+ box.hidden = true;
1694
+ box.replaceChildren();
1695
+ return;
1696
+ }
1697
+ box.hidden = false;
1698
+ box.replaceChildren(...model.pendingAttachments.map((attachment) => {
1699
+ const chip = document.createElement("div");
1700
+ chip.className = "attachment-chip";
1701
+ chip.dataset.attachmentId = attachment.id;
1702
+ if (attachment.isImage) {
1703
+ const image = document.createElement("img");
1704
+ image.src = attachment.previewUrl;
1705
+ image.alt = attachment.name;
1706
+ chip.append(image);
1707
+ }
1708
+ const label = document.createElement("span");
1709
+ label.className = "attachment-chip-name";
1710
+ label.textContent = `${attachment.name} · ${Math.max(1, Math.round(attachment.bytes / 1024))} KB`;
1711
+ chip.append(label);
1712
+ const remove = document.createElement("button");
1713
+ remove.type = "button";
1714
+ remove.className = "attachment-chip-remove";
1715
+ remove.setAttribute("aria-label", `移除 ${attachment.name}`);
1716
+ remove.textContent = "×";
1717
+ remove.addEventListener("click", () => {
1718
+ model.pendingAttachments = model.pendingAttachments.filter((entry) => entry.id !== attachment.id);
1719
+ renderPendingAttachments();
1720
+ });
1721
+ chip.append(remove);
1722
+ return chip;
1723
+ }));
1724
+ }
1725
+
1726
+ async function uploadAttachment(file) {
1727
+ if (!model.selectedId) return null;
1728
+ if (file.size > MAX_ATTACHMENT_BYTES) {
1729
+ toast(`${file.name} 超过 ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)} MB,没有上传`);
1730
+ return null;
1731
+ }
1732
+ const response = await fetch(`/api/sessions/${model.selectedId}/attachments`, {
1733
+ method: "POST",
1734
+ credentials: "same-origin",
1735
+ headers: {
1736
+ "content-type": file.type || "application/octet-stream",
1737
+ "x-attachment-name": encodeURIComponent(file.name || "attachment"),
1738
+ },
1739
+ body: file,
1740
+ });
1741
+ const body = response.headers.get("content-type")?.includes("application/json") ? await response.json() : null;
1742
+ if (!response.ok) throw new Error(body?.error ?? `上传失败 (${response.status})`);
1743
+ return body.attachment;
1744
+ }
1745
+
1746
+ $("#attach-button").addEventListener("click", () => $("#attach-input").click());
1747
+
1748
+ $("#attach-input").addEventListener("change", async (event) => {
1749
+ const files = [...(event.target.files ?? [])];
1750
+ event.target.value = "";
1751
+ if (!files.length) return;
1752
+ const button = $("#attach-button");
1753
+ button.disabled = true;
1754
+ try {
1755
+ for (const file of files.slice(0, 6)) {
1756
+ const attachment = await uploadAttachment(file);
1757
+ if (attachment) model.pendingAttachments.push(attachment);
1758
+ }
1759
+ renderPendingAttachments();
1760
+ } catch (error) {
1761
+ toast(error.message);
1762
+ } finally {
1763
+ button.disabled = false;
1764
+ }
1765
+ });
1766
+ // ---- 排队中的消息 ----
1767
+ // A busy agent used to answer 409. Now the server queues the message, so the phone
1768
+ // shows what is waiting and lets you take it back before it is sent.
1769
+
1770
+ function renderQueuedMessages(session) {
1771
+ const box = $("#composer-queue");
1772
+ if (!box) return;
1773
+ const queued = session?.queued ?? [];
1774
+ if (!queued.length) {
1775
+ box.hidden = true;
1776
+ box.replaceChildren();
1777
+ return;
1778
+ }
1779
+ box.hidden = false;
1780
+ box.replaceChildren(...queued.map((entry) => {
1781
+ const row = document.createElement("div");
1782
+ row.className = "queue-item";
1783
+ row.dataset.queueId = entry.commandId;
1784
+ const label = document.createElement("span");
1785
+ label.textContent = `第 ${entry.position} 位 · ${entry.text || "(附件)"}`;
1786
+ const cancel = document.createElement("button");
1787
+ cancel.type = "button";
1788
+ cancel.textContent = "撤回";
1789
+ cancel.addEventListener("click", async () => {
1790
+ if (!model.selectedId) return;
1791
+ try {
1792
+ await api(`/api/sessions/${model.selectedId}/queue/cancel`, {
1793
+ method: "POST",
1794
+ body: JSON.stringify({ commandId: entry.commandId }),
1795
+ });
1796
+ const current = currentSession();
1797
+ if (current) current.queued = current.queued.filter((item) => item.commandId !== entry.commandId);
1798
+ renderQueuedMessages(currentSession());
1799
+ toast("已撤回排队消息");
1800
+ } catch (error) {
1801
+ toast(error.message);
1802
+ }
1803
+ });
1804
+ row.append(label, cancel);
1805
+ return row;
1806
+ }));
1807
+ }
1808
+
1809
+ // ---- 语音输入 ----
1810
+ // Only shown when the engine exists: a phone without Web Speech keeps the plain
1811
+ // keyboard instead of a button that cannot work.
1812
+
1813
+ const SpeechRecognitionImpl = window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null;
1814
+ let recognition = null;
1815
+
1816
+ function stopVoiceInput() {
1817
+ recognition?.stop();
1818
+ recognition = null;
1819
+ $("#voice-button").setAttribute("aria-pressed", "false");
1820
+ }
1821
+
1822
+ if (SpeechRecognitionImpl) {
1823
+ $("#voice-button").hidden = false;
1824
+ $("#voice-button").addEventListener("click", () => {
1825
+ if (recognition) {
1826
+ stopVoiceInput();
1827
+ return;
1828
+ }
1829
+ recognition = new SpeechRecognitionImpl();
1830
+ recognition.lang = "zh-CN";
1831
+ recognition.interimResults = true;
1832
+ recognition.continuous = false;
1833
+ $("#voice-button").setAttribute("aria-pressed", "true");
1834
+ const input = $("#message-input");
1835
+ const before = input.value;
1836
+ recognition.onresult = (event) => {
1837
+ let transcript = "";
1838
+ for (let index = event.resultIndex; index < event.results.length; index += 1) {
1839
+ transcript += event.results[index][0].transcript;
1840
+ }
1841
+ input.value = `${before}${before && !before.endsWith(" ") ? " " : ""}${transcript}`.slice(0, 20000);
1842
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1843
+ };
1844
+ recognition.onerror = (event) => {
1845
+ stopVoiceInput();
1846
+ if (event.error !== "aborted" && event.error !== "no-speech") toast(`语音输入失败:${event.error}`);
1847
+ };
1848
+ recognition.onend = () => {
1849
+ recognition = null;
1850
+ $("#voice-button").setAttribute("aria-pressed", "false");
1851
+ };
1852
+ try {
1853
+ recognition.start();
1854
+ } catch (error) {
1855
+ stopVoiceInput();
1856
+ toast(`语音输入无法启动:${error.message}`);
1857
+ }
1858
+ });
1859
+ }
1860
+ // ---- 用量:轮次、工具调用与(有则显示)token ----
1861
+ // Turns and tool calls are always known; tokens appear only when the agent reports
1862
+ // them, so the line never claims a number it does not have.
1863
+
1864
+ function formatTokens(value) {
1865
+ if (!Number.isFinite(value) || value <= 0) return "";
1866
+ if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}k`;
1867
+ return String(value);
1868
+ }
1869
+
1870
+ function renderUsage(session) {
1871
+ const line = $("#usage-line");
1872
+ if (!line) return;
1873
+ const usage = session?.usage;
1874
+ if (!usage || (!usage.turns && !usage.toolCalls)) {
1875
+ line.hidden = true;
1876
+ line.textContent = "";
1877
+ return;
1878
+ }
1879
+ const parts = [`${usage.turns ?? 0} 轮对话`];
1880
+ if (usage.toolCalls) parts.push(`${usage.toolCalls} 次工具`);
1881
+ const input = formatTokens(usage.inputTokens);
1882
+ const output = formatTokens(usage.outputTokens);
1883
+ if (input || output) parts.push(`${input || "0"} → ${output || "0"} tokens`);
1884
+ line.textContent = parts.join(" · ");
1885
+ line.hidden = false;
1886
+ }
1887
+
1888
+ function renderIntegrationStatus() {
1889
+ const row = $("#integration-row");
1890
+ const note = $("#integration-note");
1891
+ if (!row || !note) return;
1892
+ const status = model.state?.desktopIntegration;
1893
+ // "No channel at all" used to be silent, which is exactly the state a bridge restart
1894
+ // leaves behind: every DSH Desktop conversation in the drawer, none of them drivable.
1895
+ const hasDesktopSessions = (model.state?.sessions ?? []).some((item) => item.adapterId === "deepseek-harness-desktop");
1896
+ if (!status?.paired) {
1897
+ row.hidden = !hasDesktopSessions;
1898
+ note.textContent = hasDesktopSessions
1899
+ ? "桌面端还没连上:在电脑上的 DSH Desktop 会话里执行 /phone;桌面端在跑新版集成时会自己接回。"
1900
+ : "";
1901
+ return;
1902
+ }
1903
+ row.hidden = false;
1904
+ note.textContent = status.stale
1905
+ ? `桌面端集成是旧版(v${status.version},需要 v${status.required}):运行 npm run install:dsh-phone 并重启 DSH Desktop,否则新功能不会出现。`
1906
+ : `已连接,版本 v${status.version}。`;
1907
+ }
1908
+ // ---- 手机端的通知开关 ----
1909
+ // Muting is per bridge and persisted in .agentbridge/notify.json, so the switch keeps
1910
+ // its position across restarts and does not depend on how the bridge was launched.
1911
+
1912
+ async function setNotificationsMuted(muted) {
1913
+ const result = await api("/api/notifications", { method: "POST", body: JSON.stringify({ muted }) });
1914
+ if (model.state) model.state.notifications = result.notifications;
1915
+ renderDrawerNotifications();
1916
+ renderNotifications();
1917
+ toast(muted ? "已静音:不再推送通知" : "已开启:Agent 需要你时会推送");
1918
+ }
1919
+
1920
+ function renderDrawerNotifications() {
1921
+ const row = document.querySelector(".drawer-card");
1922
+ const toggle = $("#drawer-notify-toggle");
1923
+ const note = $("#drawer-notify-note");
1924
+ if (!row || !toggle || !note) return;
1925
+ const notifications = model.state?.notifications;
1926
+ if (!notifications?.enabled) {
1927
+ // Nothing configured: say so once, without a switch that would do nothing.
1928
+ row.hidden = false;
1929
+ note.textContent = "未配置:在 .agentbridge/notify.json 里填写 webhook 后即可开启";
1930
+ toggle.hidden = true;
1931
+ return;
1932
+ }
1933
+ row.hidden = false;
1934
+ toggle.hidden = false;
1935
+ const channel = notifications.channel ? ` · ${notifications.channel}` : "";
1936
+ note.textContent = notifications.muted
1937
+ ? `静音中(${notifications.format}${channel})`
1938
+ : `推送中(${notifications.format}${channel})`;
1939
+ toggle.textContent = notifications.muted ? "开启" : "静音";
1940
+ toggle.setAttribute("aria-pressed", String(Boolean(notifications.muted)));
1941
+ }
1942
+
1943
+ $("#drawer-notify-toggle")?.addEventListener("click", async () => {
1944
+ const muted = Boolean(model.state?.notifications?.muted);
1945
+ const button = $("#drawer-notify-toggle");
1946
+ button.disabled = true;
1947
+ try {
1948
+ await setNotificationsMuted(!muted);
1949
+ } catch (error) {
1950
+ toast(error.message);
1951
+ } finally {
1952
+ button.disabled = false;
1953
+ }
1954
+ });
1955
+ // ---- 玻璃选择器:原生 select 仍是状态来源,弹层与全站弹层共用一套配方 ----
1956
+ // Why a custom panel at all: a native <select> popup is drawn by the OS, so it can
1957
+ // never match the glass language. The value still lives in the real <select>, which is
1958
+ // what renderModelControl() and the change handler already read.
1959
+
1960
+ const pickers = {
1961
+ model: { trigger: "#model-trigger", panel: "#model-panel", options: "#model-options", select: "#model-select", value: "#model-trigger-value", search: "#model-search" },
1962
+ reasoning: { trigger: "#reasoning-trigger", panel: "#reasoning-panel", options: "#reasoning-options", select: "#reasoning-select", value: "#reasoning-trigger-value", search: null },
1963
+ };
1964
+
1965
+ function closePickers(except) {
1966
+ for (const [name, config] of Object.entries(pickers)) {
1967
+ if (name === except) continue;
1968
+ const panel = $(config.panel);
1969
+ const trigger = $(config.trigger);
1970
+ if (panel) panel.hidden = true;
1971
+ if (trigger) trigger.setAttribute("aria-expanded", "false");
1972
+ }
1973
+ renderQuickNav();
1974
+ }
1975
+
1976
+ function renderPicker(name) {
1977
+ const config = pickers[name];
1978
+ const select = $(config.select);
1979
+ const trigger = $(config.trigger);
1980
+ const value = $(config.value);
1981
+ const options = $(config.options);
1982
+ if (!select || !trigger || !value || !options) return;
1983
+
1984
+ const entries = [...select.options].map((option) => ({ value: option.value, label: option.textContent.trim() }));
1985
+ const current = select.selectedOptions[0];
1986
+ value.textContent = current?.textContent.trim() || "—";
1987
+ trigger.disabled = select.disabled || entries.length === 0;
1988
+
1989
+ if (config.search) {
1990
+ const search = $(config.search);
1991
+ // A 240-model list is unusable without a filter; hide it for short lists.
1992
+ search.hidden = entries.length < 12;
1993
+ if (!search.hidden && !search.dataset.wired) {
1994
+ search.dataset.wired = "1";
1995
+ search.addEventListener("input", () => renderPickerOptions(name));
1996
+ }
1997
+ }
1998
+ renderPickerOptions(name);
1999
+ }
2000
+
2001
+ function renderPickerOptions(name) {
2002
+ const config = pickers[name];
2003
+ const select = $(config.select);
2004
+ const options = $(config.options);
2005
+ if (!select || !options) return;
2006
+ const query = (config.search ? ($(config.search)?.value ?? "") : "").trim().toLowerCase();
2007
+ const rows = [...select.options]
2008
+ .map((option) => ({ value: option.value, label: option.textContent.trim(), selected: option.selected }))
2009
+ .filter((entry) => !query || entry.label.toLowerCase().includes(query));
2010
+ if (!rows.length) {
2011
+ options.innerHTML = `<p class="picker-empty">没有匹配的选项</p>`;
2012
+ return;
2013
+ }
2014
+ options.innerHTML = rows.map((entry) => `<button class="picker-option" type="button" role="option" aria-selected="${entry.selected}" data-value="${escapeHtml(entry.value)}">${escapeHtml(entry.label)}</button>`).join("");
2015
+ }
2016
+
2017
+ function togglePicker(name) {
2018
+ const config = pickers[name];
2019
+ const panel = $(config.panel);
2020
+ const trigger = $(config.trigger);
2021
+ if (!panel || !trigger || trigger.disabled) return;
2022
+ const open = panel.hidden;
2023
+ closePickers(open ? name : null);
2024
+ panel.hidden = !open;
2025
+ trigger.setAttribute("aria-expanded", String(open));
2026
+ if (open) {
2027
+ renderPicker(name);
2028
+ placePopover(panel, trigger);
2029
+ if (config.search && !$(config.search).hidden) $(config.search).focus({ preventScroll: true });
2030
+ else $(config.options).querySelector(".picker-option[aria-selected='true']")?.focus({ preventScroll: true });
2031
+ }
2032
+ renderQuickNav();
2033
+ }
2034
+
2035
+ for (const [name, config] of Object.entries(pickers)) {
2036
+ $(config.trigger)?.addEventListener("click", (event) => {
2037
+ event.stopPropagation();
2038
+ togglePicker(name);
2039
+ });
2040
+ $(config.options)?.addEventListener("click", (event) => {
2041
+ const option = event.target.closest(".picker-option");
2042
+ if (!option) return;
2043
+ const select = $(config.select);
2044
+ select.value = option.dataset.value;
2045
+ // The change handler is what enables 切换模型; dispatch it exactly as a native
2046
+ // selection would, so the rest of the app needs no knowledge of this panel.
2047
+ select.dispatchEvent(new Event("change", { bubbles: true }));
2048
+ renderPicker(name);
2049
+ closePickers(null);
2050
+ $(config.trigger).focus({ preventScroll: true });
2051
+ });
2052
+ }
2053
+
2054
+ document.addEventListener("click", (event) => {
2055
+ if (event.target.closest(".picker")) return;
2056
+ closePickers(null);
2057
+ });
2058
+
2059
+ document.addEventListener("keydown", (event) => {
2060
+ if (event.key !== "Escape") return;
2061
+ const open = Object.entries(pickers).find(([, config]) => !$(config.panel)?.hidden);
2062
+ if (!open) return;
2063
+ event.preventDefault();
2064
+ closePickers(null);
2065
+ $(open[1].trigger)?.focus({ preventScroll: true });
2066
+ });
2067
+ // 自动放行面板也要"点外面/Esc 关闭",与选择器弹层一致
2068
+ (function wireAutoApproveDismissal() {
2069
+ const panel = $("#auto-approve-panel");
2070
+ const toggle = $("#auto-approve-toggle");
2071
+ if (!panel || !toggle) return;
2072
+ document.addEventListener("click", (event) => {
2073
+ if (panel.hidden) return;
2074
+ if (event.target.closest("#auto-approve-panel") || event.target.closest("#auto-approve-toggle")) return;
2075
+ closeAutoApprovePanel();
2076
+ });
2077
+ document.addEventListener("keydown", (event) => {
2078
+ if (event.key !== "Escape" || panel.hidden) return;
2079
+ event.preventDefault();
2080
+ closeAutoApprovePanel();
2081
+ toggle.focus({ preventScroll: true });
2082
+ });
2083
+ })();
2084
+ // ---- 弹层定位:永远落在视口内 ----
2085
+ // `position: fixed` + 打开后按视口算坐标。触发按钮可以只有 110px 宽,面板却要 320px;
2086
+ // 右对齐会向左溢出,左对齐会向右溢出,所以两端都夹住,下方放不下时翻到上方。
2087
+
2088
+ const POPOVER_MARGIN = 8;
2089
+ const POPOVER_GAP = 6;
2090
+
2091
+ function placePopover(panel, trigger) {
2092
+ if (!panel || !trigger) return;
2093
+ const before = trigger.getBoundingClientRect();
2094
+ if (before.top < 0 || before.bottom > window.innerHeight) {
2095
+ trigger.scrollIntoView({ block: "center", inline: "nearest" });
2096
+ }
2097
+ const rect = trigger.getBoundingClientRect();
2098
+ // Measure at a safe origin first: offsetWidth/Height need the panel to be laid out.
2099
+ panel.style.visibility = "hidden";
2100
+ panel.style.left = "0px";
2101
+ panel.style.top = "0px";
2102
+ const width = panel.offsetWidth;
2103
+ const height = panel.offsetHeight;
2104
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
2105
+ const viewportHeight = window.innerHeight;
2106
+
2107
+ // Right-aligned to the trigger by default, then clamped to the viewport.
2108
+ const maxLeft = Math.max(POPOVER_MARGIN, viewportWidth - width - POPOVER_MARGIN);
2109
+ let left = rect.right - width;
2110
+ left = Math.min(Math.max(left, POPOVER_MARGIN), maxLeft);
2111
+
2112
+ let top = rect.bottom + POPOVER_GAP;
2113
+ if (top + height > viewportHeight - POPOVER_MARGIN) {
2114
+ const above = rect.top - height - POPOVER_GAP;
2115
+ top = above >= POPOVER_MARGIN ? above : rect.top;
2116
+ }
2117
+ // Clamp both axes unconditionally: if the trigger itself is scrolled out of view the
2118
+ // panel still has to land inside the phone frame rather than off the page.
2119
+ const maxTop = Math.max(POPOVER_MARGIN, viewportHeight - height - POPOVER_MARGIN);
2120
+ top = Math.min(Math.max(top, POPOVER_MARGIN), maxTop);
2121
+
2122
+ panel.style.left = `${Math.round(left)}px`;
2123
+ panel.style.top = `${Math.round(top)}px`;
2124
+ panel.style.visibility = "visible";
2125
+ }
2126
+
2127
+ /** Reposition whatever is open (rotation, keyboard, scrolling). */
2128
+ function repositionOpenPopovers() {
2129
+ const auto = $("#auto-approve-panel");
2130
+ if (auto && !auto.hidden) placePopover(auto, $("#auto-approve-toggle"));
2131
+ for (const config of Object.values(pickers)) {
2132
+ const panel = $(config.panel);
2133
+ if (panel && !panel.hidden) placePopover(panel, $(config.trigger));
2134
+ }
2135
+ }
2136
+
2137
+ window.addEventListener("resize", repositionOpenPopovers);
2138
+ window.addEventListener("orientationchange", repositionOpenPopovers);
2139
+ // Capture phase: the transcript scrolls inside a container, and the panel should follow
2140
+ // its trigger instead of drifting away from it.
2141
+ window.addEventListener("scroll", repositionOpenPopovers, { passive: true, capture: true });
2142
+
2143
+ // ---- 快捷导航(置顶 ⇧ / 置底 ⇩):悬浮在视口右侧,始终可点 ----
2144
+ // 谁在滚要分情况:宽屏上 #event-log 自己有 max-height,是日志在滚;手机上
2145
+ // 那个媒体查询把 .event-log 写成 max-height:none,于是整页在滚、日志一步不动。
2146
+ // 所以两端都滚一遍(不能滚的那个是 no-op),判定端点时也同时看两者 —— 只看
2147
+ // #event-log 会让手机端永远"既在顶又在底",按钮永远处于灰掉的状态。
2148
+
2149
+ function pageScroller() {
2150
+ return document.scrollingElement ?? document.documentElement;
2151
+ }
2152
+
2153
+ function scrollToTop() {
2154
+ const behavior = "smooth";
2155
+ $("#event-log")?.scrollTo?.({ top: 0, behavior });
2156
+ window.scrollTo({ top: 0, behavior });
2157
+ }
2158
+
2159
+ function scrollToBottom() {
2160
+ const behavior = "smooth";
2161
+ const log = $("#event-log");
2162
+ if (log) log.scrollTo?.({ top: log.scrollHeight, behavior });
2163
+ window.scrollTo({ top: pageScroller().scrollHeight, behavior });
2164
+ }
2165
+
2166
+ // 8px 容差:亚像素布局与平滑滚动收尾都会留下一点余量。
2167
+ function transcriptAtTop() {
2168
+ const log = $("#event-log");
2169
+ const logAtTop = !log || log.scrollHeight - log.clientHeight <= 8 || log.scrollTop <= 8;
2170
+ return logAtTop && pageScroller().scrollTop <= 8;
2171
+ }
2172
+
2173
+ function transcriptAtBottom() {
2174
+ const log = $("#event-log");
2175
+ const logAtBottom = !log || log.scrollHeight - log.clientHeight <= 8
2176
+ || log.scrollHeight - log.scrollTop - log.clientHeight < 8;
2177
+ const root = pageScroller();
2178
+ return logAtBottom && root.scrollHeight - root.scrollTop - root.clientHeight < 8;
2179
+ }
2180
+
2181
+ // 到端点时只做视觉提示,绝不拦截点击:用户要求的是"始终可点",而且
2182
+ // 内容随时会因为新消息变长,把按钮变成死键没有意义。
2183
+ // 弹出面板打开时把它收起来:手机上面板几乎占满宽度,悬浮键夹在旁边只是干扰。
2184
+ function anyPopoverOpen() {
2185
+ return [...document.querySelectorAll(".glass-popover")].some((node) => !node.hidden);
2186
+ }
2187
+
2188
+ function renderQuickNav() {
2189
+ const nav = $("#quick-nav");
2190
+ if (!nav) return;
2191
+ const view = $("#session-view");
2192
+ nav.hidden = view?.hidden !== false || anyPopoverOpen();
2193
+ if (nav.hidden) return;
2194
+ $("#quick-nav-top")?.classList.toggle("is-idle", transcriptAtTop());
2195
+ $("#quick-nav-bottom")?.classList.toggle("is-idle", transcriptAtBottom());
2196
+ }
2197
+
2198
+ (function wireQuickNav() {
2199
+ const nav = $("#quick-nav");
2200
+ const topBtn = $("#quick-nav-top");
2201
+ const bottomBtn = $("#quick-nav-bottom");
2202
+ if (!nav || !topBtn || !bottomBtn) return;
2203
+
2204
+ topBtn.addEventListener("click", scrollToTop);
2205
+ bottomBtn.addEventListener("click", scrollToBottom);
2206
+
2207
+ // 手机上是页面在滚,所以必须监听 window;宽屏上是日志自己滚,两个都挂。
2208
+ window.addEventListener("scroll", renderQuickNav, { passive: true, capture: true });
2209
+ $("#event-log")?.addEventListener("scroll", renderQuickNav, { passive: true });
2210
+ window.addEventListener("resize", renderQuickNav);
2211
+ })();