codex-weixin 0.3.8 → 0.3.10

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/dist/web/app.js CHANGED
@@ -1,1452 +1,1452 @@
1
- const state = {
2
- version: "",
3
- requestToken: "",
4
- accounts: [],
5
- sessions: [],
6
- config: null,
7
- codex: null,
8
- codexRuntime: null,
9
- codexModels: [],
10
- loginPoll: null,
11
- selectedSessionKey: "",
12
- sessionMessages: [],
13
- loadedSessionKey: "",
14
- loadingMessages: false,
15
- sendingMessage: false,
16
- savingSessionRuntime: false,
17
- updateInfo: null,
18
- updateChecking: false,
19
- updateInstalling: false,
20
- selectedAccountId: "",
21
- chatFiles: []
22
- };
23
-
24
- const MAX_CHAT_FILES = 10;
25
- const MAX_CHAT_FILE_BYTES = 100 * 1024 * 1024;
26
- const DISMISSED_UPDATE_KEY = "codex-weixin.dismissed-update";
27
- const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
28
- const UPDATE_RECONNECT_TIMEOUT_MS = 90 * 1000;
29
- let streamingRenderFrame = 0;
30
-
31
- const els = {};
32
-
33
- document.addEventListener("DOMContentLoaded", () => {
34
- Object.assign(els, {
35
- accountsList: document.querySelector("#accountsList"),
36
- productVersion: document.querySelector("#productVersion"),
37
- sessionsList: document.querySelector("#sessionsList"),
38
- sessionAccountTabs: document.querySelector("#sessionAccountTabs"),
39
- sessionListCount: document.querySelector("#sessionListCount"),
40
- chatTitle: document.querySelector("#chatTitle"),
41
- chatContext: document.querySelector("#chatContext"),
42
- chatMessages: document.querySelector("#chatMessages"),
43
- chatComposer: document.querySelector("#chatComposer"),
44
- chatInput: document.querySelector("#chatInput"),
45
- chatSendButton: document.querySelector("#chatSendButton"),
46
- chatAttachButton: document.querySelector("#chatAttachButton"),
47
- chatFileInput: document.querySelector("#chatFileInput"),
48
- composerFiles: document.querySelector("#composerFiles"),
49
- refreshMessagesButton: document.querySelector("#refreshMessagesButton"),
50
- sessionRuntimeToolbar: document.querySelector("#sessionRuntimeToolbar"),
51
- sessionModelInput: document.querySelector("#sessionModelInput"),
52
- sessionEffortInput: document.querySelector("#sessionEffortInput"),
53
- sessionStreamInput: document.querySelector("#sessionStreamInput"),
54
- runningAccountMetric: document.querySelector("#runningAccountMetric"),
55
- sessionMetric: document.querySelector("#sessionMetric"),
56
- workspaceMetric: document.querySelector("#workspaceMetric"),
57
- qrDialog: document.querySelector("#qrDialog"),
58
- qrFrame: document.querySelector("#qrFrame"),
59
- qrStatus: document.querySelector("#qrStatus"),
60
- updateDialog: document.querySelector("#updateDialog"),
61
- updateCurrentVersion: document.querySelector("#updateCurrentVersion"),
62
- updateLatestVersion: document.querySelector("#updateLatestVersion"),
63
- updateProgress: document.querySelector("#updateProgress"),
64
- updateProgressTitle: document.querySelector("#updateProgressTitle"),
65
- updateProgressDetail: document.querySelector("#updateProgressDetail"),
66
- updateLaterButton: document.querySelector("#updateLaterButton"),
67
- updateNowButton: document.querySelector("#updateNowButton"),
68
- updateCheckButton: document.querySelector("#updateCheckButton"),
69
- settingsVersionValue: document.querySelector("#settingsVersionValue"),
70
- accountDialog: document.querySelector("#accountDialog"),
71
- removeAccountDialog: document.querySelector("#removeAccountDialog"),
72
- sessionDialog: document.querySelector("#sessionDialog")
73
- });
74
- bindEvents();
75
- void bootstrap();
76
- });
77
-
78
- function bindEvents() {
79
- document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
80
- document.querySelectorAll("[data-close-dialog]").forEach((button) => button.addEventListener("click", () => closeDialog(button.dataset.closeDialog)));
81
- document.querySelector("#addAccountButton").addEventListener("click", () => void beginLogin());
82
- document.querySelector("#refreshQrButton").addEventListener("click", () => void beginLogin());
83
- document.querySelector("#refreshAccountsButton").addEventListener("click", () => void refreshData(true));
84
- document.querySelector("#newSessionButton").addEventListener("click", openNewSessionDialog);
85
- document.querySelector("#settingsForm").addEventListener("submit", (event) => void saveSettings(event));
86
- document.querySelector("#modelInput").addEventListener("change", () => renderEffortOptions(""));
87
- els.sessionModelInput.addEventListener("change", () => void handleSessionModelChange());
88
- els.sessionEffortInput.addEventListener("change", () => void saveSessionRuntimeSettings());
89
- els.sessionStreamInput.addEventListener("change", () => void saveSessionRuntimeSettings());
90
- document.querySelector("#accountForm").addEventListener("submit", (event) => void saveAccountRemark(event));
91
- document.querySelector("#removeAccountForm").addEventListener("submit", (event) => void removeAccount(event));
92
- document.querySelector("#sessionForm").addEventListener("submit", (event) => void saveSession(event));
93
- document.querySelector("#sessionSenderInput").addEventListener("change", updateNewSessionDefaultTitle);
94
- els.chatComposer.addEventListener("submit", (event) => void sendSessionMessage(event));
95
- els.chatInput.addEventListener("input", updateComposerState);
96
- els.chatInput.addEventListener("keydown", handleChatInputKeydown);
97
- els.chatAttachButton.addEventListener("click", () => els.chatFileInput.click());
98
- els.chatFileInput.addEventListener("change", handleChatFileSelection);
99
- els.composerFiles.addEventListener("click", handleComposerFileAction);
100
- els.refreshMessagesButton.addEventListener("click", () => void loadSelectedSessionMessages());
101
- els.accountsList.addEventListener("click", (event) => void handleAccountAction(event));
102
- els.sessionsList.addEventListener("click", (event) => void handleSessionAction(event));
103
- els.sessionAccountTabs.addEventListener("click", handleSessionAccountTab);
104
- els.qrDialog.addEventListener("close", stopLoginPoll);
105
- els.updateLaterButton.addEventListener("click", dismissUpdate);
106
- els.updateNowButton.addEventListener("click", () => void installUpdate());
107
- els.updateCheckButton.addEventListener("click", () => void checkForUpdateNow());
108
- els.updateDialog.addEventListener("cancel", (event) => {
109
- event.preventDefault();
110
- if (!state.updateInstalling) dismissUpdate();
111
- });
112
- window.addEventListener("hashchange", () => showView(location.hash.slice(1) || "accounts", false));
113
- }
114
-
115
- async function bootstrap() {
116
- try {
117
- const data = await api("/api/bootstrap", { token: false });
118
- state.requestToken = data.requestToken;
119
- state.version = data.version || "";
120
- state.accounts = data.accounts;
121
- state.sessions = data.sessions;
122
- state.config = data.config;
123
- state.codex = data.codex;
124
- state.codexRuntime = data.codexRuntime;
125
- state.codexModels = data.codexModels || [];
126
- renderAll();
127
- showView(location.hash.slice(1) || "accounts", false);
128
- window.setInterval(() => void refreshData(false), 5000);
129
- void checkForUpdate();
130
- window.setInterval(() => void checkForUpdate(), UPDATE_CHECK_INTERVAL_MS);
131
- } catch (error) {
132
- toast(error.message, true);
133
- els.accountsList.innerHTML = emptyState("server-off", "无法连接本机服务", "请重新启动 codex-weixin");
134
- }
135
- }
136
-
137
- async function checkForUpdate() {
138
- if (state.updateInstalling || state.updateChecking) return;
139
- try {
140
- const info = await api("/api/update", { token: false });
141
- if (!info.updateAvailable || !info.latestVersion || dismissedUpdateVersion() === info.latestVersion) {
142
- return;
143
- }
144
- showAvailableUpdate(info);
145
- } catch {
146
- // Update checks are best-effort and must not interrupt the local management page.
147
- }
148
- }
149
-
150
- async function checkForUpdateNow() {
151
- if (state.updateChecking || state.updateInstalling) return;
152
- const label = els.updateCheckButton.querySelector("span");
153
- state.updateChecking = true;
154
- els.updateCheckButton.disabled = true;
155
- els.updateCheckButton.classList.add("is-loading");
156
- label.textContent = "检查中";
157
- try {
158
- const info = await api("/api/update?force=1");
159
- if (info.error) throw new Error(info.error);
160
- if (info.updateAvailable && info.latestVersion) {
161
- showAvailableUpdate(info);
162
- return;
163
- }
164
- const current = String(info.currentVersion || state.version || "").replace(/^v/i, "");
165
- toast(current ? `已是最新版本 v${current}` : "已是最新版本");
166
- } catch (error) {
167
- toast(error.message || "无法检查新版本", true);
168
- } finally {
169
- state.updateChecking = false;
170
- els.updateCheckButton.disabled = false;
171
- els.updateCheckButton.classList.remove("is-loading");
172
- label.textContent = "检查更新";
173
- drawIcons();
174
- }
175
- }
176
-
177
- function showAvailableUpdate(info) {
178
- state.updateInfo = info;
179
- els.updateCurrentVersion.textContent = `v${String(info.currentVersion).replace(/^v/i, "")}`;
180
- els.updateLatestVersion.textContent = `v${String(info.latestVersion).replace(/^v/i, "")}`;
181
- resetUpdateDialog();
182
- if (!els.updateDialog.open) els.updateDialog.showModal();
183
- drawIcons();
184
- }
185
-
186
- function dismissUpdate() {
187
- if (state.updateInstalling) return;
188
- const version = state.updateInfo?.latestVersion;
189
- if (version) {
190
- try {
191
- localStorage.setItem(DISMISSED_UPDATE_KEY, version);
192
- } catch {
193
- // Dismissing still works when browser storage is unavailable.
194
- }
195
- }
196
- state.updateInfo = null;
197
- if (els.updateDialog.open) els.updateDialog.close();
198
- }
199
-
200
- async function installUpdate() {
201
- if (state.updateInstalling || !state.updateInfo?.latestVersion) return;
202
- const previousToken = state.requestToken;
203
- state.updateInstalling = true;
204
- els.updateLaterButton.disabled = true;
205
- els.updateNowButton.disabled = true;
206
- els.updateNowButton.querySelector("span").textContent = "更新中";
207
- setUpdateProgress(
208
- "正在安装更新",
209
- `正在连接${updateRegistryName(state.updateInfo.registry)},微信服务将继续运行`
210
- );
211
- try {
212
- const result = await api("/api/update", { method: "POST" });
213
- const targetVersion = result.version;
214
- state.updateInfo = { ...state.updateInfo, latestVersion: targetVersion, registry: result.registry };
215
- els.updateLatestVersion.textContent = `v${String(targetVersion).replace(/^v/i, "")}`;
216
- if (!result.restarting) {
217
- throw new Error("更新已安装,但自动重启未启动,请手动重启 codex-weixin");
218
- }
219
- setUpdateProgress(
220
- "正在重启服务",
221
- `已通过${updateRegistryName(result.registry)}完成安装,正在恢复微信连接`
222
- );
223
- await waitForUpdatedService(targetVersion, previousToken);
224
- setUpdateProgress("更新完成", "新版本已启动,正在刷新页面");
225
- window.location.reload();
226
- } catch (error) {
227
- state.updateInstalling = false;
228
- els.updateLaterButton.disabled = false;
229
- els.updateNowButton.disabled = false;
230
- els.updateNowButton.querySelector("span").textContent = "重试更新";
231
- setUpdateProgress("更新未完成", error.message || "请稍后重试", true);
232
- }
233
- }
234
-
235
- async function waitForUpdatedService(targetVersion, previousToken) {
236
- const deadline = Date.now() + UPDATE_RECONNECT_TIMEOUT_MS;
237
- while (Date.now() < deadline) {
238
- try {
239
- const response = await fetch("/api/bootstrap", { cache: "no-store" });
240
- const data = await response.json().catch(() => ({}));
241
- if (
242
- response.ok
243
- && data.version === targetVersion
244
- && data.requestToken
245
- && data.requestToken !== previousToken
246
- ) {
247
- state.requestToken = data.requestToken;
248
- state.version = data.version;
249
- return;
250
- }
251
- } catch {
252
- // The service is expected to be briefly unavailable while it restarts.
253
- }
254
- await delay(900);
255
- }
256
- throw new Error("新版本已安装,但服务未能自动恢复,请手动重启 codex-weixin");
257
- }
258
-
259
- function resetUpdateDialog() {
260
- state.updateInstalling = false;
261
- els.updateProgress.hidden = true;
262
- els.updateProgress.classList.remove("is-error");
263
- els.updateLaterButton.disabled = false;
264
- els.updateNowButton.disabled = false;
265
- els.updateNowButton.querySelector("span").textContent = "立即更新";
266
- }
267
-
268
- function setUpdateProgress(title, detail, error = false) {
269
- els.updateProgress.hidden = false;
270
- els.updateProgress.classList.toggle("is-error", error);
271
- els.updateProgressTitle.textContent = title;
272
- els.updateProgressDetail.textContent = detail;
273
- }
274
-
275
- function updateRegistryName(registry) {
276
- return registry === "npmmirror" ? "国内镜像" : "npm 官方源";
277
- }
278
-
279
- function dismissedUpdateVersion() {
280
- try {
281
- return localStorage.getItem(DISMISSED_UPDATE_KEY) || "";
282
- } catch {
283
- return "";
284
- }
285
- }
286
-
287
- function delay(ms) {
288
- return new Promise((resolve) => window.setTimeout(resolve, ms));
289
- }
290
-
291
- async function refreshData(notify) {
292
- try {
293
- const previousSession = selectedSession();
294
- const [accounts, sessions] = await Promise.all([api("/api/accounts"), api("/api/sessions")]);
295
- state.accounts = accounts.accounts;
296
- state.sessions = sessions.sessions;
297
- renderMetrics();
298
- renderAccounts();
299
- renderSessions();
300
- drawIcons();
301
- const currentSession = selectedSession();
302
- if (
303
- previousSession
304
- && currentSession
305
- && sessionKey(previousSession) === sessionKey(currentSession)
306
- && previousSession.updatedAt !== currentSession.updatedAt
307
- && state.loadedSessionKey === state.selectedSessionKey
308
- && !state.sendingMessage
309
- ) {
310
- void loadSelectedSessionMessages();
311
- } else if (previousSession?.responding !== currentSession?.responding) {
312
- window.requestAnimationFrame(scrollChatToEnd);
313
- }
314
- if (notify) toast("状态已刷新");
315
- } catch (error) {
316
- if (notify) toast(error.message, true);
317
- }
318
- }
319
-
320
- function renderAll() {
321
- renderProductVersion();
322
- renderMetrics();
323
- renderAccounts();
324
- renderSessions();
325
- renderSettings();
326
- drawIcons();
327
- }
328
-
329
- function renderProductVersion() {
330
- const version = state.version.trim();
331
- els.productVersion.hidden = !version;
332
- els.productVersion.textContent = version ? `v${version.replace(/^v/i, "")}` : "";
333
- els.settingsVersionValue.textContent = version ? `v${version.replace(/^v/i, "")}` : "--";
334
- }
335
-
336
- function renderMetrics() {
337
- els.runningAccountMetric.textContent = String(state.accounts.filter((account) => account.status === "running").length);
338
- els.sessionMetric.textContent = String(state.sessions.length);
339
- els.workspaceMetric.textContent = state.config?.defaultCwd || "--";
340
- els.workspaceMetric.title = state.config?.defaultCwd || "";
341
- const serviceText = document.querySelector("#serviceStateText");
342
- const serviceDot = document.querySelector("#serviceDot");
343
- if (state.codex?.ready) {
344
- serviceText.textContent = state.codex.version || "Codex 已就绪";
345
- serviceDot.classList.remove("is-error");
346
- } else {
347
- serviceText.textContent = "未检测到 Codex CLI";
348
- serviceDot.classList.add("is-error");
349
- }
350
- }
351
-
352
- function renderAccounts() {
353
- const expandedAccountIds = new Set(
354
- [...els.accountsList.querySelectorAll(".account-identifiers[open]")]
355
- .map((details) => details.dataset.accountId)
356
- .filter(Boolean)
357
- );
358
- if (!state.accounts.length) {
359
- els.accountsList.innerHTML = emptyState("scan-line", "还没有微信账号", "", `<button class="button button-primary" type="button" data-account-action="add"><i data-lucide="scan-line"></i><span>添加微信</span></button>`);
360
- drawIcons();
361
- return;
362
- }
363
- els.accountsList.innerHTML = state.accounts.map((account) => {
364
- const pendingSender = account.lastActiveSenderId && !account.pairedSenderIds.includes(account.lastActiveSenderId)
365
- ? account.lastActiveSenderId : "";
366
- const authorized = account.pairedSenderIds.length > 0;
367
- return `<article class="account-card">
368
- <div class="account-main">
369
- <div class="account-identity">
370
- <span class="account-avatar"><i data-lucide="message-circle"></i></span>
371
- <div class="account-name">
372
- <strong>${escapeHtml(accountDisplayName(account.accountId))}</strong>
373
- <div class="account-description">个人微信接入</div>
374
- </div>
375
- </div>
376
- <div class="account-stat"><span>状态</span><strong class="status-label status-${escapeAttr(account.status)}">${statusText(account.status)}</strong></div>
377
- <div class="account-stat"><span>会话</span><strong>${account.sessionCount}</strong></div>
378
- <div class="account-actions">
379
- <button class="icon-button" type="button" data-account-action="rename" data-account-id="${escapeAttr(account.accountId)}" title="修改账号备注" aria-label="修改账号备注"><i data-lucide="pencil"></i></button>
380
- <button class="icon-button" type="button" data-account-action="${account.status === "running" ? "stop" : "start"}" data-account-id="${escapeAttr(account.accountId)}" title="${account.status === "running" ? "停止账号" : "启动账号"}" aria-label="${account.status === "running" ? "停止账号" : "启动账号"}"><i data-lucide="${account.status === "running" ? "pause" : "play"}"></i></button>
381
- <button class="icon-button is-danger" type="button" data-account-action="remove" data-account-id="${escapeAttr(account.accountId)}" title="移除账号" aria-label="移除账号"><i data-lucide="trash-2"></i></button>
382
- </div>
383
- </div>
384
- <details class="account-identifiers" data-account-id="${escapeAttr(account.accountId)}"${expandedAccountIds.has(account.accountId) ? " open" : ""}>
385
- <summary>
386
- <span class="account-identifiers-title"><i data-lucide="fingerprint"></i><strong>账号 ID</strong><small>Bot ID 与 User ID</small></span>
387
- <i class="account-identifiers-chevron" data-lucide="chevron-down"></i>
388
- </summary>
389
- <dl class="account-identifiers-grid">
390
- <div><dt>Bot ID</dt><dd><code title="${escapeAttr(account.botId || account.accountId)}">${escapeHtml(account.botId || account.accountId)}</code></dd></div>
391
- <div><dt>User ID</dt><dd><code title="${escapeAttr(account.userId || "未返回")}">${escapeHtml(account.userId || "未返回")}</code></dd></div>
392
- </dl>
393
- </details>
394
- <div class="account-detail">${renderAuthorizationState(account, pendingSender)}</div>
395
- ${authorized && pendingSender ? `<div class="pending-access"><div><strong>新的微信访问请求</strong><span>当前授权不受影响,可选择允许新的访问</span></div><button class="button button-secondary" type="button" data-account-action="allow" data-account-id="${escapeAttr(account.accountId)}" data-sender-id="${escapeAttr(pendingSender)}"><i data-lucide="user-check"></i><span>允许访问</span></button></div>` : ""}
396
- ${account.error ? `<div class="pending-access"><div><strong>账号运行错误</strong><span>${escapeHtml(account.error)}</span></div></div>` : ""}
397
- </article>`;
398
- }).join("");
399
- }
400
-
401
- function renderAuthorizationState(account, pendingSender) {
402
- const accountId = escapeAttr(account.accountId);
403
- if (account.pairedSenderIds.length) {
404
- return `<div class="authorization-state is-authorized" aria-label="授权状态:已授权">
405
- <span class="authorization-icon"><i data-lucide="shield-check"></i></span>
406
- <div class="authorization-copy"><strong>已授权</strong><span>可以从微信控制 Codex</span></div>
407
- <button class="button button-secondary authorization-action" type="button" data-account-action="revoke-all" data-account-id="${accountId}"><i data-lucide="shield-x"></i><span>撤销授权</span></button>
408
- </div>`;
409
- }
410
- if (pendingSender) {
411
- return `<div class="authorization-state is-pending" aria-label="授权状态:待授权">
412
- <span class="authorization-icon"><i data-lucide="shield-alert"></i></span>
413
- <div class="authorization-copy"><strong>待授权</strong><span>检测到新的微信访问请求</span></div>
414
- <button class="button button-primary authorization-action" type="button" data-account-action="allow" data-account-id="${accountId}" data-sender-id="${escapeAttr(pendingSender)}"><i data-lucide="user-check"></i><span>允许访问</span></button>
415
- </div>`;
416
- }
417
- return `<div class="authorization-state is-unauthorized" aria-label="授权状态:未授权">
418
- <span class="authorization-icon"><i data-lucide="shield"></i></span>
419
- <div class="authorization-copy"><strong>未授权</strong><span>请先从微信向此账号发送一条消息</span></div>
420
- </div>`;
421
- }
422
-
423
- function renderSessions() {
424
- if (!state.sessions.length) {
425
- els.sessionListCount.textContent = "0";
426
- els.sessionAccountTabs.innerHTML = "";
427
- els.sessionsList.innerHTML = emptyState("messages-square", "还没有受管会话", "", `<button class="button button-secondary" type="button" data-session-action="new"><i data-lucide="plus"></i><span>新建会话</span></button>`);
428
- state.selectedSessionKey = "";
429
- state.sessionMessages = [];
430
- state.loadedSessionKey = "";
431
- renderChatPanel();
432
- drawIcons();
433
- return;
434
- }
435
- const accountIds = [...new Set([
436
- ...state.accounts.map((account) => account.accountId),
437
- ...state.sessions.map((session) => session.accountId)
438
- ])].filter((accountId) => state.sessions.some((session) => session.accountId === accountId));
439
- if (!accountIds.includes(state.selectedAccountId)) {
440
- const selected = selectedSession();
441
- state.selectedAccountId = selected && accountIds.includes(selected.accountId) ? selected.accountId : accountIds[0];
442
- }
443
- const visibleSessions = state.sessions.filter((session) => session.accountId === state.selectedAccountId);
444
- els.sessionListCount.textContent = String(visibleSessions.length);
445
- els.sessionAccountTabs.innerHTML = accountIds.map((accountId) => {
446
- const active = accountId === state.selectedAccountId;
447
- const count = state.sessions.filter((session) => session.accountId === accountId).length;
448
- return `<button class="session-account-tab${active ? " is-active" : ""}" type="button" data-session-account="${escapeAttr(accountId)}" aria-pressed="${active}"><i data-lucide="message-circle"></i><span>${escapeHtml(accountDisplayName(accountId))}</span><b>${count}</b></button>`;
449
- }).join("");
450
- let shouldLoad = false;
451
- if (!visibleSessions.some((session) => sessionKey(session) === state.selectedSessionKey)) {
452
- state.selectedSessionKey = sessionKey(visibleSessions[0]);
453
- state.sessionMessages = [];
454
- state.loadedSessionKey = "";
455
- shouldLoad = true;
456
- }
457
- els.sessionsList.innerHTML = visibleSessions.map((session) => {
458
- const selected = sessionKey(session) === state.selectedSessionKey;
459
- return `<article class="session-card${selected ? " is-selected" : ""}">
460
- <button class="session-open" type="button" data-session-action="open" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" aria-pressed="${selected}">
461
- <span class="session-card-top"><strong>${session.active ? `<span class="active-mark" title="微信当前会话"></span>` : ""}${escapeHtml(session.title)}</strong><time datetime="${escapeAttr(session.updatedAt)}">${escapeHtml(relativeTime(session.updatedAt))}</time></span>
462
- <span class="session-owner"><strong>${escapeHtml(accountDisplayName(session.accountId))}</strong></span>
463
- <span class="session-workspace" title="${escapeAttr(session.workspace)}">${escapeHtml(session.workspace)}</span>
464
- <span class="session-thread${session.responding ? " is-responding" : ""}">${session.responding ? "对方正在输入…" : session.threadId ? "已连接 Codex" : "等待首条消息"}</span>
465
- </button>
466
- <div class="session-actions">
467
- <button class="icon-button" type="button" data-session-action="activate" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" ${session.active ? "disabled" : ""} title="切换为微信当前会话" aria-label="切换为微信当前会话"><i data-lucide="circle-play"></i></button>
468
- <button class="icon-button" type="button" data-session-action="rename" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" title="重命名会话" aria-label="重命名会话"><i data-lucide="pencil"></i></button>
469
- <button class="icon-button" type="button" data-session-action="reset" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" ${session.threadId ? "" : "disabled"} title="重置 Codex 上下文" aria-label="重置 Codex 上下文"><i data-lucide="rotate-ccw"></i></button>
470
- <button class="icon-button is-danger" type="button" data-session-action="delete" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" title="删除受管会话" aria-label="删除受管会话"><i data-lucide="trash-2"></i></button>
471
- </div>
472
- </article>`;
473
- }).join("");
474
- renderChatPanel();
475
- drawIcons();
476
- if (shouldLoad) void loadSelectedSessionMessages();
477
- }
478
-
479
- function renderSettings() {
480
- if (!state.config) return;
481
- document.querySelector("#defaultCwdInput").value = state.config.defaultCwd || "";
482
- document.querySelector("#allowedWorkspacesInput").value = (state.config.allowedWorkspaces || []).join("\n");
483
- document.querySelector("#backendInput").value = state.config.codexBackend || "auto";
484
- document.querySelector("#sandboxInput").value = state.config.codexExecSandbox || "";
485
- document.querySelector("#streamRepliesInput").checked = Boolean(state.config.streamReplies);
486
- renderModelOptions();
487
- document.querySelector("#effectiveModelValue").textContent = state.codexRuntime?.model || state.config.model || "Codex 默认";
488
- document.querySelector("#effectiveEffortValue").textContent = state.codexRuntime?.effort || state.config.effort || "Codex 默认";
489
- }
490
-
491
- function renderModelOptions() {
492
- const select = document.querySelector("#modelInput");
493
- const configuredModel = state.config?.model || "";
494
- const effectiveModel = state.codexRuntime?.model || "";
495
- const models = Array.isArray(state.codexModels) ? state.codexModels : [];
496
- const options = [{
497
- value: "",
498
- label: effectiveModel ? `沿用 Codex 设置(当前:${effectiveModel})` : "沿用 Codex 设置"
499
- }, ...models.map((model) => ({
500
- value: model.model,
501
- label: model.displayName && model.displayName !== model.model
502
- ? `${model.displayName} · ${model.model}`
503
- : model.model,
504
- title: model.description || ""
505
- }))];
506
- if (configuredModel && !options.some((option) => option.value === configuredModel)) {
507
- options.push({ value: configuredModel, label: `${configuredModel}(当前配置)` });
508
- }
509
- select.innerHTML = options.map((option) => `<option value="${escapeAttr(option.value)}"${option.title ? ` title="${escapeAttr(option.title)}"` : ""}>${escapeHtml(option.label)}</option>`).join("");
510
- select.value = configuredModel;
511
- renderEffortOptions(state.config?.effort || "");
512
- }
513
-
514
- function renderEffortOptions(preferredEffort) {
515
- const modelValue = document.querySelector("#modelInput").value;
516
- const effectiveModel = modelValue || state.codexRuntime?.model || "";
517
- const model = state.codexModels.find((candidate) => candidate.model === effectiveModel);
518
- const allEfforts = model?.supportedEfforts?.length
519
- ? model.supportedEfforts
520
- : state.codexModels.flatMap((candidate) => candidate.supportedEfforts || []);
521
- const efforts = [...new Map(allEfforts.map((option) => [option.effort, option])).values()]
522
- .sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
523
- if (preferredEffort && !efforts.some((option) => option.effort === preferredEffort)) {
524
- efforts.push({ effort: preferredEffort, description: "当前配置" });
525
- }
526
- const inheritedEffort = state.codexRuntime?.effort || model?.defaultEffort;
527
- const inheritedLabel = inheritedEffort
528
- ? `沿用 Codex 设置(当前:${effortInlineName(inheritedEffort)})`
529
- : "沿用 Codex 设置";
530
- const select = document.querySelector("#effortInput");
531
- select.innerHTML = [
532
- `<option value="">${escapeHtml(inheritedLabel)}</option>`,
533
- ...efforts.map((option) => `<option value="${escapeAttr(option.effort)}"${option.description ? ` title="${escapeAttr(option.description)}"` : ""}>${escapeHtml(effortDisplayName(option.effort))}</option>`)
534
- ].join("");
535
- select.value = preferredEffort;
536
- }
537
-
538
- function effortDisplayName(effort) {
539
- const label = ({ minimal: "最小", low: "低", medium: "中", high: "高", xhigh: "超高", max: "最大", ultra: "极高" })[effort];
540
- return label ? `${label}(${effort})` : effort;
541
- }
542
-
543
- function effortInlineName(effort) {
544
- const label = ({ minimal: "最小", low: "低", medium: "中", high: "高", xhigh: "超高", max: "最大", ultra: "极高" })[effort];
545
- return label ? `${label} · ${effort}` : effort;
546
- }
547
-
548
- function effortRank(effort) {
549
- const index = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"].indexOf(effort);
550
- return index < 0 ? Number.MAX_SAFE_INTEGER : index;
551
- }
552
-
553
- async function handleAccountAction(event) {
554
- const button = event.target.closest("[data-account-action]");
555
- if (!button) return;
556
- const action = button.dataset.accountAction;
557
- if (action === "add") return beginLogin();
558
- const accountId = button.dataset.accountId;
559
- const account = state.accounts.find((item) => item.accountId === accountId);
560
- if (action === "rename") {
561
- if (account) openAccountRemarkDialog(account);
562
- return;
563
- }
564
- if (action === "remove") {
565
- if (account) openRemoveAccountDialog(account);
566
- return;
567
- }
568
- try {
569
- button.disabled = true;
570
- if (action === "start" || action === "stop") await api(`/api/accounts/${encodeURIComponent(accountId)}/${action}`, { method: "POST" });
571
- if (action === "allow") {
572
- await api(`/api/accounts/${encodeURIComponent(accountId)}/senders/${encodeURIComponent(button.dataset.senderId)}/allow`, { method: "POST" });
573
- }
574
- if (action === "revoke-all" && account) {
575
- if (!window.confirm("撤销此微信账号的全部控制授权?撤销后需要重新允许才能继续使用。")) return;
576
- await Promise.all(account.pairedSenderIds.map((senderId) => api(
577
- `/api/accounts/${encodeURIComponent(accountId)}/senders/${encodeURIComponent(senderId)}/remove`,
578
- { method: "POST" }
579
- )));
580
- }
581
- await refreshData(false);
582
- } catch (error) {
583
- toast(error.message, true);
584
- } finally {
585
- button.disabled = false;
586
- }
587
- }
588
-
589
- function openAccountRemarkDialog(account) {
590
- document.querySelector("#editingRemarkAccountId").value = account.accountId;
591
- document.querySelector("#accountRemarkInput").value = account.displayName || "";
592
- els.accountDialog.showModal();
593
- document.querySelector("#accountRemarkInput").select();
594
- }
595
-
596
- function openRemoveAccountDialog(account) {
597
- document.querySelector("#removingAccountId").value = account.accountId;
598
- document.querySelector("#removingAccountName").textContent = accountDisplayName(account.accountId);
599
- document.querySelector('input[name="retainHistory"][value="true"]').checked = true;
600
- els.removeAccountDialog.showModal();
601
- drawIcons();
602
- }
603
-
604
- async function removeAccount(event) {
605
- event.preventDefault();
606
- const button = event.submitter;
607
- const accountId = document.querySelector("#removingAccountId").value;
608
- const retainHistory = document.querySelector('input[name="retainHistory"]:checked')?.value === "true";
609
- try {
610
- button.disabled = true;
611
- await api(`/api/accounts/${encodeURIComponent(accountId)}`, {
612
- method: "DELETE",
613
- body: { retainHistory }
614
- });
615
- els.removeAccountDialog.close();
616
- await refreshData(false);
617
- toast(retainHistory ? "账号已移除,重新扫码后将恢复会话历史" : "账号和会话历史已删除");
618
- } catch (error) {
619
- toast(error.message, true);
620
- } finally {
621
- button.disabled = false;
622
- }
623
- }
624
-
625
- async function saveAccountRemark(event) {
626
- event.preventDefault();
627
- const button = event.submitter;
628
- const accountId = document.querySelector("#editingRemarkAccountId").value;
629
- const displayName = document.querySelector("#accountRemarkInput").value.trim();
630
- try {
631
- button.disabled = true;
632
- await api(`/api/accounts/${encodeURIComponent(accountId)}`, {
633
- method: "PATCH",
634
- body: { displayName }
635
- });
636
- els.accountDialog.close();
637
- await refreshData(false);
638
- toast(displayName ? "账号备注已保存" : "账号备注已清除");
639
- } catch (error) {
640
- toast(error.message, true);
641
- } finally {
642
- button.disabled = false;
643
- }
644
- }
645
-
646
- async function handleSessionAction(event) {
647
- const button = event.target.closest("[data-session-action]");
648
- if (!button) return;
649
- const action = button.dataset.sessionAction;
650
- if (action === "new") return openNewSessionDialog();
651
- const accountId = button.dataset.accountId;
652
- const sessionId = button.dataset.sessionId;
653
- const session = state.sessions.find((item) => item.accountId === accountId && item.id === sessionId);
654
- if (!session) return;
655
- if (action === "open") return selectSession(session);
656
- if (action === "rename") return openRenameSessionDialog(session);
657
- try {
658
- button.disabled = true;
659
- if (action === "activate" || action === "reset") {
660
- if (action === "reset" && !window.confirm("重置此会话的 Codex 上下文?下一条微信或 Web 消息会创建新的 thread。")) return;
661
- await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}/${action}`, { method: "POST" });
662
- }
663
- if (action === "delete") {
664
- if (!window.confirm("删除此受管会话?Codex 自身保存的历史文件不会被删除。")) return;
665
- await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
666
- }
667
- await refreshData(false);
668
- } catch (error) {
669
- toast(error.message, true);
670
- } finally {
671
- button.disabled = false;
672
- }
673
- }
674
-
675
- function handleSessionAccountTab(event) {
676
- const button = event.target.closest("[data-session-account]");
677
- if (!button || button.dataset.sessionAccount === state.selectedAccountId) return;
678
- state.selectedAccountId = button.dataset.sessionAccount;
679
- state.selectedSessionKey = "";
680
- state.sessionMessages = [];
681
- state.loadedSessionKey = "";
682
- resetComposer();
683
- renderSessions();
684
- }
685
-
686
- function selectSession(session) {
687
- const key = sessionKey(session);
688
- if (key === state.selectedSessionKey && state.loadedSessionKey === key) {
689
- return;
690
- }
691
- state.selectedSessionKey = key;
692
- state.sessionMessages = [];
693
- state.loadedSessionKey = "";
694
- state.loadingMessages = true;
695
- resetComposer();
696
- renderSessions();
697
- void loadSelectedSessionMessages();
698
- }
699
-
700
- async function loadSelectedSessionMessages() {
701
- const session = selectedSession();
702
- if (!session) {
703
- renderChatPanel();
704
- return;
705
- }
706
- const key = sessionKey(session);
707
- state.loadingMessages = true;
708
- renderChatPanel();
709
- try {
710
- const result = await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`);
711
- if (state.selectedSessionKey !== key) return;
712
- state.sessionMessages = result.messages || [];
713
- state.loadedSessionKey = key;
714
- } catch (error) {
715
- if (state.selectedSessionKey !== key) return;
716
- state.sessionMessages = [];
717
- state.loadedSessionKey = key;
718
- toast(error.message, true);
719
- } finally {
720
- if (state.selectedSessionKey === key) {
721
- state.loadingMessages = false;
722
- renderChatPanel();
723
- scrollChatToEnd();
724
- }
725
- }
726
- }
727
-
728
- function renderChatPanel() {
729
- const session = selectedSession();
730
- const enabled = Boolean(session) && !state.sendingMessage && !state.savingSessionRuntime;
731
- els.chatInput.disabled = !enabled;
732
- els.chatAttachButton.disabled = !enabled;
733
- els.chatFileInput.disabled = !enabled;
734
- els.refreshMessagesButton.disabled = !session || state.loadingMessages || state.sendingMessage;
735
- renderComposerFiles();
736
- updateComposerState();
737
- renderSessionRuntimeControls(session);
738
- if (!session) {
739
- els.chatTitle.textContent = "选择一个会话";
740
- els.chatContext.textContent = "查看历史消息并继续聊天";
741
- setChatMessagesHtml(emptyChatState("messages-square", "从左侧选择会话"), "no-session");
742
- return;
743
- }
744
-
745
- els.chatTitle.textContent = session.title;
746
- els.chatContext.textContent = `${accountDisplayName(session.accountId)} · ${session.workspace}`;
747
- const responding = Boolean(session.responding || state.sendingMessage);
748
- if (state.loadingMessages) {
749
- setChatMessagesHtml(
750
- `<div class="chat-loading"><div class="spinner" aria-label="正在加载历史消息"></div><span>正在读取 Codex 历史</span></div>`,
751
- `loading:${sessionKey(session)}`
752
- );
753
- return;
754
- }
755
- if (!state.sessionMessages.length) {
756
- setChatMessagesHtml(
757
- responding
758
- ? renderTypingIndicator()
759
- : emptyChatState("message-circle", session.threadId ? "这个 thread 暂无可显示消息" : "发送第一条消息开始会话", session.threadId ? "" : "历史会在 Codex 创建 thread 后显示"),
760
- `empty:${sessionKey(session)}:${session.threadId || "new"}:${responding}`
761
- );
762
- return;
763
- }
764
- const renderKey = `messages:${sessionKey(session)}:${responding}:${JSON.stringify(state.sessionMessages)}`;
765
- const html = renderConversationMessages(state.sessionMessages, responding) + (responding ? renderTypingIndicator() : "");
766
- setChatMessagesHtml(html, renderKey);
767
- }
768
-
769
- function renderConversationMessages(messages, responding) {
770
- const html = [];
771
- let lastUserCreatedAt;
772
- let index = 0;
773
- while (index < messages.length) {
774
- const message = messages[index];
775
- if (message.kind === "progress") {
776
- const progress = [];
777
- while (index < messages.length && messages[index].kind === "progress") {
778
- progress.push(messages[index]);
779
- index += 1;
780
- }
781
- const nextMessage = messages[index];
782
- const active = responding && !nextMessage;
783
- const completedAt = nextMessage?.role === "assistant"
784
- ? nextMessage.createdAt
785
- : progress.at(-1)?.createdAt;
786
- html.push(renderProgressGroup(progress, lastUserCreatedAt, completedAt, active));
787
- continue;
788
- }
789
- if (message.role === "user") lastUserCreatedAt = message.createdAt;
790
- html.push(renderChatMessage(message));
791
- index += 1;
792
- }
793
- return html.join("");
794
- }
795
-
796
- function renderChatMessage(message) {
797
- return `<article class="chat-message is-${escapeAttr(message.role)}${message.attachments?.length ? " has-attachments" : ""}">
798
- <div class="message-meta"><span>${message.role === "user" ? "你" : "Codex"}</span>${message.createdAt ? `<time datetime="${escapeAttr(message.createdAt)}">${escapeHtml(messageTime(message.createdAt))}</time>` : ""}</div>
799
- <div class="message-bubble">${message.text ? renderMarkdown(message.text) : ""}${renderMessageAttachments(message.attachments)}</div>
800
- </article>`;
801
- }
802
-
803
- function renderProgressGroup(messages, startedAt, completedAt, active) {
804
- const duration = formatProcessingDuration(startedAt, active ? new Date().toISOString() : completedAt);
805
- return `<details class="chat-progress-group"${active ? " open" : ""}>
806
- <summary>
807
- <span class="progress-summary-title"><i data-lucide="chevron-right"></i>处理过程</span>
808
- <span>${active ? "已处理" : "处理用时"} ${escapeHtml(duration)}</span>
809
- </summary>
810
- <ol class="progress-list">${messages.map((message) => `<li>${renderMarkdown(message.text)}</li>`).join("")}</ol>
811
- </details>`;
812
- }
813
-
814
- function formatProcessingDuration(startValue, endValue) {
815
- const start = new Date(startValue || "").getTime();
816
- const end = new Date(endValue || "").getTime();
817
- if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--";
818
- const seconds = Math.max(0, Math.round((end - start) / 1000));
819
- if (seconds < 60) return `${seconds} 秒`;
820
- const minutes = Math.floor(seconds / 60);
821
- const remaining = seconds % 60;
822
- return remaining ? `${minutes} 分 ${remaining} 秒` : `${minutes} 分钟`;
823
- }
824
-
825
- function renderSessionRuntimeControls(session) {
826
- const disabled = !session || state.sendingMessage || state.savingSessionRuntime;
827
- els.sessionModelInput.disabled = disabled;
828
- els.sessionEffortInput.disabled = disabled;
829
- els.sessionStreamInput.disabled = disabled;
830
- const renderKey = session ? [
831
- sessionKey(session),
832
- session.model || "",
833
- session.effort || "",
834
- typeof session.streamReplies === "boolean" ? String(session.streamReplies) : "inherit",
835
- state.config?.model || "",
836
- state.config?.effort || "",
837
- String(Boolean(state.config?.streamReplies)),
838
- state.codexRuntime?.model || "",
839
- state.codexRuntime?.effort || "",
840
- state.codexModels.length
841
- ].join("|") : "none";
842
- if (els.sessionRuntimeToolbar.dataset.renderKey === renderKey) return;
843
- els.sessionRuntimeToolbar.dataset.renderKey = renderKey;
844
-
845
- if (!session) {
846
- els.sessionModelInput.innerHTML = '<option value="">选择会话后设置</option>';
847
- els.sessionEffortInput.innerHTML = '<option value="">选择会话后设置</option>';
848
- els.sessionStreamInput.innerHTML = '<option value="">选择会话后设置</option>';
849
- return;
850
- }
851
-
852
- const inheritedModel = state.config?.model || state.codexRuntime?.model || "";
853
- const models = Array.isArray(state.codexModels) ? state.codexModels : [];
854
- const options = [{
855
- value: "",
856
- label: inheritedModel ? `继承全局(${inheritedModel})` : "继承全局设置"
857
- }, ...models.map((model) => ({
858
- value: model.model,
859
- label: model.displayName && model.displayName !== model.model
860
- ? `${model.displayName} · ${model.model}`
861
- : model.model,
862
- title: model.description || ""
863
- }))];
864
- if (session.model && !options.some((option) => option.value === session.model)) {
865
- options.push({ value: session.model, label: `${session.model}(当前会话)`, title: "" });
866
- }
867
- els.sessionModelInput.innerHTML = options.map((option) => `<option value="${escapeAttr(option.value)}"${option.title ? ` title="${escapeAttr(option.title)}"` : ""}>${escapeHtml(option.label)}</option>`).join("");
868
- els.sessionModelInput.value = session.model || "";
869
- setSessionEffortOptions(session.model || "", session.effort || "");
870
- els.sessionStreamInput.innerHTML = [
871
- `<option value="">继承全局(${state.config?.streamReplies ? "开启" : "关闭"})</option>`,
872
- '<option value="on">开启</option>',
873
- '<option value="off">关闭</option>'
874
- ].join("");
875
- els.sessionStreamInput.value = typeof session.streamReplies === "boolean"
876
- ? session.streamReplies ? "on" : "off"
877
- : "";
878
- }
879
-
880
- function setSessionEffortOptions(modelOverride, preferredEffort) {
881
- const effectiveModel = modelOverride || state.config?.model || state.codexRuntime?.model || "";
882
- const model = state.codexModels.find((candidate) => candidate.model === effectiveModel);
883
- const advertised = model?.supportedEfforts?.length
884
- ? model.supportedEfforts
885
- : state.codexModels.flatMap((candidate) => candidate.supportedEfforts || []);
886
- const efforts = [...new Map(advertised.map((option) => [option.effort, option])).values()]
887
- .sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
888
- if (preferredEffort && !efforts.some((option) => option.effort === preferredEffort)) {
889
- efforts.push({ effort: preferredEffort, description: "当前会话" });
890
- }
891
- const inheritedEffort = state.config?.effort || state.codexRuntime?.effort || model?.defaultEffort;
892
- const inheritedLabel = inheritedEffort
893
- ? `继承全局(${effortInlineName(inheritedEffort)})`
894
- : "继承全局设置";
895
- els.sessionEffortInput.innerHTML = [
896
- `<option value="">${escapeHtml(inheritedLabel)}</option>`,
897
- ...efforts.map((option) => `<option value="${escapeAttr(option.effort)}"${option.description ? ` title="${escapeAttr(option.description)}"` : ""}>${escapeHtml(effortDisplayName(option.effort))}</option>`)
898
- ].join("");
899
- els.sessionEffortInput.value = preferredEffort;
900
- }
901
-
902
- async function handleSessionModelChange() {
903
- const modelValue = els.sessionModelInput.value;
904
- const model = state.codexModels.find((candidate) => candidate.model === (modelValue || state.config?.model || state.codexRuntime?.model));
905
- const efforts = model?.supportedEfforts?.map((option) => option.effort) || [];
906
- let effortValue = els.sessionEffortInput.value;
907
- const inheritedEffort = state.config?.effort || state.codexRuntime?.effort || "";
908
- const effectiveEffort = effortValue || inheritedEffort;
909
- if (effectiveEffort && efforts.length && !efforts.includes(effectiveEffort)) {
910
- effortValue = efforts.includes(model?.defaultEffort) ? model.defaultEffort : efforts[0] || "";
911
- }
912
- setSessionEffortOptions(modelValue, effortValue);
913
- await saveSessionRuntimeSettings();
914
- }
915
-
916
- async function saveSessionRuntimeSettings() {
917
- const session = selectedSession();
918
- if (!session || state.savingSessionRuntime) return;
919
- const key = sessionKey(session);
920
- const model = els.sessionModelInput.value;
921
- const effort = els.sessionEffortInput.value;
922
- const stream = els.sessionStreamInput.value;
923
- state.savingSessionRuntime = true;
924
- renderChatPanel();
925
- try {
926
- const result = await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}`, {
927
- method: "PATCH",
928
- body: {
929
- model: model || null,
930
- effort: effort || null,
931
- streamReplies: stream ? stream === "on" : null
932
- }
933
- });
934
- const index = state.sessions.findIndex((candidate) => sessionKey(candidate) === key);
935
- if (index >= 0) state.sessions[index] = result.session;
936
- els.sessionRuntimeToolbar.dataset.renderKey = "";
937
- renderSessions();
938
- toast("会话设置已更新");
939
- } catch (error) {
940
- toast(error.message, true);
941
- await refreshData(false);
942
- } finally {
943
- state.savingSessionRuntime = false;
944
- renderChatPanel();
945
- }
946
- }
947
-
948
- function renderTypingIndicator() {
949
- return `<div class="chat-typing" role="status" aria-label="对方正在输入">
950
- <span class="typing-dots" aria-hidden="true"><i></i><i></i><i></i></span>
951
- <span>对方正在输入…</span>
952
- </div>`;
953
- }
954
-
955
- function setChatMessagesHtml(html, renderKey) {
956
- if (els.chatMessages.dataset.renderKey === renderKey) return;
957
- els.chatMessages.innerHTML = html;
958
- els.chatMessages.dataset.renderKey = renderKey;
959
- drawIcons();
960
- }
961
-
962
- function renderMessageAttachments(attachments) {
963
- if (!Array.isArray(attachments) || !attachments.length) return "";
964
- return `<div class="message-attachments">${attachments.map((attachment) => {
965
- const name = escapeHtml(attachment.name || "附件");
966
- const url = attachment.url ? escapeAttr(attachment.url) : "";
967
- const pending = Boolean(attachment.pending);
968
- const available = Boolean(attachment.available && url);
969
- const icon = attachment.type === "video" ? "file-video" : attachment.type === "image" ? "image" : "file";
970
- let preview = "";
971
- if (available && attachment.type === "video") {
972
- preview = `<video controls playsinline preload="metadata" src="${url}" aria-label="视频:${escapeAttr(attachment.name || "附件")}"></video>`;
973
- } else if (available && attachment.type === "image") {
974
- preview = `<img loading="lazy" src="${url}" alt="${escapeAttr(attachment.name || "图片附件")}">`;
975
- }
976
- return `<div class="message-attachment is-${escapeAttr(attachment.type || "file")}${available || pending ? "" : " is-missing"}">
977
- ${preview}
978
- <div class="attachment-meta">
979
- <span class="attachment-type-icon"><i data-lucide="${icon}"></i></span>
980
- <span class="attachment-copy"><strong title="${escapeAttr(attachment.name || "附件")}">${name}</strong><small>${pending ? "正在上传" : available ? formatBytes(attachment.size) : "文件已移动或删除"}</small></span>
981
- ${available ? `<a class="icon-button attachment-download" href="${url}?download=1" download="${escapeAttr(attachment.name || "attachment")}" title="下载附件" aria-label="下载 ${escapeAttr(attachment.name || "附件")}"><i data-lucide="download"></i></a>` : ""}
982
- </div>
983
- </div>`;
984
- }).join("")}</div>`;
985
- }
986
-
987
- function formatBytes(value) {
988
- const bytes = Number(value);
989
- if (!Number.isFinite(bytes) || bytes < 0) return "本机文件";
990
- if (bytes < 1024) return `${bytes} B`;
991
- if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
992
- if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
993
- return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
994
- }
995
-
996
- async function sendSessionMessage(event) {
997
- event.preventDefault();
998
- const session = selectedSession();
999
- const text = els.chatInput.value.trim();
1000
- const files = [...state.chatFiles];
1001
- if (!session || (!text && !files.length) || state.sendingMessage) return;
1002
- const key = sessionKey(session);
1003
- const streaming = session.streamReplies ?? Boolean(state.config?.streamReplies);
1004
- let progressSequence = 0;
1005
- state.sendingMessage = true;
1006
- state.sessionMessages.push({
1007
- id: `pending-${Date.now()}`,
1008
- role: "user",
1009
- text,
1010
- createdAt: new Date().toISOString(),
1011
- attachments: files.map((file, index) => ({
1012
- index,
1013
- type: fileKind(file),
1014
- name: file.name,
1015
- size: file.size,
1016
- pending: true
1017
- }))
1018
- });
1019
- els.chatInput.value = "";
1020
- state.chatFiles = [];
1021
- renderChatPanel();
1022
- scrollChatToEnd();
1023
- try {
1024
- const body = new FormData();
1025
- body.append("text", text);
1026
- files.forEach((file) => body.append("files", file, file.name));
1027
- const url = `/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`;
1028
- if (streaming) {
1029
- await streamApi(`${url}?stream=1`, { method: "POST", body }, (streamEvent) => {
1030
- if (state.selectedSessionKey !== key) return;
1031
- if (streamEvent.type === "progress" && streamEvent.message?.trim()) {
1032
- state.sessionMessages.push({
1033
- id: `progress-${Date.now()}-${progressSequence++}`,
1034
- role: "assistant",
1035
- text: streamEvent.message.trim(),
1036
- kind: "progress",
1037
- createdAt: new Date().toISOString(),
1038
- attachments: []
1039
- });
1040
- scheduleStreamingRender();
1041
- }
1042
- if (streamEvent.type === "done" && streamEvent.result?.message) {
1043
- state.sessionMessages.push(streamEvent.result.message);
1044
- scheduleStreamingRender();
1045
- }
1046
- });
1047
- } else {
1048
- await api(url, { method: "POST", body });
1049
- }
1050
- await refreshData(false);
1051
- if (state.selectedSessionKey === key) {
1052
- await loadSelectedSessionMessages();
1053
- }
1054
- } catch (error) {
1055
- toast(error.message, true);
1056
- if (state.selectedSessionKey === key) {
1057
- els.chatInput.value = text;
1058
- state.chatFiles = files;
1059
- await loadSelectedSessionMessages();
1060
- }
1061
- } finally {
1062
- state.sendingMessage = false;
1063
- renderChatPanel();
1064
- els.chatInput.focus();
1065
- }
1066
- }
1067
-
1068
- function scheduleStreamingRender() {
1069
- if (streamingRenderFrame) return;
1070
- streamingRenderFrame = requestAnimationFrame(() => {
1071
- streamingRenderFrame = 0;
1072
- renderChatPanel();
1073
- scrollChatToEnd();
1074
- });
1075
- }
1076
-
1077
- function handleChatFileSelection(event) {
1078
- const nextFiles = [...event.target.files];
1079
- event.target.value = "";
1080
- if (!nextFiles.length) return;
1081
- const combined = [...state.chatFiles, ...nextFiles];
1082
- if (combined.length > MAX_CHAT_FILES) {
1083
- toast(`一次最多添加 ${MAX_CHAT_FILES} 个文件`, true);
1084
- return;
1085
- }
1086
- const totalBytes = combined.reduce((total, file) => total + file.size, 0);
1087
- if (totalBytes > MAX_CHAT_FILE_BYTES) {
1088
- toast("单次附件总大小不能超过 100 MiB", true);
1089
- return;
1090
- }
1091
- state.chatFiles = combined;
1092
- renderComposerFiles();
1093
- updateComposerState();
1094
- }
1095
-
1096
- function handleComposerFileAction(event) {
1097
- const button = event.target.closest("[data-remove-chat-file]");
1098
- if (!button || state.sendingMessage) return;
1099
- state.chatFiles.splice(Number(button.dataset.removeChatFile), 1);
1100
- renderComposerFiles();
1101
- updateComposerState();
1102
- }
1103
-
1104
- function renderComposerFiles() {
1105
- els.composerFiles.hidden = state.chatFiles.length === 0;
1106
- els.composerFiles.innerHTML = state.chatFiles.map((file, index) => `<div class="composer-file">
1107
- <i data-lucide="${fileKind(file) === "image" ? "image" : fileKind(file) === "video" ? "file-video" : "file"}"></i>
1108
- <span class="composer-file-copy"><strong title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</strong><small>${formatBytes(file.size)}</small></span>
1109
- <button class="icon-button composer-file-remove" type="button" data-remove-chat-file="${index}" title="移除附件" aria-label="移除 ${escapeAttr(file.name)}"><i data-lucide="x"></i></button>
1110
- </div>`).join("");
1111
- drawIcons();
1112
- }
1113
-
1114
- function updateComposerState() {
1115
- const canCompose = Boolean(selectedSession()) && !state.sendingMessage && !state.savingSessionRuntime;
1116
- els.chatInput.disabled = !canCompose;
1117
- els.chatAttachButton.disabled = !canCompose;
1118
- els.chatFileInput.disabled = !canCompose;
1119
- els.chatSendButton.disabled = !canCompose || (!els.chatInput.value.trim() && !state.chatFiles.length);
1120
- }
1121
-
1122
- function resetComposer() {
1123
- state.chatFiles = [];
1124
- if (els.chatInput) els.chatInput.value = "";
1125
- if (els.chatFileInput) els.chatFileInput.value = "";
1126
- if (els.composerFiles) renderComposerFiles();
1127
- }
1128
-
1129
- function fileKind(file) {
1130
- if (file.type.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|heic)$/i.test(file.name)) return "image";
1131
- if (file.type.startsWith("video/") || /\.(mp4|mov|webm|mkv|avi|m4v)$/i.test(file.name)) return "video";
1132
- return "file";
1133
- }
1134
-
1135
- function handleChatInputKeydown(event) {
1136
- if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
1137
- event.preventDefault();
1138
- els.chatComposer.requestSubmit();
1139
- }
1140
-
1141
- function selectedSession() {
1142
- return state.sessions.find((session) => sessionKey(session) === state.selectedSessionKey);
1143
- }
1144
-
1145
- function sessionKey(session) {
1146
- return `${session.accountId}\n${session.id}`;
1147
- }
1148
-
1149
- function emptyChatState(icon, title, description = "") {
1150
- return `<div class="chat-empty"><span><i data-lucide="${escapeAttr(icon)}"></i></span><strong>${escapeHtml(title)}</strong>${description ? `<p>${escapeHtml(description)}</p>` : ""}</div>`;
1151
- }
1152
-
1153
- function scrollChatToEnd() {
1154
- window.requestAnimationFrame(() => {
1155
- window.requestAnimationFrame(() => {
1156
- els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
1157
- });
1158
- });
1159
- }
1160
-
1161
- async function beginLogin() {
1162
- stopLoginPoll();
1163
- els.qrFrame.innerHTML = `<div class="spinner" aria-label="正在生成二维码"></div>`;
1164
- setQrStatus("正在生成二维码");
1165
- if (!els.qrDialog.open) els.qrDialog.showModal();
1166
- drawIcons();
1167
- try {
1168
- const login = await api("/api/logins", { method: "POST" });
1169
- els.qrFrame.innerHTML = `<img src="${escapeAttr(login.qrDataUrl)}" alt="微信登录二维码">`;
1170
- setQrStatus("等待微信扫码");
1171
- state.loginPoll = window.setInterval(() => void pollLogin(login.id), 1800);
1172
- } catch (error) {
1173
- setQrStatus(error.message, "error");
1174
- }
1175
- }
1176
-
1177
- async function pollLogin(id) {
1178
- try {
1179
- const result = await api(`/api/logins/${encodeURIComponent(id)}`, { token: false });
1180
- if (result.status === "waiting") setQrStatus("等待微信扫码");
1181
- if (result.status === "scanned") setQrStatus("已扫码,请在微信中确认");
1182
- if (result.status === "expired") {
1183
- stopLoginPoll();
1184
- setQrStatus("二维码已过期", "error");
1185
- }
1186
- if (result.status === "confirmed") {
1187
- stopLoginPoll();
1188
- setQrStatus("账号已连接", "success");
1189
- await refreshData(false);
1190
- window.setTimeout(() => els.qrDialog.close(), 900);
1191
- }
1192
- } catch (error) {
1193
- stopLoginPoll();
1194
- setQrStatus(error.message, "error");
1195
- }
1196
- }
1197
-
1198
- function stopLoginPoll() {
1199
- if (state.loginPoll) window.clearInterval(state.loginPoll);
1200
- state.loginPoll = null;
1201
- }
1202
-
1203
- function setQrStatus(text, kind = "") {
1204
- els.qrStatus.textContent = text;
1205
- els.qrStatus.className = `qr-status${kind ? ` is-${kind}` : ""}`;
1206
- }
1207
-
1208
- function openNewSessionDialog() {
1209
- const options = state.accounts.flatMap((account) => {
1210
- const sender = account.lastActiveSenderId && account.pairedSenderIds.includes(account.lastActiveSenderId)
1211
- ? account.lastActiveSenderId
1212
- : account.pairedSenderIds[0];
1213
- return sender ? [{ account, sender }] : [];
1214
- });
1215
- if (!options.length) {
1216
- toast("请先在微信发送消息,并在账号页允许该联系人", true);
1217
- showView("accounts");
1218
- return;
1219
- }
1220
- document.querySelector("#sessionDialogTitle").textContent = "新建会话";
1221
- document.querySelector("#editingSessionId").value = "";
1222
- document.querySelector("#editingAccountId").value = "";
1223
- document.querySelector("#senderField").hidden = false;
1224
- document.querySelector("#sessionWorkspaceField").hidden = false;
1225
- const senderInput = document.querySelector("#sessionSenderInput");
1226
- senderInput.innerHTML = options.map(({ account, sender }) => `<option value="${escapeAttr(`${account.accountId}\n${sender}`)}">${escapeHtml(accountDisplayName(account.accountId))}</option>`).join("");
1227
- const selectedOption = options.find(({ account }) => account.accountId === state.selectedAccountId) ?? options[0];
1228
- senderInput.value = `${selectedOption.account.accountId}\n${selectedOption.sender}`;
1229
- updateNewSessionDefaultTitle();
1230
- document.querySelector("#sessionWorkspaceInput").value = state.config.defaultCwd;
1231
- els.sessionDialog.showModal();
1232
- document.querySelector("#sessionTitleInput").focus();
1233
- }
1234
-
1235
- function updateNewSessionDefaultTitle() {
1236
- if (document.querySelector("#editingSessionId").value) return;
1237
- const [accountId] = document.querySelector("#sessionSenderInput").value.split("\n");
1238
- const accountSessionCount = state.sessions.filter((session) => session.accountId === accountId).length;
1239
- document.querySelector("#sessionTitleInput").value = `会话 ${accountSessionCount + 1}`;
1240
- }
1241
-
1242
- function openRenameSessionDialog(session) {
1243
- document.querySelector("#sessionDialogTitle").textContent = "重命名会话";
1244
- document.querySelector("#editingSessionId").value = session.id;
1245
- document.querySelector("#editingAccountId").value = session.accountId;
1246
- document.querySelector("#senderField").hidden = true;
1247
- document.querySelector("#sessionWorkspaceField").hidden = true;
1248
- document.querySelector("#sessionTitleInput").value = session.title;
1249
- els.sessionDialog.showModal();
1250
- document.querySelector("#sessionTitleInput").select();
1251
- }
1252
-
1253
- async function saveSession(event) {
1254
- event.preventDefault();
1255
- const title = document.querySelector("#sessionTitleInput").value.trim();
1256
- const sessionId = document.querySelector("#editingSessionId").value;
1257
- try {
1258
- if (sessionId) {
1259
- const accountId = document.querySelector("#editingAccountId").value;
1260
- await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}`, { method: "PATCH", body: { title } });
1261
- } else {
1262
- const [accountId, senderId] = document.querySelector("#sessionSenderInput").value.split("\n");
1263
- const created = await api("/api/sessions", { method: "POST", body: { accountId, senderId, title, workspace: document.querySelector("#sessionWorkspaceInput").value.trim() } });
1264
- state.selectedAccountId = accountId;
1265
- state.selectedSessionKey = sessionKey(created.session);
1266
- state.sessionMessages = [];
1267
- state.loadedSessionKey = "";
1268
- }
1269
- els.sessionDialog.close();
1270
- await refreshData(false);
1271
- } catch (error) {
1272
- toast(error.message, true);
1273
- }
1274
- }
1275
-
1276
- async function saveSettings(event) {
1277
- event.preventDefault();
1278
- const button = event.submitter;
1279
- try {
1280
- button.disabled = true;
1281
- const result = await api("/api/config", {
1282
- method: "PUT",
1283
- body: {
1284
- defaultCwd: document.querySelector("#defaultCwdInput").value.trim(),
1285
- allowedWorkspaces: document.querySelector("#allowedWorkspacesInput").value.split("\n").map((line) => line.trim()).filter(Boolean),
1286
- codexBackend: document.querySelector("#backendInput").value,
1287
- codexExecSandbox: document.querySelector("#sandboxInput").value || null,
1288
- model: document.querySelector("#modelInput").value.trim(),
1289
- effort: document.querySelector("#effortInput").value.trim(),
1290
- streamReplies: document.querySelector("#streamRepliesInput").checked
1291
- }
1292
- });
1293
- state.config = result.config;
1294
- state.codexRuntime = result.codexRuntime;
1295
- state.codexModels = result.codexModels || state.codexModels;
1296
- renderAll();
1297
- toast("设置已保存");
1298
- } catch (error) {
1299
- toast(error.message, true);
1300
- } finally {
1301
- button.disabled = false;
1302
- }
1303
- }
1304
-
1305
- function showView(name, updateHash = true) {
1306
- const valid = ["accounts", "sessions", "settings"].includes(name) ? name : "accounts";
1307
- document.querySelectorAll("[data-view-panel]").forEach((panel) => {
1308
- const visible = panel.dataset.viewPanel === valid;
1309
- panel.hidden = !visible;
1310
- panel.classList.toggle("is-visible", visible);
1311
- });
1312
- document.querySelectorAll(".tab[data-view]").forEach((tab) => tab.classList.toggle("is-active", tab.dataset.view === valid));
1313
- if (updateHash && location.hash !== `#${valid}`) history.replaceState(null, "", `#${valid}`);
1314
- }
1315
-
1316
- function closeDialog(id) {
1317
- document.querySelector(`#${CSS.escape(id)}`)?.close();
1318
- }
1319
-
1320
- async function api(url, options = {}) {
1321
- const isFormData = options.body instanceof FormData;
1322
- const headers = { ...(options.body && !isFormData ? { "Content-Type": "application/json" } : {}) };
1323
- if (options.token !== false && state.requestToken) headers["X-Codex-Weixin-Token"] = state.requestToken;
1324
- const response = await fetch(url, {
1325
- method: options.method || "GET",
1326
- headers,
1327
- body: options.body ? (isFormData ? options.body : JSON.stringify(options.body)) : undefined
1328
- });
1329
- const data = await response.json().catch(() => ({}));
1330
- if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
1331
- return data;
1332
- }
1333
-
1334
- async function streamApi(url, options, onEvent) {
1335
- const headers = {};
1336
- if (state.requestToken) headers["X-Codex-Weixin-Token"] = state.requestToken;
1337
- const response = await fetch(url, {
1338
- method: options.method || "POST",
1339
- headers,
1340
- body: options.body
1341
- });
1342
- const contentType = response.headers.get("content-type") || "";
1343
- if (!contentType.startsWith("application/x-ndjson")) {
1344
- const data = await response.json().catch(() => ({}));
1345
- if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
1346
- return data.result;
1347
- }
1348
- if (!response.ok || !response.body) {
1349
- throw new Error(`请求失败 (${response.status})`);
1350
- }
1351
- const reader = response.body.getReader();
1352
- const decoder = new TextDecoder();
1353
- let buffer = "";
1354
- let result;
1355
- const consumeLine = (line) => {
1356
- if (!line.trim()) return;
1357
- const streamEvent = JSON.parse(line);
1358
- if (streamEvent.type === "error") throw new Error(streamEvent.error || "过程进度失败");
1359
- onEvent(streamEvent);
1360
- if (streamEvent.type === "done") result = streamEvent.result;
1361
- };
1362
- while (true) {
1363
- const { value, done } = await reader.read();
1364
- buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
1365
- const lines = buffer.split(/\r?\n/);
1366
- buffer = lines.pop() || "";
1367
- for (const line of lines) consumeLine(line);
1368
- if (done) break;
1369
- }
1370
- if (buffer) consumeLine(buffer);
1371
- return result;
1372
- }
1373
-
1374
- function emptyState(icon, title, description = "", action = "") {
1375
- return `<div class="empty-state"><div class="empty-state-inner"><span class="empty-icon"><i data-lucide="${escapeAttr(icon)}"></i></span><h2>${escapeHtml(title)}</h2>${description ? `<p>${escapeHtml(description)}</p>` : ""}${action}</div></div>`;
1376
- }
1377
-
1378
- function accountDisplayName(accountId) {
1379
- const account = state.accounts.find((item) => item.accountId === accountId);
1380
- const index = state.accounts.findIndex((item) => item.accountId === accountId);
1381
- return account?.displayName || `微信账号 ${index >= 0 ? index + 1 : ""}`.trim();
1382
- }
1383
-
1384
- function statusText(status) {
1385
- return ({ running: "运行中", starting: "启动中", stopped: "已停止", error: "异常" })[status] || status;
1386
- }
1387
-
1388
- function shortId(value) {
1389
- if (!value || value.length <= 26) return value || "--";
1390
- return `${value.slice(0, 12)}...${value.slice(-8)}`;
1391
- }
1392
-
1393
- function relativeTime(value) {
1394
- const seconds = Math.round((Date.now() - new Date(value).getTime()) / 1000);
1395
- if (seconds < 60) return "刚刚";
1396
- const minutes = Math.floor(seconds / 60);
1397
- if (minutes < 60) return `${minutes} 分钟前`;
1398
- const hours = Math.floor(minutes / 60);
1399
- if (hours < 24) return `${hours} 小时前`;
1400
- return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }).format(new Date(value));
1401
- }
1402
-
1403
- function messageTime(value) {
1404
- const date = new Date(value);
1405
- if (Number.isNaN(date.getTime())) return "";
1406
- return new Intl.DateTimeFormat("zh-CN", {
1407
- month: "2-digit",
1408
- day: "2-digit",
1409
- hour: "2-digit",
1410
- minute: "2-digit"
1411
- }).format(date);
1412
- }
1413
-
1414
- function toast(message, error = false) {
1415
- const node = document.createElement("div");
1416
- node.className = `toast${error ? " is-error" : ""}`;
1417
- node.textContent = message;
1418
- document.querySelector("#toastRegion").append(node);
1419
- window.setTimeout(() => node.remove(), 3800);
1420
- }
1421
-
1422
- function drawIcons() {
1423
- window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
1424
- }
1425
-
1426
- function renderMarkdown(value) {
1427
- const source = String(value ?? "");
1428
- if (!window.marked?.parse || !window.DOMPurify?.sanitize) {
1429
- return escapeHtml(source).replace(/\n/g, "<br>");
1430
- }
1431
- const rendered = window.marked.parse(source, { gfm: true, breaks: true });
1432
- const clean = window.DOMPurify.sanitize(rendered, {
1433
- USE_PROFILES: { html: true },
1434
- FORBID_TAGS: ["style", "img"],
1435
- FORBID_ATTR: ["style"]
1436
- });
1437
- const template = document.createElement("template");
1438
- template.innerHTML = clean;
1439
- template.content.querySelectorAll("a").forEach((link) => {
1440
- link.target = "_blank";
1441
- link.rel = "noreferrer noopener";
1442
- });
1443
- return template.innerHTML;
1444
- }
1445
-
1446
- function escapeHtml(value) {
1447
- return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
1448
- }
1449
-
1450
- function escapeAttr(value) {
1451
- return escapeHtml(value).replace(/`/g, "&#96;");
1452
- }
1
+ const state = {
2
+ version: "",
3
+ requestToken: "",
4
+ accounts: [],
5
+ sessions: [],
6
+ config: null,
7
+ codex: null,
8
+ codexRuntime: null,
9
+ codexModels: [],
10
+ loginPoll: null,
11
+ selectedSessionKey: "",
12
+ sessionMessages: [],
13
+ loadedSessionKey: "",
14
+ loadingMessages: false,
15
+ sendingMessage: false,
16
+ savingSessionRuntime: false,
17
+ updateInfo: null,
18
+ updateChecking: false,
19
+ updateInstalling: false,
20
+ selectedAccountId: "",
21
+ chatFiles: []
22
+ };
23
+
24
+ const MAX_CHAT_FILES = 10;
25
+ const MAX_CHAT_FILE_BYTES = 100 * 1024 * 1024;
26
+ const DISMISSED_UPDATE_KEY = "codex-weixin.dismissed-update";
27
+ const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
28
+ const UPDATE_RECONNECT_TIMEOUT_MS = 90 * 1000;
29
+ let streamingRenderFrame = 0;
30
+
31
+ const els = {};
32
+
33
+ document.addEventListener("DOMContentLoaded", () => {
34
+ Object.assign(els, {
35
+ accountsList: document.querySelector("#accountsList"),
36
+ productVersion: document.querySelector("#productVersion"),
37
+ sessionsList: document.querySelector("#sessionsList"),
38
+ sessionAccountTabs: document.querySelector("#sessionAccountTabs"),
39
+ sessionListCount: document.querySelector("#sessionListCount"),
40
+ chatTitle: document.querySelector("#chatTitle"),
41
+ chatContext: document.querySelector("#chatContext"),
42
+ chatMessages: document.querySelector("#chatMessages"),
43
+ chatComposer: document.querySelector("#chatComposer"),
44
+ chatInput: document.querySelector("#chatInput"),
45
+ chatSendButton: document.querySelector("#chatSendButton"),
46
+ chatAttachButton: document.querySelector("#chatAttachButton"),
47
+ chatFileInput: document.querySelector("#chatFileInput"),
48
+ composerFiles: document.querySelector("#composerFiles"),
49
+ refreshMessagesButton: document.querySelector("#refreshMessagesButton"),
50
+ sessionRuntimeToolbar: document.querySelector("#sessionRuntimeToolbar"),
51
+ sessionModelInput: document.querySelector("#sessionModelInput"),
52
+ sessionEffortInput: document.querySelector("#sessionEffortInput"),
53
+ sessionStreamInput: document.querySelector("#sessionStreamInput"),
54
+ runningAccountMetric: document.querySelector("#runningAccountMetric"),
55
+ sessionMetric: document.querySelector("#sessionMetric"),
56
+ workspaceMetric: document.querySelector("#workspaceMetric"),
57
+ qrDialog: document.querySelector("#qrDialog"),
58
+ qrFrame: document.querySelector("#qrFrame"),
59
+ qrStatus: document.querySelector("#qrStatus"),
60
+ updateDialog: document.querySelector("#updateDialog"),
61
+ updateCurrentVersion: document.querySelector("#updateCurrentVersion"),
62
+ updateLatestVersion: document.querySelector("#updateLatestVersion"),
63
+ updateProgress: document.querySelector("#updateProgress"),
64
+ updateProgressTitle: document.querySelector("#updateProgressTitle"),
65
+ updateProgressDetail: document.querySelector("#updateProgressDetail"),
66
+ updateLaterButton: document.querySelector("#updateLaterButton"),
67
+ updateNowButton: document.querySelector("#updateNowButton"),
68
+ updateCheckButton: document.querySelector("#updateCheckButton"),
69
+ settingsVersionValue: document.querySelector("#settingsVersionValue"),
70
+ accountDialog: document.querySelector("#accountDialog"),
71
+ removeAccountDialog: document.querySelector("#removeAccountDialog"),
72
+ sessionDialog: document.querySelector("#sessionDialog")
73
+ });
74
+ bindEvents();
75
+ void bootstrap();
76
+ });
77
+
78
+ function bindEvents() {
79
+ document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
80
+ document.querySelectorAll("[data-close-dialog]").forEach((button) => button.addEventListener("click", () => closeDialog(button.dataset.closeDialog)));
81
+ document.querySelector("#addAccountButton").addEventListener("click", () => void beginLogin());
82
+ document.querySelector("#refreshQrButton").addEventListener("click", () => void beginLogin());
83
+ document.querySelector("#refreshAccountsButton").addEventListener("click", () => void refreshData(true));
84
+ document.querySelector("#newSessionButton").addEventListener("click", openNewSessionDialog);
85
+ document.querySelector("#settingsForm").addEventListener("submit", (event) => void saveSettings(event));
86
+ document.querySelector("#modelInput").addEventListener("change", () => renderEffortOptions(""));
87
+ els.sessionModelInput.addEventListener("change", () => void handleSessionModelChange());
88
+ els.sessionEffortInput.addEventListener("change", () => void saveSessionRuntimeSettings());
89
+ els.sessionStreamInput.addEventListener("change", () => void saveSessionRuntimeSettings());
90
+ document.querySelector("#accountForm").addEventListener("submit", (event) => void saveAccountRemark(event));
91
+ document.querySelector("#removeAccountForm").addEventListener("submit", (event) => void removeAccount(event));
92
+ document.querySelector("#sessionForm").addEventListener("submit", (event) => void saveSession(event));
93
+ document.querySelector("#sessionSenderInput").addEventListener("change", updateNewSessionDefaultTitle);
94
+ els.chatComposer.addEventListener("submit", (event) => void sendSessionMessage(event));
95
+ els.chatInput.addEventListener("input", updateComposerState);
96
+ els.chatInput.addEventListener("keydown", handleChatInputKeydown);
97
+ els.chatAttachButton.addEventListener("click", () => els.chatFileInput.click());
98
+ els.chatFileInput.addEventListener("change", handleChatFileSelection);
99
+ els.composerFiles.addEventListener("click", handleComposerFileAction);
100
+ els.refreshMessagesButton.addEventListener("click", () => void loadSelectedSessionMessages());
101
+ els.accountsList.addEventListener("click", (event) => void handleAccountAction(event));
102
+ els.sessionsList.addEventListener("click", (event) => void handleSessionAction(event));
103
+ els.sessionAccountTabs.addEventListener("click", handleSessionAccountTab);
104
+ els.qrDialog.addEventListener("close", stopLoginPoll);
105
+ els.updateLaterButton.addEventListener("click", dismissUpdate);
106
+ els.updateNowButton.addEventListener("click", () => void installUpdate());
107
+ els.updateCheckButton.addEventListener("click", () => void checkForUpdateNow());
108
+ els.updateDialog.addEventListener("cancel", (event) => {
109
+ event.preventDefault();
110
+ if (!state.updateInstalling) dismissUpdate();
111
+ });
112
+ window.addEventListener("hashchange", () => showView(location.hash.slice(1) || "accounts", false));
113
+ }
114
+
115
+ async function bootstrap() {
116
+ try {
117
+ const data = await api("/api/bootstrap", { token: false });
118
+ state.requestToken = data.requestToken;
119
+ state.version = data.version || "";
120
+ state.accounts = data.accounts;
121
+ state.sessions = data.sessions;
122
+ state.config = data.config;
123
+ state.codex = data.codex;
124
+ state.codexRuntime = data.codexRuntime;
125
+ state.codexModels = data.codexModels || [];
126
+ renderAll();
127
+ showView(location.hash.slice(1) || "accounts", false);
128
+ window.setInterval(() => void refreshData(false), 5000);
129
+ void checkForUpdate();
130
+ window.setInterval(() => void checkForUpdate(), UPDATE_CHECK_INTERVAL_MS);
131
+ } catch (error) {
132
+ toast(error.message, true);
133
+ els.accountsList.innerHTML = emptyState("server-off", "无法连接本机服务", "请重新启动 codex-weixin");
134
+ }
135
+ }
136
+
137
+ async function checkForUpdate() {
138
+ if (state.updateInstalling || state.updateChecking) return;
139
+ try {
140
+ const info = await api("/api/update", { token: false });
141
+ if (!info.updateAvailable || !info.latestVersion || dismissedUpdateVersion() === info.latestVersion) {
142
+ return;
143
+ }
144
+ showAvailableUpdate(info);
145
+ } catch {
146
+ // Update checks are best-effort and must not interrupt the local management page.
147
+ }
148
+ }
149
+
150
+ async function checkForUpdateNow() {
151
+ if (state.updateChecking || state.updateInstalling) return;
152
+ const label = els.updateCheckButton.querySelector("span");
153
+ state.updateChecking = true;
154
+ els.updateCheckButton.disabled = true;
155
+ els.updateCheckButton.classList.add("is-loading");
156
+ label.textContent = "检查中";
157
+ try {
158
+ const info = await api("/api/update?force=1");
159
+ if (info.error) throw new Error(info.error);
160
+ if (info.updateAvailable && info.latestVersion) {
161
+ showAvailableUpdate(info);
162
+ return;
163
+ }
164
+ const current = String(info.currentVersion || state.version || "").replace(/^v/i, "");
165
+ toast(current ? `已是最新版本 v${current}` : "已是最新版本");
166
+ } catch (error) {
167
+ toast(error.message || "无法检查新版本", true);
168
+ } finally {
169
+ state.updateChecking = false;
170
+ els.updateCheckButton.disabled = false;
171
+ els.updateCheckButton.classList.remove("is-loading");
172
+ label.textContent = "检查更新";
173
+ drawIcons();
174
+ }
175
+ }
176
+
177
+ function showAvailableUpdate(info) {
178
+ state.updateInfo = info;
179
+ els.updateCurrentVersion.textContent = `v${String(info.currentVersion).replace(/^v/i, "")}`;
180
+ els.updateLatestVersion.textContent = `v${String(info.latestVersion).replace(/^v/i, "")}`;
181
+ resetUpdateDialog();
182
+ if (!els.updateDialog.open) els.updateDialog.showModal();
183
+ drawIcons();
184
+ }
185
+
186
+ function dismissUpdate() {
187
+ if (state.updateInstalling) return;
188
+ const version = state.updateInfo?.latestVersion;
189
+ if (version) {
190
+ try {
191
+ localStorage.setItem(DISMISSED_UPDATE_KEY, version);
192
+ } catch {
193
+ // Dismissing still works when browser storage is unavailable.
194
+ }
195
+ }
196
+ state.updateInfo = null;
197
+ if (els.updateDialog.open) els.updateDialog.close();
198
+ }
199
+
200
+ async function installUpdate() {
201
+ if (state.updateInstalling || !state.updateInfo?.latestVersion) return;
202
+ const previousToken = state.requestToken;
203
+ state.updateInstalling = true;
204
+ els.updateLaterButton.disabled = true;
205
+ els.updateNowButton.disabled = true;
206
+ els.updateNowButton.querySelector("span").textContent = "更新中";
207
+ setUpdateProgress(
208
+ "正在安装更新",
209
+ `正在连接${updateRegistryName(state.updateInfo.registry)},微信服务将继续运行`
210
+ );
211
+ try {
212
+ const result = await api("/api/update", { method: "POST" });
213
+ const targetVersion = result.version;
214
+ state.updateInfo = { ...state.updateInfo, latestVersion: targetVersion, registry: result.registry };
215
+ els.updateLatestVersion.textContent = `v${String(targetVersion).replace(/^v/i, "")}`;
216
+ if (!result.restarting) {
217
+ throw new Error("更新已安装,但自动重启未启动,请手动重启 codex-weixin");
218
+ }
219
+ setUpdateProgress(
220
+ "正在重启服务",
221
+ `已通过${updateRegistryName(result.registry)}完成安装,正在恢复微信连接`
222
+ );
223
+ await waitForUpdatedService(targetVersion, previousToken);
224
+ setUpdateProgress("更新完成", "新版本已启动,正在刷新页面");
225
+ window.location.reload();
226
+ } catch (error) {
227
+ state.updateInstalling = false;
228
+ els.updateLaterButton.disabled = false;
229
+ els.updateNowButton.disabled = false;
230
+ els.updateNowButton.querySelector("span").textContent = "重试更新";
231
+ setUpdateProgress("更新未完成", error.message || "请稍后重试", true);
232
+ }
233
+ }
234
+
235
+ async function waitForUpdatedService(targetVersion, previousToken) {
236
+ const deadline = Date.now() + UPDATE_RECONNECT_TIMEOUT_MS;
237
+ while (Date.now() < deadline) {
238
+ try {
239
+ const response = await fetch("/api/bootstrap", { cache: "no-store" });
240
+ const data = await response.json().catch(() => ({}));
241
+ if (
242
+ response.ok
243
+ && data.version === targetVersion
244
+ && data.requestToken
245
+ && data.requestToken !== previousToken
246
+ ) {
247
+ state.requestToken = data.requestToken;
248
+ state.version = data.version;
249
+ return;
250
+ }
251
+ } catch {
252
+ // The service is expected to be briefly unavailable while it restarts.
253
+ }
254
+ await delay(900);
255
+ }
256
+ throw new Error("新版本已安装,但服务未能自动恢复,请手动重启 codex-weixin");
257
+ }
258
+
259
+ function resetUpdateDialog() {
260
+ state.updateInstalling = false;
261
+ els.updateProgress.hidden = true;
262
+ els.updateProgress.classList.remove("is-error");
263
+ els.updateLaterButton.disabled = false;
264
+ els.updateNowButton.disabled = false;
265
+ els.updateNowButton.querySelector("span").textContent = "立即更新";
266
+ }
267
+
268
+ function setUpdateProgress(title, detail, error = false) {
269
+ els.updateProgress.hidden = false;
270
+ els.updateProgress.classList.toggle("is-error", error);
271
+ els.updateProgressTitle.textContent = title;
272
+ els.updateProgressDetail.textContent = detail;
273
+ }
274
+
275
+ function updateRegistryName(registry) {
276
+ return registry === "npmmirror" ? "国内镜像" : "npm 官方源";
277
+ }
278
+
279
+ function dismissedUpdateVersion() {
280
+ try {
281
+ return localStorage.getItem(DISMISSED_UPDATE_KEY) || "";
282
+ } catch {
283
+ return "";
284
+ }
285
+ }
286
+
287
+ function delay(ms) {
288
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
289
+ }
290
+
291
+ async function refreshData(notify) {
292
+ try {
293
+ const previousSession = selectedSession();
294
+ const [accounts, sessions] = await Promise.all([api("/api/accounts"), api("/api/sessions")]);
295
+ state.accounts = accounts.accounts;
296
+ state.sessions = sessions.sessions;
297
+ renderMetrics();
298
+ renderAccounts();
299
+ renderSessions();
300
+ drawIcons();
301
+ const currentSession = selectedSession();
302
+ if (
303
+ previousSession
304
+ && currentSession
305
+ && sessionKey(previousSession) === sessionKey(currentSession)
306
+ && previousSession.updatedAt !== currentSession.updatedAt
307
+ && state.loadedSessionKey === state.selectedSessionKey
308
+ && !state.sendingMessage
309
+ ) {
310
+ void loadSelectedSessionMessages();
311
+ } else if (previousSession?.responding !== currentSession?.responding) {
312
+ window.requestAnimationFrame(scrollChatToEnd);
313
+ }
314
+ if (notify) toast("状态已刷新");
315
+ } catch (error) {
316
+ if (notify) toast(error.message, true);
317
+ }
318
+ }
319
+
320
+ function renderAll() {
321
+ renderProductVersion();
322
+ renderMetrics();
323
+ renderAccounts();
324
+ renderSessions();
325
+ renderSettings();
326
+ drawIcons();
327
+ }
328
+
329
+ function renderProductVersion() {
330
+ const version = state.version.trim();
331
+ els.productVersion.hidden = !version;
332
+ els.productVersion.textContent = version ? `v${version.replace(/^v/i, "")}` : "";
333
+ els.settingsVersionValue.textContent = version ? `v${version.replace(/^v/i, "")}` : "--";
334
+ }
335
+
336
+ function renderMetrics() {
337
+ els.runningAccountMetric.textContent = String(state.accounts.filter((account) => account.status === "running").length);
338
+ els.sessionMetric.textContent = String(state.sessions.length);
339
+ els.workspaceMetric.textContent = state.config?.defaultCwd || "--";
340
+ els.workspaceMetric.title = state.config?.defaultCwd || "";
341
+ const serviceText = document.querySelector("#serviceStateText");
342
+ const serviceDot = document.querySelector("#serviceDot");
343
+ if (state.codex?.ready) {
344
+ serviceText.textContent = state.codex.version || "Codex 已就绪";
345
+ serviceDot.classList.remove("is-error");
346
+ } else {
347
+ serviceText.textContent = "未检测到 Codex CLI";
348
+ serviceDot.classList.add("is-error");
349
+ }
350
+ }
351
+
352
+ function renderAccounts() {
353
+ const expandedAccountIds = new Set(
354
+ [...els.accountsList.querySelectorAll(".account-identifiers[open]")]
355
+ .map((details) => details.dataset.accountId)
356
+ .filter(Boolean)
357
+ );
358
+ if (!state.accounts.length) {
359
+ els.accountsList.innerHTML = emptyState("scan-line", "还没有微信账号", "", `<button class="button button-primary" type="button" data-account-action="add"><i data-lucide="scan-line"></i><span>添加微信</span></button>`);
360
+ drawIcons();
361
+ return;
362
+ }
363
+ els.accountsList.innerHTML = state.accounts.map((account) => {
364
+ const pendingSender = account.lastActiveSenderId && !account.pairedSenderIds.includes(account.lastActiveSenderId)
365
+ ? account.lastActiveSenderId : "";
366
+ const authorized = account.pairedSenderIds.length > 0;
367
+ return `<article class="account-card">
368
+ <div class="account-main">
369
+ <div class="account-identity">
370
+ <span class="account-avatar"><i data-lucide="message-circle"></i></span>
371
+ <div class="account-name">
372
+ <strong>${escapeHtml(accountDisplayName(account.accountId))}</strong>
373
+ <div class="account-description">个人微信接入</div>
374
+ </div>
375
+ </div>
376
+ <div class="account-stat"><span>状态</span><strong class="status-label status-${escapeAttr(account.status)}">${statusText(account.status)}</strong></div>
377
+ <div class="account-stat"><span>会话</span><strong>${account.sessionCount}</strong></div>
378
+ <div class="account-actions">
379
+ <button class="icon-button" type="button" data-account-action="rename" data-account-id="${escapeAttr(account.accountId)}" title="修改账号备注" aria-label="修改账号备注"><i data-lucide="pencil"></i></button>
380
+ <button class="icon-button" type="button" data-account-action="${account.status === "running" ? "stop" : "start"}" data-account-id="${escapeAttr(account.accountId)}" title="${account.status === "running" ? "停止账号" : "启动账号"}" aria-label="${account.status === "running" ? "停止账号" : "启动账号"}"><i data-lucide="${account.status === "running" ? "pause" : "play"}"></i></button>
381
+ <button class="icon-button is-danger" type="button" data-account-action="remove" data-account-id="${escapeAttr(account.accountId)}" title="移除账号" aria-label="移除账号"><i data-lucide="trash-2"></i></button>
382
+ </div>
383
+ </div>
384
+ <details class="account-identifiers" data-account-id="${escapeAttr(account.accountId)}"${expandedAccountIds.has(account.accountId) ? " open" : ""}>
385
+ <summary>
386
+ <span class="account-identifiers-title"><i data-lucide="fingerprint"></i><strong>账号 ID</strong><small>Bot ID 与 User ID</small></span>
387
+ <i class="account-identifiers-chevron" data-lucide="chevron-down"></i>
388
+ </summary>
389
+ <dl class="account-identifiers-grid">
390
+ <div><dt>Bot ID</dt><dd><code title="${escapeAttr(account.botId || account.accountId)}">${escapeHtml(account.botId || account.accountId)}</code></dd></div>
391
+ <div><dt>User ID</dt><dd><code title="${escapeAttr(account.userId || "未返回")}">${escapeHtml(account.userId || "未返回")}</code></dd></div>
392
+ </dl>
393
+ </details>
394
+ <div class="account-detail">${renderAuthorizationState(account, pendingSender)}</div>
395
+ ${authorized && pendingSender ? `<div class="pending-access"><div><strong>新的微信访问请求</strong><span>当前授权不受影响,可选择允许新的访问</span></div><button class="button button-secondary" type="button" data-account-action="allow" data-account-id="${escapeAttr(account.accountId)}" data-sender-id="${escapeAttr(pendingSender)}"><i data-lucide="user-check"></i><span>允许访问</span></button></div>` : ""}
396
+ ${account.error ? `<div class="pending-access"><div><strong>账号运行错误</strong><span>${escapeHtml(account.error)}</span></div></div>` : ""}
397
+ </article>`;
398
+ }).join("");
399
+ }
400
+
401
+ function renderAuthorizationState(account, pendingSender) {
402
+ const accountId = escapeAttr(account.accountId);
403
+ if (account.pairedSenderIds.length) {
404
+ return `<div class="authorization-state is-authorized" aria-label="授权状态:已授权">
405
+ <span class="authorization-icon"><i data-lucide="shield-check"></i></span>
406
+ <div class="authorization-copy"><strong>已授权</strong><span>可以从微信控制 Codex</span></div>
407
+ <button class="button button-secondary authorization-action" type="button" data-account-action="revoke-all" data-account-id="${accountId}"><i data-lucide="shield-x"></i><span>撤销授权</span></button>
408
+ </div>`;
409
+ }
410
+ if (pendingSender) {
411
+ return `<div class="authorization-state is-pending" aria-label="授权状态:待授权">
412
+ <span class="authorization-icon"><i data-lucide="shield-alert"></i></span>
413
+ <div class="authorization-copy"><strong>待授权</strong><span>检测到新的微信访问请求</span></div>
414
+ <button class="button button-primary authorization-action" type="button" data-account-action="allow" data-account-id="${accountId}" data-sender-id="${escapeAttr(pendingSender)}"><i data-lucide="user-check"></i><span>允许访问</span></button>
415
+ </div>`;
416
+ }
417
+ return `<div class="authorization-state is-unauthorized" aria-label="授权状态:未授权">
418
+ <span class="authorization-icon"><i data-lucide="shield"></i></span>
419
+ <div class="authorization-copy"><strong>未授权</strong><span>请先从微信向此账号发送一条消息</span></div>
420
+ </div>`;
421
+ }
422
+
423
+ function renderSessions() {
424
+ if (!state.sessions.length) {
425
+ els.sessionListCount.textContent = "0";
426
+ els.sessionAccountTabs.innerHTML = "";
427
+ els.sessionsList.innerHTML = emptyState("messages-square", "还没有受管会话", "", `<button class="button button-secondary" type="button" data-session-action="new"><i data-lucide="plus"></i><span>新建会话</span></button>`);
428
+ state.selectedSessionKey = "";
429
+ state.sessionMessages = [];
430
+ state.loadedSessionKey = "";
431
+ renderChatPanel();
432
+ drawIcons();
433
+ return;
434
+ }
435
+ const accountIds = [...new Set([
436
+ ...state.accounts.map((account) => account.accountId),
437
+ ...state.sessions.map((session) => session.accountId)
438
+ ])].filter((accountId) => state.sessions.some((session) => session.accountId === accountId));
439
+ if (!accountIds.includes(state.selectedAccountId)) {
440
+ const selected = selectedSession();
441
+ state.selectedAccountId = selected && accountIds.includes(selected.accountId) ? selected.accountId : accountIds[0];
442
+ }
443
+ const visibleSessions = state.sessions.filter((session) => session.accountId === state.selectedAccountId);
444
+ els.sessionListCount.textContent = String(visibleSessions.length);
445
+ els.sessionAccountTabs.innerHTML = accountIds.map((accountId) => {
446
+ const active = accountId === state.selectedAccountId;
447
+ const count = state.sessions.filter((session) => session.accountId === accountId).length;
448
+ return `<button class="session-account-tab${active ? " is-active" : ""}" type="button" data-session-account="${escapeAttr(accountId)}" aria-pressed="${active}"><i data-lucide="message-circle"></i><span>${escapeHtml(accountDisplayName(accountId))}</span><b>${count}</b></button>`;
449
+ }).join("");
450
+ let shouldLoad = false;
451
+ if (!visibleSessions.some((session) => sessionKey(session) === state.selectedSessionKey)) {
452
+ state.selectedSessionKey = sessionKey(visibleSessions[0]);
453
+ state.sessionMessages = [];
454
+ state.loadedSessionKey = "";
455
+ shouldLoad = true;
456
+ }
457
+ els.sessionsList.innerHTML = visibleSessions.map((session) => {
458
+ const selected = sessionKey(session) === state.selectedSessionKey;
459
+ return `<article class="session-card${selected ? " is-selected" : ""}">
460
+ <button class="session-open" type="button" data-session-action="open" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" aria-pressed="${selected}">
461
+ <span class="session-card-top"><strong>${session.active ? `<span class="active-mark" title="微信当前会话"></span>` : ""}${escapeHtml(session.title)}</strong><time datetime="${escapeAttr(session.updatedAt)}">${escapeHtml(relativeTime(session.updatedAt))}</time></span>
462
+ <span class="session-owner"><strong>${escapeHtml(accountDisplayName(session.accountId))}</strong></span>
463
+ <span class="session-workspace" title="${escapeAttr(session.workspace)}">${escapeHtml(session.workspace)}</span>
464
+ <span class="session-thread${session.responding ? " is-responding" : ""}">${session.responding ? "对方正在输入…" : session.threadId ? "已连接 Codex" : "等待首条消息"}</span>
465
+ </button>
466
+ <div class="session-actions">
467
+ <button class="icon-button" type="button" data-session-action="activate" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" ${session.active ? "disabled" : ""} title="切换为微信当前会话" aria-label="切换为微信当前会话"><i data-lucide="circle-play"></i></button>
468
+ <button class="icon-button" type="button" data-session-action="rename" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" title="重命名会话" aria-label="重命名会话"><i data-lucide="pencil"></i></button>
469
+ <button class="icon-button" type="button" data-session-action="reset" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" ${session.threadId ? "" : "disabled"} title="重置 Codex 上下文" aria-label="重置 Codex 上下文"><i data-lucide="rotate-ccw"></i></button>
470
+ <button class="icon-button is-danger" type="button" data-session-action="delete" data-account-id="${escapeAttr(session.accountId)}" data-session-id="${escapeAttr(session.id)}" title="删除受管会话" aria-label="删除受管会话"><i data-lucide="trash-2"></i></button>
471
+ </div>
472
+ </article>`;
473
+ }).join("");
474
+ renderChatPanel();
475
+ drawIcons();
476
+ if (shouldLoad) void loadSelectedSessionMessages();
477
+ }
478
+
479
+ function renderSettings() {
480
+ if (!state.config) return;
481
+ document.querySelector("#defaultCwdInput").value = state.config.defaultCwd || "";
482
+ document.querySelector("#allowedWorkspacesInput").value = (state.config.allowedWorkspaces || []).join("\n");
483
+ document.querySelector("#backendInput").value = state.config.codexBackend || "auto";
484
+ document.querySelector("#sandboxInput").value = state.config.codexExecSandbox || "";
485
+ document.querySelector("#streamRepliesInput").checked = Boolean(state.config.streamReplies);
486
+ renderModelOptions();
487
+ document.querySelector("#effectiveModelValue").textContent = state.codexRuntime?.model || state.config.model || "Codex 默认";
488
+ document.querySelector("#effectiveEffortValue").textContent = state.codexRuntime?.effort || state.config.effort || "Codex 默认";
489
+ }
490
+
491
+ function renderModelOptions() {
492
+ const select = document.querySelector("#modelInput");
493
+ const configuredModel = state.config?.model || "";
494
+ const effectiveModel = state.codexRuntime?.model || "";
495
+ const models = Array.isArray(state.codexModels) ? state.codexModels : [];
496
+ const options = [{
497
+ value: "",
498
+ label: effectiveModel ? `沿用 Codex 设置(当前:${effectiveModel})` : "沿用 Codex 设置"
499
+ }, ...models.map((model) => ({
500
+ value: model.model,
501
+ label: model.displayName && model.displayName !== model.model
502
+ ? `${model.displayName} · ${model.model}`
503
+ : model.model,
504
+ title: model.description || ""
505
+ }))];
506
+ if (configuredModel && !options.some((option) => option.value === configuredModel)) {
507
+ options.push({ value: configuredModel, label: `${configuredModel}(当前配置)` });
508
+ }
509
+ select.innerHTML = options.map((option) => `<option value="${escapeAttr(option.value)}"${option.title ? ` title="${escapeAttr(option.title)}"` : ""}>${escapeHtml(option.label)}</option>`).join("");
510
+ select.value = configuredModel;
511
+ renderEffortOptions(state.config?.effort || "");
512
+ }
513
+
514
+ function renderEffortOptions(preferredEffort) {
515
+ const modelValue = document.querySelector("#modelInput").value;
516
+ const effectiveModel = modelValue || state.codexRuntime?.model || "";
517
+ const model = state.codexModels.find((candidate) => candidate.model === effectiveModel);
518
+ const allEfforts = model?.supportedEfforts?.length
519
+ ? model.supportedEfforts
520
+ : state.codexModels.flatMap((candidate) => candidate.supportedEfforts || []);
521
+ const efforts = [...new Map(allEfforts.map((option) => [option.effort, option])).values()]
522
+ .sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
523
+ if (preferredEffort && !efforts.some((option) => option.effort === preferredEffort)) {
524
+ efforts.push({ effort: preferredEffort, description: "当前配置" });
525
+ }
526
+ const inheritedEffort = state.codexRuntime?.effort || model?.defaultEffort;
527
+ const inheritedLabel = inheritedEffort
528
+ ? `沿用 Codex 设置(当前:${effortInlineName(inheritedEffort)})`
529
+ : "沿用 Codex 设置";
530
+ const select = document.querySelector("#effortInput");
531
+ select.innerHTML = [
532
+ `<option value="">${escapeHtml(inheritedLabel)}</option>`,
533
+ ...efforts.map((option) => `<option value="${escapeAttr(option.effort)}"${option.description ? ` title="${escapeAttr(option.description)}"` : ""}>${escapeHtml(effortDisplayName(option.effort))}</option>`)
534
+ ].join("");
535
+ select.value = preferredEffort;
536
+ }
537
+
538
+ function effortDisplayName(effort) {
539
+ const label = ({ minimal: "最小", low: "低", medium: "中", high: "高", xhigh: "超高", max: "最大", ultra: "极高" })[effort];
540
+ return label ? `${label}(${effort})` : effort;
541
+ }
542
+
543
+ function effortInlineName(effort) {
544
+ const label = ({ minimal: "最小", low: "低", medium: "中", high: "高", xhigh: "超高", max: "最大", ultra: "极高" })[effort];
545
+ return label ? `${label} · ${effort}` : effort;
546
+ }
547
+
548
+ function effortRank(effort) {
549
+ const index = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"].indexOf(effort);
550
+ return index < 0 ? Number.MAX_SAFE_INTEGER : index;
551
+ }
552
+
553
+ async function handleAccountAction(event) {
554
+ const button = event.target.closest("[data-account-action]");
555
+ if (!button) return;
556
+ const action = button.dataset.accountAction;
557
+ if (action === "add") return beginLogin();
558
+ const accountId = button.dataset.accountId;
559
+ const account = state.accounts.find((item) => item.accountId === accountId);
560
+ if (action === "rename") {
561
+ if (account) openAccountRemarkDialog(account);
562
+ return;
563
+ }
564
+ if (action === "remove") {
565
+ if (account) openRemoveAccountDialog(account);
566
+ return;
567
+ }
568
+ try {
569
+ button.disabled = true;
570
+ if (action === "start" || action === "stop") await api(`/api/accounts/${encodeURIComponent(accountId)}/${action}`, { method: "POST" });
571
+ if (action === "allow") {
572
+ await api(`/api/accounts/${encodeURIComponent(accountId)}/senders/${encodeURIComponent(button.dataset.senderId)}/allow`, { method: "POST" });
573
+ }
574
+ if (action === "revoke-all" && account) {
575
+ if (!window.confirm("撤销此微信账号的全部控制授权?撤销后需要重新允许才能继续使用。")) return;
576
+ await Promise.all(account.pairedSenderIds.map((senderId) => api(
577
+ `/api/accounts/${encodeURIComponent(accountId)}/senders/${encodeURIComponent(senderId)}/remove`,
578
+ { method: "POST" }
579
+ )));
580
+ }
581
+ await refreshData(false);
582
+ } catch (error) {
583
+ toast(error.message, true);
584
+ } finally {
585
+ button.disabled = false;
586
+ }
587
+ }
588
+
589
+ function openAccountRemarkDialog(account) {
590
+ document.querySelector("#editingRemarkAccountId").value = account.accountId;
591
+ document.querySelector("#accountRemarkInput").value = account.displayName || "";
592
+ els.accountDialog.showModal();
593
+ document.querySelector("#accountRemarkInput").select();
594
+ }
595
+
596
+ function openRemoveAccountDialog(account) {
597
+ document.querySelector("#removingAccountId").value = account.accountId;
598
+ document.querySelector("#removingAccountName").textContent = accountDisplayName(account.accountId);
599
+ document.querySelector('input[name="retainHistory"][value="true"]').checked = true;
600
+ els.removeAccountDialog.showModal();
601
+ drawIcons();
602
+ }
603
+
604
+ async function removeAccount(event) {
605
+ event.preventDefault();
606
+ const button = event.submitter;
607
+ const accountId = document.querySelector("#removingAccountId").value;
608
+ const retainHistory = document.querySelector('input[name="retainHistory"]:checked')?.value === "true";
609
+ try {
610
+ button.disabled = true;
611
+ await api(`/api/accounts/${encodeURIComponent(accountId)}`, {
612
+ method: "DELETE",
613
+ body: { retainHistory }
614
+ });
615
+ els.removeAccountDialog.close();
616
+ await refreshData(false);
617
+ toast(retainHistory ? "账号已移除,重新扫码后将恢复会话历史" : "账号和会话历史已删除");
618
+ } catch (error) {
619
+ toast(error.message, true);
620
+ } finally {
621
+ button.disabled = false;
622
+ }
623
+ }
624
+
625
+ async function saveAccountRemark(event) {
626
+ event.preventDefault();
627
+ const button = event.submitter;
628
+ const accountId = document.querySelector("#editingRemarkAccountId").value;
629
+ const displayName = document.querySelector("#accountRemarkInput").value.trim();
630
+ try {
631
+ button.disabled = true;
632
+ await api(`/api/accounts/${encodeURIComponent(accountId)}`, {
633
+ method: "PATCH",
634
+ body: { displayName }
635
+ });
636
+ els.accountDialog.close();
637
+ await refreshData(false);
638
+ toast(displayName ? "账号备注已保存" : "账号备注已清除");
639
+ } catch (error) {
640
+ toast(error.message, true);
641
+ } finally {
642
+ button.disabled = false;
643
+ }
644
+ }
645
+
646
+ async function handleSessionAction(event) {
647
+ const button = event.target.closest("[data-session-action]");
648
+ if (!button) return;
649
+ const action = button.dataset.sessionAction;
650
+ if (action === "new") return openNewSessionDialog();
651
+ const accountId = button.dataset.accountId;
652
+ const sessionId = button.dataset.sessionId;
653
+ const session = state.sessions.find((item) => item.accountId === accountId && item.id === sessionId);
654
+ if (!session) return;
655
+ if (action === "open") return selectSession(session);
656
+ if (action === "rename") return openRenameSessionDialog(session);
657
+ try {
658
+ button.disabled = true;
659
+ if (action === "activate" || action === "reset") {
660
+ if (action === "reset" && !window.confirm("重置此会话的 Codex 上下文?下一条微信或 Web 消息会创建新的 thread。")) return;
661
+ await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}/${action}`, { method: "POST" });
662
+ }
663
+ if (action === "delete") {
664
+ if (!window.confirm("删除此受管会话?Codex 自身保存的历史文件不会被删除。")) return;
665
+ await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
666
+ }
667
+ await refreshData(false);
668
+ } catch (error) {
669
+ toast(error.message, true);
670
+ } finally {
671
+ button.disabled = false;
672
+ }
673
+ }
674
+
675
+ function handleSessionAccountTab(event) {
676
+ const button = event.target.closest("[data-session-account]");
677
+ if (!button || button.dataset.sessionAccount === state.selectedAccountId) return;
678
+ state.selectedAccountId = button.dataset.sessionAccount;
679
+ state.selectedSessionKey = "";
680
+ state.sessionMessages = [];
681
+ state.loadedSessionKey = "";
682
+ resetComposer();
683
+ renderSessions();
684
+ }
685
+
686
+ function selectSession(session) {
687
+ const key = sessionKey(session);
688
+ if (key === state.selectedSessionKey && state.loadedSessionKey === key) {
689
+ return;
690
+ }
691
+ state.selectedSessionKey = key;
692
+ state.sessionMessages = [];
693
+ state.loadedSessionKey = "";
694
+ state.loadingMessages = true;
695
+ resetComposer();
696
+ renderSessions();
697
+ void loadSelectedSessionMessages();
698
+ }
699
+
700
+ async function loadSelectedSessionMessages() {
701
+ const session = selectedSession();
702
+ if (!session) {
703
+ renderChatPanel();
704
+ return;
705
+ }
706
+ const key = sessionKey(session);
707
+ state.loadingMessages = true;
708
+ renderChatPanel();
709
+ try {
710
+ const result = await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`);
711
+ if (state.selectedSessionKey !== key) return;
712
+ state.sessionMessages = result.messages || [];
713
+ state.loadedSessionKey = key;
714
+ } catch (error) {
715
+ if (state.selectedSessionKey !== key) return;
716
+ state.sessionMessages = [];
717
+ state.loadedSessionKey = key;
718
+ toast(error.message, true);
719
+ } finally {
720
+ if (state.selectedSessionKey === key) {
721
+ state.loadingMessages = false;
722
+ renderChatPanel();
723
+ scrollChatToEnd();
724
+ }
725
+ }
726
+ }
727
+
728
+ function renderChatPanel() {
729
+ const session = selectedSession();
730
+ const enabled = Boolean(session) && !state.sendingMessage && !state.savingSessionRuntime;
731
+ els.chatInput.disabled = !enabled;
732
+ els.chatAttachButton.disabled = !enabled;
733
+ els.chatFileInput.disabled = !enabled;
734
+ els.refreshMessagesButton.disabled = !session || state.loadingMessages || state.sendingMessage;
735
+ renderComposerFiles();
736
+ updateComposerState();
737
+ renderSessionRuntimeControls(session);
738
+ if (!session) {
739
+ els.chatTitle.textContent = "选择一个会话";
740
+ els.chatContext.textContent = "查看历史消息并继续聊天";
741
+ setChatMessagesHtml(emptyChatState("messages-square", "从左侧选择会话"), "no-session");
742
+ return;
743
+ }
744
+
745
+ els.chatTitle.textContent = session.title;
746
+ els.chatContext.textContent = `${accountDisplayName(session.accountId)} · ${session.workspace}`;
747
+ const responding = Boolean(session.responding || state.sendingMessage);
748
+ if (state.loadingMessages) {
749
+ setChatMessagesHtml(
750
+ `<div class="chat-loading"><div class="spinner" aria-label="正在加载历史消息"></div><span>正在读取 Codex 历史</span></div>`,
751
+ `loading:${sessionKey(session)}`
752
+ );
753
+ return;
754
+ }
755
+ if (!state.sessionMessages.length) {
756
+ setChatMessagesHtml(
757
+ responding
758
+ ? renderTypingIndicator()
759
+ : emptyChatState("message-circle", session.threadId ? "这个 thread 暂无可显示消息" : "发送第一条消息开始会话", session.threadId ? "" : "历史会在 Codex 创建 thread 后显示"),
760
+ `empty:${sessionKey(session)}:${session.threadId || "new"}:${responding}`
761
+ );
762
+ return;
763
+ }
764
+ const renderKey = `messages:${sessionKey(session)}:${responding}:${JSON.stringify(state.sessionMessages)}`;
765
+ const html = renderConversationMessages(state.sessionMessages, responding) + (responding ? renderTypingIndicator() : "");
766
+ setChatMessagesHtml(html, renderKey);
767
+ }
768
+
769
+ function renderConversationMessages(messages, responding) {
770
+ const html = [];
771
+ let lastUserCreatedAt;
772
+ let index = 0;
773
+ while (index < messages.length) {
774
+ const message = messages[index];
775
+ if (message.kind === "progress") {
776
+ const progress = [];
777
+ while (index < messages.length && messages[index].kind === "progress") {
778
+ progress.push(messages[index]);
779
+ index += 1;
780
+ }
781
+ const nextMessage = messages[index];
782
+ const active = responding && !nextMessage;
783
+ const completedAt = nextMessage?.role === "assistant"
784
+ ? nextMessage.createdAt
785
+ : progress.at(-1)?.createdAt;
786
+ html.push(renderProgressGroup(progress, lastUserCreatedAt, completedAt, active));
787
+ continue;
788
+ }
789
+ if (message.role === "user") lastUserCreatedAt = message.createdAt;
790
+ html.push(renderChatMessage(message));
791
+ index += 1;
792
+ }
793
+ return html.join("");
794
+ }
795
+
796
+ function renderChatMessage(message) {
797
+ return `<article class="chat-message is-${escapeAttr(message.role)}${message.attachments?.length ? " has-attachments" : ""}">
798
+ <div class="message-meta"><span>${message.role === "user" ? "你" : "Codex"}</span>${message.createdAt ? `<time datetime="${escapeAttr(message.createdAt)}">${escapeHtml(messageTime(message.createdAt))}</time>` : ""}</div>
799
+ <div class="message-bubble">${message.text ? renderMarkdown(message.text) : ""}${renderMessageAttachments(message.attachments)}</div>
800
+ </article>`;
801
+ }
802
+
803
+ function renderProgressGroup(messages, startedAt, completedAt, active) {
804
+ const duration = formatProcessingDuration(startedAt, active ? new Date().toISOString() : completedAt);
805
+ return `<details class="chat-progress-group"${active ? " open" : ""}>
806
+ <summary>
807
+ <span class="progress-summary-title"><i data-lucide="chevron-right"></i>处理过程</span>
808
+ <span>${active ? "已处理" : "处理用时"} ${escapeHtml(duration)}</span>
809
+ </summary>
810
+ <ol class="progress-list">${messages.map((message) => `<li>${renderMarkdown(message.text)}</li>`).join("")}</ol>
811
+ </details>`;
812
+ }
813
+
814
+ function formatProcessingDuration(startValue, endValue) {
815
+ const start = new Date(startValue || "").getTime();
816
+ const end = new Date(endValue || "").getTime();
817
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--";
818
+ const seconds = Math.max(0, Math.round((end - start) / 1000));
819
+ if (seconds < 60) return `${seconds} 秒`;
820
+ const minutes = Math.floor(seconds / 60);
821
+ const remaining = seconds % 60;
822
+ return remaining ? `${minutes} 分 ${remaining} 秒` : `${minutes} 分钟`;
823
+ }
824
+
825
+ function renderSessionRuntimeControls(session) {
826
+ const disabled = !session || state.sendingMessage || state.savingSessionRuntime;
827
+ els.sessionModelInput.disabled = disabled;
828
+ els.sessionEffortInput.disabled = disabled;
829
+ els.sessionStreamInput.disabled = disabled;
830
+ const renderKey = session ? [
831
+ sessionKey(session),
832
+ session.model || "",
833
+ session.effort || "",
834
+ typeof session.streamReplies === "boolean" ? String(session.streamReplies) : "inherit",
835
+ state.config?.model || "",
836
+ state.config?.effort || "",
837
+ String(Boolean(state.config?.streamReplies)),
838
+ state.codexRuntime?.model || "",
839
+ state.codexRuntime?.effort || "",
840
+ state.codexModels.length
841
+ ].join("|") : "none";
842
+ if (els.sessionRuntimeToolbar.dataset.renderKey === renderKey) return;
843
+ els.sessionRuntimeToolbar.dataset.renderKey = renderKey;
844
+
845
+ if (!session) {
846
+ els.sessionModelInput.innerHTML = '<option value="">选择会话后设置</option>';
847
+ els.sessionEffortInput.innerHTML = '<option value="">选择会话后设置</option>';
848
+ els.sessionStreamInput.innerHTML = '<option value="">选择会话后设置</option>';
849
+ return;
850
+ }
851
+
852
+ const inheritedModel = state.config?.model || state.codexRuntime?.model || "";
853
+ const models = Array.isArray(state.codexModels) ? state.codexModels : [];
854
+ const options = [{
855
+ value: "",
856
+ label: inheritedModel ? `继承全局(${inheritedModel})` : "继承全局设置"
857
+ }, ...models.map((model) => ({
858
+ value: model.model,
859
+ label: model.displayName && model.displayName !== model.model
860
+ ? `${model.displayName} · ${model.model}`
861
+ : model.model,
862
+ title: model.description || ""
863
+ }))];
864
+ if (session.model && !options.some((option) => option.value === session.model)) {
865
+ options.push({ value: session.model, label: `${session.model}(当前会话)`, title: "" });
866
+ }
867
+ els.sessionModelInput.innerHTML = options.map((option) => `<option value="${escapeAttr(option.value)}"${option.title ? ` title="${escapeAttr(option.title)}"` : ""}>${escapeHtml(option.label)}</option>`).join("");
868
+ els.sessionModelInput.value = session.model || "";
869
+ setSessionEffortOptions(session.model || "", session.effort || "");
870
+ els.sessionStreamInput.innerHTML = [
871
+ `<option value="">继承全局(${state.config?.streamReplies ? "开启" : "关闭"})</option>`,
872
+ '<option value="on">开启</option>',
873
+ '<option value="off">关闭</option>'
874
+ ].join("");
875
+ els.sessionStreamInput.value = typeof session.streamReplies === "boolean"
876
+ ? session.streamReplies ? "on" : "off"
877
+ : "";
878
+ }
879
+
880
+ function setSessionEffortOptions(modelOverride, preferredEffort) {
881
+ const effectiveModel = modelOverride || state.config?.model || state.codexRuntime?.model || "";
882
+ const model = state.codexModels.find((candidate) => candidate.model === effectiveModel);
883
+ const advertised = model?.supportedEfforts?.length
884
+ ? model.supportedEfforts
885
+ : state.codexModels.flatMap((candidate) => candidate.supportedEfforts || []);
886
+ const efforts = [...new Map(advertised.map((option) => [option.effort, option])).values()]
887
+ .sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
888
+ if (preferredEffort && !efforts.some((option) => option.effort === preferredEffort)) {
889
+ efforts.push({ effort: preferredEffort, description: "当前会话" });
890
+ }
891
+ const inheritedEffort = state.config?.effort || state.codexRuntime?.effort || model?.defaultEffort;
892
+ const inheritedLabel = inheritedEffort
893
+ ? `继承全局(${effortInlineName(inheritedEffort)})`
894
+ : "继承全局设置";
895
+ els.sessionEffortInput.innerHTML = [
896
+ `<option value="">${escapeHtml(inheritedLabel)}</option>`,
897
+ ...efforts.map((option) => `<option value="${escapeAttr(option.effort)}"${option.description ? ` title="${escapeAttr(option.description)}"` : ""}>${escapeHtml(effortDisplayName(option.effort))}</option>`)
898
+ ].join("");
899
+ els.sessionEffortInput.value = preferredEffort;
900
+ }
901
+
902
+ async function handleSessionModelChange() {
903
+ const modelValue = els.sessionModelInput.value;
904
+ const model = state.codexModels.find((candidate) => candidate.model === (modelValue || state.config?.model || state.codexRuntime?.model));
905
+ const efforts = model?.supportedEfforts?.map((option) => option.effort) || [];
906
+ let effortValue = els.sessionEffortInput.value;
907
+ const inheritedEffort = state.config?.effort || state.codexRuntime?.effort || "";
908
+ const effectiveEffort = effortValue || inheritedEffort;
909
+ if (effectiveEffort && efforts.length && !efforts.includes(effectiveEffort)) {
910
+ effortValue = efforts.includes(model?.defaultEffort) ? model.defaultEffort : efforts[0] || "";
911
+ }
912
+ setSessionEffortOptions(modelValue, effortValue);
913
+ await saveSessionRuntimeSettings();
914
+ }
915
+
916
+ async function saveSessionRuntimeSettings() {
917
+ const session = selectedSession();
918
+ if (!session || state.savingSessionRuntime) return;
919
+ const key = sessionKey(session);
920
+ const model = els.sessionModelInput.value;
921
+ const effort = els.sessionEffortInput.value;
922
+ const stream = els.sessionStreamInput.value;
923
+ state.savingSessionRuntime = true;
924
+ renderChatPanel();
925
+ try {
926
+ const result = await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}`, {
927
+ method: "PATCH",
928
+ body: {
929
+ model: model || null,
930
+ effort: effort || null,
931
+ streamReplies: stream ? stream === "on" : null
932
+ }
933
+ });
934
+ const index = state.sessions.findIndex((candidate) => sessionKey(candidate) === key);
935
+ if (index >= 0) state.sessions[index] = result.session;
936
+ els.sessionRuntimeToolbar.dataset.renderKey = "";
937
+ renderSessions();
938
+ toast("会话设置已更新");
939
+ } catch (error) {
940
+ toast(error.message, true);
941
+ await refreshData(false);
942
+ } finally {
943
+ state.savingSessionRuntime = false;
944
+ renderChatPanel();
945
+ }
946
+ }
947
+
948
+ function renderTypingIndicator() {
949
+ return `<div class="chat-typing" role="status" aria-label="对方正在输入">
950
+ <span class="typing-dots" aria-hidden="true"><i></i><i></i><i></i></span>
951
+ <span>对方正在输入…</span>
952
+ </div>`;
953
+ }
954
+
955
+ function setChatMessagesHtml(html, renderKey) {
956
+ if (els.chatMessages.dataset.renderKey === renderKey) return;
957
+ els.chatMessages.innerHTML = html;
958
+ els.chatMessages.dataset.renderKey = renderKey;
959
+ drawIcons();
960
+ }
961
+
962
+ function renderMessageAttachments(attachments) {
963
+ if (!Array.isArray(attachments) || !attachments.length) return "";
964
+ return `<div class="message-attachments">${attachments.map((attachment) => {
965
+ const name = escapeHtml(attachment.name || "附件");
966
+ const url = attachment.url ? escapeAttr(attachment.url) : "";
967
+ const pending = Boolean(attachment.pending);
968
+ const available = Boolean(attachment.available && url);
969
+ const icon = attachment.type === "video" ? "file-video" : attachment.type === "image" ? "image" : "file";
970
+ let preview = "";
971
+ if (available && attachment.type === "video") {
972
+ preview = `<video controls playsinline preload="metadata" src="${url}" aria-label="视频:${escapeAttr(attachment.name || "附件")}"></video>`;
973
+ } else if (available && attachment.type === "image") {
974
+ preview = `<img loading="lazy" src="${url}" alt="${escapeAttr(attachment.name || "图片附件")}">`;
975
+ }
976
+ return `<div class="message-attachment is-${escapeAttr(attachment.type || "file")}${available || pending ? "" : " is-missing"}">
977
+ ${preview}
978
+ <div class="attachment-meta">
979
+ <span class="attachment-type-icon"><i data-lucide="${icon}"></i></span>
980
+ <span class="attachment-copy"><strong title="${escapeAttr(attachment.name || "附件")}">${name}</strong><small>${pending ? "正在上传" : available ? formatBytes(attachment.size) : "文件已移动或删除"}</small></span>
981
+ ${available ? `<a class="icon-button attachment-download" href="${url}?download=1" download="${escapeAttr(attachment.name || "attachment")}" title="下载附件" aria-label="下载 ${escapeAttr(attachment.name || "附件")}"><i data-lucide="download"></i></a>` : ""}
982
+ </div>
983
+ </div>`;
984
+ }).join("")}</div>`;
985
+ }
986
+
987
+ function formatBytes(value) {
988
+ const bytes = Number(value);
989
+ if (!Number.isFinite(bytes) || bytes < 0) return "本机文件";
990
+ if (bytes < 1024) return `${bytes} B`;
991
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
992
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
993
+ return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
994
+ }
995
+
996
+ async function sendSessionMessage(event) {
997
+ event.preventDefault();
998
+ const session = selectedSession();
999
+ const text = els.chatInput.value.trim();
1000
+ const files = [...state.chatFiles];
1001
+ if (!session || (!text && !files.length) || state.sendingMessage) return;
1002
+ const key = sessionKey(session);
1003
+ const streaming = session.streamReplies ?? Boolean(state.config?.streamReplies);
1004
+ let progressSequence = 0;
1005
+ state.sendingMessage = true;
1006
+ state.sessionMessages.push({
1007
+ id: `pending-${Date.now()}`,
1008
+ role: "user",
1009
+ text,
1010
+ createdAt: new Date().toISOString(),
1011
+ attachments: files.map((file, index) => ({
1012
+ index,
1013
+ type: fileKind(file),
1014
+ name: file.name,
1015
+ size: file.size,
1016
+ pending: true
1017
+ }))
1018
+ });
1019
+ els.chatInput.value = "";
1020
+ state.chatFiles = [];
1021
+ renderChatPanel();
1022
+ scrollChatToEnd();
1023
+ try {
1024
+ const body = new FormData();
1025
+ body.append("text", text);
1026
+ files.forEach((file) => body.append("files", file, file.name));
1027
+ const url = `/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`;
1028
+ if (streaming) {
1029
+ await streamApi(`${url}?stream=1`, { method: "POST", body }, (streamEvent) => {
1030
+ if (state.selectedSessionKey !== key) return;
1031
+ if (streamEvent.type === "progress" && streamEvent.message?.trim()) {
1032
+ state.sessionMessages.push({
1033
+ id: `progress-${Date.now()}-${progressSequence++}`,
1034
+ role: "assistant",
1035
+ text: streamEvent.message.trim(),
1036
+ kind: "progress",
1037
+ createdAt: new Date().toISOString(),
1038
+ attachments: []
1039
+ });
1040
+ scheduleStreamingRender();
1041
+ }
1042
+ if (streamEvent.type === "done" && streamEvent.result?.message) {
1043
+ state.sessionMessages.push(streamEvent.result.message);
1044
+ scheduleStreamingRender();
1045
+ }
1046
+ });
1047
+ } else {
1048
+ await api(url, { method: "POST", body });
1049
+ }
1050
+ await refreshData(false);
1051
+ if (state.selectedSessionKey === key) {
1052
+ await loadSelectedSessionMessages();
1053
+ }
1054
+ } catch (error) {
1055
+ toast(error.message, true);
1056
+ if (state.selectedSessionKey === key) {
1057
+ els.chatInput.value = text;
1058
+ state.chatFiles = files;
1059
+ await loadSelectedSessionMessages();
1060
+ }
1061
+ } finally {
1062
+ state.sendingMessage = false;
1063
+ renderChatPanel();
1064
+ els.chatInput.focus();
1065
+ }
1066
+ }
1067
+
1068
+ function scheduleStreamingRender() {
1069
+ if (streamingRenderFrame) return;
1070
+ streamingRenderFrame = requestAnimationFrame(() => {
1071
+ streamingRenderFrame = 0;
1072
+ renderChatPanel();
1073
+ scrollChatToEnd();
1074
+ });
1075
+ }
1076
+
1077
+ function handleChatFileSelection(event) {
1078
+ const nextFiles = [...event.target.files];
1079
+ event.target.value = "";
1080
+ if (!nextFiles.length) return;
1081
+ const combined = [...state.chatFiles, ...nextFiles];
1082
+ if (combined.length > MAX_CHAT_FILES) {
1083
+ toast(`一次最多添加 ${MAX_CHAT_FILES} 个文件`, true);
1084
+ return;
1085
+ }
1086
+ const totalBytes = combined.reduce((total, file) => total + file.size, 0);
1087
+ if (totalBytes > MAX_CHAT_FILE_BYTES) {
1088
+ toast("单次附件总大小不能超过 100 MiB", true);
1089
+ return;
1090
+ }
1091
+ state.chatFiles = combined;
1092
+ renderComposerFiles();
1093
+ updateComposerState();
1094
+ }
1095
+
1096
+ function handleComposerFileAction(event) {
1097
+ const button = event.target.closest("[data-remove-chat-file]");
1098
+ if (!button || state.sendingMessage) return;
1099
+ state.chatFiles.splice(Number(button.dataset.removeChatFile), 1);
1100
+ renderComposerFiles();
1101
+ updateComposerState();
1102
+ }
1103
+
1104
+ function renderComposerFiles() {
1105
+ els.composerFiles.hidden = state.chatFiles.length === 0;
1106
+ els.composerFiles.innerHTML = state.chatFiles.map((file, index) => `<div class="composer-file">
1107
+ <i data-lucide="${fileKind(file) === "image" ? "image" : fileKind(file) === "video" ? "file-video" : "file"}"></i>
1108
+ <span class="composer-file-copy"><strong title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</strong><small>${formatBytes(file.size)}</small></span>
1109
+ <button class="icon-button composer-file-remove" type="button" data-remove-chat-file="${index}" title="移除附件" aria-label="移除 ${escapeAttr(file.name)}"><i data-lucide="x"></i></button>
1110
+ </div>`).join("");
1111
+ drawIcons();
1112
+ }
1113
+
1114
+ function updateComposerState() {
1115
+ const canCompose = Boolean(selectedSession()) && !state.sendingMessage && !state.savingSessionRuntime;
1116
+ els.chatInput.disabled = !canCompose;
1117
+ els.chatAttachButton.disabled = !canCompose;
1118
+ els.chatFileInput.disabled = !canCompose;
1119
+ els.chatSendButton.disabled = !canCompose || (!els.chatInput.value.trim() && !state.chatFiles.length);
1120
+ }
1121
+
1122
+ function resetComposer() {
1123
+ state.chatFiles = [];
1124
+ if (els.chatInput) els.chatInput.value = "";
1125
+ if (els.chatFileInput) els.chatFileInput.value = "";
1126
+ if (els.composerFiles) renderComposerFiles();
1127
+ }
1128
+
1129
+ function fileKind(file) {
1130
+ if (file.type.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|heic)$/i.test(file.name)) return "image";
1131
+ if (file.type.startsWith("video/") || /\.(mp4|mov|webm|mkv|avi|m4v)$/i.test(file.name)) return "video";
1132
+ return "file";
1133
+ }
1134
+
1135
+ function handleChatInputKeydown(event) {
1136
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
1137
+ event.preventDefault();
1138
+ els.chatComposer.requestSubmit();
1139
+ }
1140
+
1141
+ function selectedSession() {
1142
+ return state.sessions.find((session) => sessionKey(session) === state.selectedSessionKey);
1143
+ }
1144
+
1145
+ function sessionKey(session) {
1146
+ return `${session.accountId}\n${session.id}`;
1147
+ }
1148
+
1149
+ function emptyChatState(icon, title, description = "") {
1150
+ return `<div class="chat-empty"><span><i data-lucide="${escapeAttr(icon)}"></i></span><strong>${escapeHtml(title)}</strong>${description ? `<p>${escapeHtml(description)}</p>` : ""}</div>`;
1151
+ }
1152
+
1153
+ function scrollChatToEnd() {
1154
+ window.requestAnimationFrame(() => {
1155
+ window.requestAnimationFrame(() => {
1156
+ els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
1157
+ });
1158
+ });
1159
+ }
1160
+
1161
+ async function beginLogin() {
1162
+ stopLoginPoll();
1163
+ els.qrFrame.innerHTML = `<div class="spinner" aria-label="正在生成二维码"></div>`;
1164
+ setQrStatus("正在生成二维码");
1165
+ if (!els.qrDialog.open) els.qrDialog.showModal();
1166
+ drawIcons();
1167
+ try {
1168
+ const login = await api("/api/logins", { method: "POST" });
1169
+ els.qrFrame.innerHTML = `<img src="${escapeAttr(login.qrDataUrl)}" alt="微信登录二维码">`;
1170
+ setQrStatus("等待微信扫码");
1171
+ state.loginPoll = window.setInterval(() => void pollLogin(login.id), 1800);
1172
+ } catch (error) {
1173
+ setQrStatus(error.message, "error");
1174
+ }
1175
+ }
1176
+
1177
+ async function pollLogin(id) {
1178
+ try {
1179
+ const result = await api(`/api/logins/${encodeURIComponent(id)}`, { token: false });
1180
+ if (result.status === "waiting") setQrStatus("等待微信扫码");
1181
+ if (result.status === "scanned") setQrStatus("已扫码,请在微信中确认");
1182
+ if (result.status === "expired") {
1183
+ stopLoginPoll();
1184
+ setQrStatus("二维码已过期", "error");
1185
+ }
1186
+ if (result.status === "confirmed") {
1187
+ stopLoginPoll();
1188
+ setQrStatus("账号已连接", "success");
1189
+ await refreshData(false);
1190
+ window.setTimeout(() => els.qrDialog.close(), 900);
1191
+ }
1192
+ } catch (error) {
1193
+ stopLoginPoll();
1194
+ setQrStatus(error.message, "error");
1195
+ }
1196
+ }
1197
+
1198
+ function stopLoginPoll() {
1199
+ if (state.loginPoll) window.clearInterval(state.loginPoll);
1200
+ state.loginPoll = null;
1201
+ }
1202
+
1203
+ function setQrStatus(text, kind = "") {
1204
+ els.qrStatus.textContent = text;
1205
+ els.qrStatus.className = `qr-status${kind ? ` is-${kind}` : ""}`;
1206
+ }
1207
+
1208
+ function openNewSessionDialog() {
1209
+ const options = state.accounts.flatMap((account) => {
1210
+ const sender = account.lastActiveSenderId && account.pairedSenderIds.includes(account.lastActiveSenderId)
1211
+ ? account.lastActiveSenderId
1212
+ : account.pairedSenderIds[0];
1213
+ return sender ? [{ account, sender }] : [];
1214
+ });
1215
+ if (!options.length) {
1216
+ toast("请先在微信发送消息,并在账号页允许该联系人", true);
1217
+ showView("accounts");
1218
+ return;
1219
+ }
1220
+ document.querySelector("#sessionDialogTitle").textContent = "新建会话";
1221
+ document.querySelector("#editingSessionId").value = "";
1222
+ document.querySelector("#editingAccountId").value = "";
1223
+ document.querySelector("#senderField").hidden = false;
1224
+ document.querySelector("#sessionWorkspaceField").hidden = false;
1225
+ const senderInput = document.querySelector("#sessionSenderInput");
1226
+ senderInput.innerHTML = options.map(({ account, sender }) => `<option value="${escapeAttr(`${account.accountId}\n${sender}`)}">${escapeHtml(accountDisplayName(account.accountId))}</option>`).join("");
1227
+ const selectedOption = options.find(({ account }) => account.accountId === state.selectedAccountId) ?? options[0];
1228
+ senderInput.value = `${selectedOption.account.accountId}\n${selectedOption.sender}`;
1229
+ updateNewSessionDefaultTitle();
1230
+ document.querySelector("#sessionWorkspaceInput").value = state.config.defaultCwd;
1231
+ els.sessionDialog.showModal();
1232
+ document.querySelector("#sessionTitleInput").focus();
1233
+ }
1234
+
1235
+ function updateNewSessionDefaultTitle() {
1236
+ if (document.querySelector("#editingSessionId").value) return;
1237
+ const [accountId] = document.querySelector("#sessionSenderInput").value.split("\n");
1238
+ const accountSessionCount = state.sessions.filter((session) => session.accountId === accountId).length;
1239
+ document.querySelector("#sessionTitleInput").value = `会话 ${accountSessionCount + 1}`;
1240
+ }
1241
+
1242
+ function openRenameSessionDialog(session) {
1243
+ document.querySelector("#sessionDialogTitle").textContent = "重命名会话";
1244
+ document.querySelector("#editingSessionId").value = session.id;
1245
+ document.querySelector("#editingAccountId").value = session.accountId;
1246
+ document.querySelector("#senderField").hidden = true;
1247
+ document.querySelector("#sessionWorkspaceField").hidden = true;
1248
+ document.querySelector("#sessionTitleInput").value = session.title;
1249
+ els.sessionDialog.showModal();
1250
+ document.querySelector("#sessionTitleInput").select();
1251
+ }
1252
+
1253
+ async function saveSession(event) {
1254
+ event.preventDefault();
1255
+ const title = document.querySelector("#sessionTitleInput").value.trim();
1256
+ const sessionId = document.querySelector("#editingSessionId").value;
1257
+ try {
1258
+ if (sessionId) {
1259
+ const accountId = document.querySelector("#editingAccountId").value;
1260
+ await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}`, { method: "PATCH", body: { title } });
1261
+ } else {
1262
+ const [accountId, senderId] = document.querySelector("#sessionSenderInput").value.split("\n");
1263
+ const created = await api("/api/sessions", { method: "POST", body: { accountId, senderId, title, workspace: document.querySelector("#sessionWorkspaceInput").value.trim() } });
1264
+ state.selectedAccountId = accountId;
1265
+ state.selectedSessionKey = sessionKey(created.session);
1266
+ state.sessionMessages = [];
1267
+ state.loadedSessionKey = "";
1268
+ }
1269
+ els.sessionDialog.close();
1270
+ await refreshData(false);
1271
+ } catch (error) {
1272
+ toast(error.message, true);
1273
+ }
1274
+ }
1275
+
1276
+ async function saveSettings(event) {
1277
+ event.preventDefault();
1278
+ const button = event.submitter;
1279
+ try {
1280
+ button.disabled = true;
1281
+ const result = await api("/api/config", {
1282
+ method: "PUT",
1283
+ body: {
1284
+ defaultCwd: document.querySelector("#defaultCwdInput").value.trim(),
1285
+ allowedWorkspaces: document.querySelector("#allowedWorkspacesInput").value.split("\n").map((line) => line.trim()).filter(Boolean),
1286
+ codexBackend: document.querySelector("#backendInput").value,
1287
+ codexExecSandbox: document.querySelector("#sandboxInput").value || null,
1288
+ model: document.querySelector("#modelInput").value.trim(),
1289
+ effort: document.querySelector("#effortInput").value.trim(),
1290
+ streamReplies: document.querySelector("#streamRepliesInput").checked
1291
+ }
1292
+ });
1293
+ state.config = result.config;
1294
+ state.codexRuntime = result.codexRuntime;
1295
+ state.codexModels = result.codexModels || state.codexModels;
1296
+ renderAll();
1297
+ toast("设置已保存");
1298
+ } catch (error) {
1299
+ toast(error.message, true);
1300
+ } finally {
1301
+ button.disabled = false;
1302
+ }
1303
+ }
1304
+
1305
+ function showView(name, updateHash = true) {
1306
+ const valid = ["accounts", "sessions", "settings"].includes(name) ? name : "accounts";
1307
+ document.querySelectorAll("[data-view-panel]").forEach((panel) => {
1308
+ const visible = panel.dataset.viewPanel === valid;
1309
+ panel.hidden = !visible;
1310
+ panel.classList.toggle("is-visible", visible);
1311
+ });
1312
+ document.querySelectorAll(".tab[data-view]").forEach((tab) => tab.classList.toggle("is-active", tab.dataset.view === valid));
1313
+ if (updateHash && location.hash !== `#${valid}`) history.replaceState(null, "", `#${valid}`);
1314
+ }
1315
+
1316
+ function closeDialog(id) {
1317
+ document.querySelector(`#${CSS.escape(id)}`)?.close();
1318
+ }
1319
+
1320
+ async function api(url, options = {}) {
1321
+ const isFormData = options.body instanceof FormData;
1322
+ const headers = { ...(options.body && !isFormData ? { "Content-Type": "application/json" } : {}) };
1323
+ if (options.token !== false && state.requestToken) headers["X-Codex-Weixin-Token"] = state.requestToken;
1324
+ const response = await fetch(url, {
1325
+ method: options.method || "GET",
1326
+ headers,
1327
+ body: options.body ? (isFormData ? options.body : JSON.stringify(options.body)) : undefined
1328
+ });
1329
+ const data = await response.json().catch(() => ({}));
1330
+ if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
1331
+ return data;
1332
+ }
1333
+
1334
+ async function streamApi(url, options, onEvent) {
1335
+ const headers = {};
1336
+ if (state.requestToken) headers["X-Codex-Weixin-Token"] = state.requestToken;
1337
+ const response = await fetch(url, {
1338
+ method: options.method || "POST",
1339
+ headers,
1340
+ body: options.body
1341
+ });
1342
+ const contentType = response.headers.get("content-type") || "";
1343
+ if (!contentType.startsWith("application/x-ndjson")) {
1344
+ const data = await response.json().catch(() => ({}));
1345
+ if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
1346
+ return data.result;
1347
+ }
1348
+ if (!response.ok || !response.body) {
1349
+ throw new Error(`请求失败 (${response.status})`);
1350
+ }
1351
+ const reader = response.body.getReader();
1352
+ const decoder = new TextDecoder();
1353
+ let buffer = "";
1354
+ let result;
1355
+ const consumeLine = (line) => {
1356
+ if (!line.trim()) return;
1357
+ const streamEvent = JSON.parse(line);
1358
+ if (streamEvent.type === "error") throw new Error(streamEvent.error || "过程进度失败");
1359
+ onEvent(streamEvent);
1360
+ if (streamEvent.type === "done") result = streamEvent.result;
1361
+ };
1362
+ while (true) {
1363
+ const { value, done } = await reader.read();
1364
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
1365
+ const lines = buffer.split(/\r?\n/);
1366
+ buffer = lines.pop() || "";
1367
+ for (const line of lines) consumeLine(line);
1368
+ if (done) break;
1369
+ }
1370
+ if (buffer) consumeLine(buffer);
1371
+ return result;
1372
+ }
1373
+
1374
+ function emptyState(icon, title, description = "", action = "") {
1375
+ return `<div class="empty-state"><div class="empty-state-inner"><span class="empty-icon"><i data-lucide="${escapeAttr(icon)}"></i></span><h2>${escapeHtml(title)}</h2>${description ? `<p>${escapeHtml(description)}</p>` : ""}${action}</div></div>`;
1376
+ }
1377
+
1378
+ function accountDisplayName(accountId) {
1379
+ const account = state.accounts.find((item) => item.accountId === accountId);
1380
+ const index = state.accounts.findIndex((item) => item.accountId === accountId);
1381
+ return account?.displayName || `微信账号 ${index >= 0 ? index + 1 : ""}`.trim();
1382
+ }
1383
+
1384
+ function statusText(status) {
1385
+ return ({ running: "运行中", starting: "启动中", stopped: "已停止", error: "异常" })[status] || status;
1386
+ }
1387
+
1388
+ function shortId(value) {
1389
+ if (!value || value.length <= 26) return value || "--";
1390
+ return `${value.slice(0, 12)}...${value.slice(-8)}`;
1391
+ }
1392
+
1393
+ function relativeTime(value) {
1394
+ const seconds = Math.round((Date.now() - new Date(value).getTime()) / 1000);
1395
+ if (seconds < 60) return "刚刚";
1396
+ const minutes = Math.floor(seconds / 60);
1397
+ if (minutes < 60) return `${minutes} 分钟前`;
1398
+ const hours = Math.floor(minutes / 60);
1399
+ if (hours < 24) return `${hours} 小时前`;
1400
+ return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }).format(new Date(value));
1401
+ }
1402
+
1403
+ function messageTime(value) {
1404
+ const date = new Date(value);
1405
+ if (Number.isNaN(date.getTime())) return "";
1406
+ return new Intl.DateTimeFormat("zh-CN", {
1407
+ month: "2-digit",
1408
+ day: "2-digit",
1409
+ hour: "2-digit",
1410
+ minute: "2-digit"
1411
+ }).format(date);
1412
+ }
1413
+
1414
+ function toast(message, error = false) {
1415
+ const node = document.createElement("div");
1416
+ node.className = `toast${error ? " is-error" : ""}`;
1417
+ node.textContent = message;
1418
+ document.querySelector("#toastRegion").append(node);
1419
+ window.setTimeout(() => node.remove(), 3800);
1420
+ }
1421
+
1422
+ function drawIcons() {
1423
+ window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
1424
+ }
1425
+
1426
+ function renderMarkdown(value) {
1427
+ const source = String(value ?? "");
1428
+ if (!window.marked?.parse || !window.DOMPurify?.sanitize) {
1429
+ return escapeHtml(source).replace(/\n/g, "<br>");
1430
+ }
1431
+ const rendered = window.marked.parse(source, { gfm: true, breaks: true });
1432
+ const clean = window.DOMPurify.sanitize(rendered, {
1433
+ USE_PROFILES: { html: true },
1434
+ FORBID_TAGS: ["style", "img"],
1435
+ FORBID_ATTR: ["style"]
1436
+ });
1437
+ const template = document.createElement("template");
1438
+ template.innerHTML = clean;
1439
+ template.content.querySelectorAll("a").forEach((link) => {
1440
+ link.target = "_blank";
1441
+ link.rel = "noreferrer noopener";
1442
+ });
1443
+ return template.innerHTML;
1444
+ }
1445
+
1446
+ function escapeHtml(value) {
1447
+ return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
1448
+ }
1449
+
1450
+ function escapeAttr(value) {
1451
+ return escapeHtml(value).replace(/`/g, "&#96;");
1452
+ }