codex-weixin 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +5 -0
  4. package/README.en.md +200 -0
  5. package/README.md +210 -0
  6. package/dist/bridge/access.d.ts +21 -0
  7. package/dist/bridge/access.js +31 -0
  8. package/dist/bridge/access.js.map +1 -0
  9. package/dist/bridge/actions.d.ts +21 -0
  10. package/dist/bridge/actions.js +120 -0
  11. package/dist/bridge/actions.js.map +1 -0
  12. package/dist/bridge/errors.d.ts +1 -0
  13. package/dist/bridge/errors.js +15 -0
  14. package/dist/bridge/errors.js.map +1 -0
  15. package/dist/bridge/format.d.ts +14 -0
  16. package/dist/bridge/format.js +62 -0
  17. package/dist/bridge/format.js.map +1 -0
  18. package/dist/bridge/prompt-buffer.d.ts +45 -0
  19. package/dist/bridge/prompt-buffer.js +56 -0
  20. package/dist/bridge/prompt-buffer.js.map +1 -0
  21. package/dist/bridge/service.d.ts +38 -0
  22. package/dist/bridge/service.js +292 -0
  23. package/dist/bridge/service.js.map +1 -0
  24. package/dist/codex/app-server-runner.d.ts +74 -0
  25. package/dist/codex/app-server-runner.js +514 -0
  26. package/dist/codex/app-server-runner.js.map +1 -0
  27. package/dist/codex/exec-runner.d.ts +36 -0
  28. package/dist/codex/exec-runner.js +153 -0
  29. package/dist/codex/exec-runner.js.map +1 -0
  30. package/dist/codex/runner.d.ts +22 -0
  31. package/dist/codex/runner.js +57 -0
  32. package/dist/codex/runner.js.map +1 -0
  33. package/dist/codex/sandbox.d.ts +3 -0
  34. package/dist/codex/sandbox.js +15 -0
  35. package/dist/codex/sandbox.js.map +1 -0
  36. package/dist/index.d.ts +15 -0
  37. package/dist/index.js +16 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/server/account-manager.d.ts +94 -0
  40. package/dist/server/account-manager.js +468 -0
  41. package/dist/server/account-manager.js.map +1 -0
  42. package/dist/server/arguments.d.ts +3 -0
  43. package/dist/server/arguments.js +25 -0
  44. package/dist/server/arguments.js.map +1 -0
  45. package/dist/server/http-server.d.ts +23 -0
  46. package/dist/server/http-server.js +481 -0
  47. package/dist/server/http-server.js.map +1 -0
  48. package/dist/server/index.d.ts +2 -0
  49. package/dist/server/index.js +69 -0
  50. package/dist/server/index.js.map +1 -0
  51. package/dist/server/login-manager.d.ts +27 -0
  52. package/dist/server/login-manager.js +49 -0
  53. package/dist/server/login-manager.js.map +1 -0
  54. package/dist/server/process-lock.d.ts +5 -0
  55. package/dist/server/process-lock.js +63 -0
  56. package/dist/server/process-lock.js.map +1 -0
  57. package/dist/state/config.d.ts +19 -0
  58. package/dist/state/config.js +41 -0
  59. package/dist/state/config.js.map +1 -0
  60. package/dist/state/json-store.d.ts +3 -0
  61. package/dist/state/json-store.js +23 -0
  62. package/dist/state/json-store.js.map +1 -0
  63. package/dist/state/paths.d.ts +12 -0
  64. package/dist/state/paths.js +28 -0
  65. package/dist/state/paths.js.map +1 -0
  66. package/dist/state/runtime-state.d.ts +57 -0
  67. package/dist/state/runtime-state.js +212 -0
  68. package/dist/state/runtime-state.js.map +1 -0
  69. package/dist/web/app.js +958 -0
  70. package/dist/web/index.html +273 -0
  71. package/dist/web/styles.css +324 -0
  72. package/dist/web/vendor/lucide.min.js +12 -0
  73. package/dist/web/vendor/marked.umd.js +79 -0
  74. package/dist/web/vendor/purify.min.js +3 -0
  75. package/dist/weixin/accounts.d.ts +22 -0
  76. package/dist/weixin/accounts.js +66 -0
  77. package/dist/weixin/accounts.js.map +1 -0
  78. package/dist/weixin/api.d.ts +75 -0
  79. package/dist/weixin/api.js +191 -0
  80. package/dist/weixin/api.js.map +1 -0
  81. package/dist/weixin/login.d.ts +29 -0
  82. package/dist/weixin/login.js +100 -0
  83. package/dist/weixin/login.js.map +1 -0
  84. package/dist/weixin/media.d.ts +32 -0
  85. package/dist/weixin/media.js +266 -0
  86. package/dist/weixin/media.js.map +1 -0
  87. package/dist/weixin/messages.d.ts +24 -0
  88. package/dist/weixin/messages.js +87 -0
  89. package/dist/weixin/messages.js.map +1 -0
  90. package/dist/weixin/monitor.d.ts +22 -0
  91. package/dist/weixin/monitor.js +110 -0
  92. package/dist/weixin/monitor.js.map +1 -0
  93. package/package.json +61 -0
@@ -0,0 +1,958 @@
1
+ const state = {
2
+ requestToken: "",
3
+ accounts: [],
4
+ sessions: [],
5
+ config: null,
6
+ codex: null,
7
+ codexRuntime: null,
8
+ codexModels: [],
9
+ loginPoll: null,
10
+ selectedSessionKey: "",
11
+ sessionMessages: [],
12
+ loadedSessionKey: "",
13
+ loadingMessages: false,
14
+ sendingMessage: false,
15
+ selectedAccountId: "",
16
+ chatFiles: []
17
+ };
18
+
19
+ const MAX_CHAT_FILES = 10;
20
+ const MAX_CHAT_FILE_BYTES = 50 * 1024 * 1024;
21
+
22
+ const els = {};
23
+
24
+ document.addEventListener("DOMContentLoaded", () => {
25
+ Object.assign(els, {
26
+ accountsList: document.querySelector("#accountsList"),
27
+ sessionsList: document.querySelector("#sessionsList"),
28
+ sessionAccountTabs: document.querySelector("#sessionAccountTabs"),
29
+ sessionListCount: document.querySelector("#sessionListCount"),
30
+ chatTitle: document.querySelector("#chatTitle"),
31
+ chatContext: document.querySelector("#chatContext"),
32
+ chatMessages: document.querySelector("#chatMessages"),
33
+ chatComposer: document.querySelector("#chatComposer"),
34
+ chatInput: document.querySelector("#chatInput"),
35
+ chatSendButton: document.querySelector("#chatSendButton"),
36
+ chatAttachButton: document.querySelector("#chatAttachButton"),
37
+ chatFileInput: document.querySelector("#chatFileInput"),
38
+ composerFiles: document.querySelector("#composerFiles"),
39
+ refreshMessagesButton: document.querySelector("#refreshMessagesButton"),
40
+ runningAccountMetric: document.querySelector("#runningAccountMetric"),
41
+ sessionMetric: document.querySelector("#sessionMetric"),
42
+ workspaceMetric: document.querySelector("#workspaceMetric"),
43
+ qrDialog: document.querySelector("#qrDialog"),
44
+ qrFrame: document.querySelector("#qrFrame"),
45
+ qrStatus: document.querySelector("#qrStatus"),
46
+ accountDialog: document.querySelector("#accountDialog"),
47
+ sessionDialog: document.querySelector("#sessionDialog")
48
+ });
49
+ bindEvents();
50
+ void bootstrap();
51
+ });
52
+
53
+ function bindEvents() {
54
+ document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
55
+ document.querySelectorAll("[data-close-dialog]").forEach((button) => button.addEventListener("click", () => closeDialog(button.dataset.closeDialog)));
56
+ document.querySelector("#addAccountButton").addEventListener("click", () => void beginLogin());
57
+ document.querySelector("#refreshQrButton").addEventListener("click", () => void beginLogin());
58
+ document.querySelector("#refreshAccountsButton").addEventListener("click", () => void refreshData(true));
59
+ document.querySelector("#newSessionButton").addEventListener("click", openNewSessionDialog);
60
+ document.querySelector("#settingsForm").addEventListener("submit", (event) => void saveSettings(event));
61
+ document.querySelector("#modelInput").addEventListener("change", () => renderEffortOptions(""));
62
+ document.querySelector("#accountForm").addEventListener("submit", (event) => void saveAccountRemark(event));
63
+ document.querySelector("#sessionForm").addEventListener("submit", (event) => void saveSession(event));
64
+ document.querySelector("#sessionSenderInput").addEventListener("change", updateNewSessionDefaultTitle);
65
+ els.chatComposer.addEventListener("submit", (event) => void sendSessionMessage(event));
66
+ els.chatInput.addEventListener("input", updateComposerState);
67
+ els.chatInput.addEventListener("keydown", handleChatInputKeydown);
68
+ els.chatAttachButton.addEventListener("click", () => els.chatFileInput.click());
69
+ els.chatFileInput.addEventListener("change", handleChatFileSelection);
70
+ els.composerFiles.addEventListener("click", handleComposerFileAction);
71
+ els.refreshMessagesButton.addEventListener("click", () => void loadSelectedSessionMessages());
72
+ els.accountsList.addEventListener("click", (event) => void handleAccountAction(event));
73
+ els.sessionsList.addEventListener("click", (event) => void handleSessionAction(event));
74
+ els.sessionAccountTabs.addEventListener("click", handleSessionAccountTab);
75
+ els.qrDialog.addEventListener("close", stopLoginPoll);
76
+ window.addEventListener("hashchange", () => showView(location.hash.slice(1) || "accounts", false));
77
+ }
78
+
79
+ async function bootstrap() {
80
+ try {
81
+ const data = await api("/api/bootstrap", { token: false });
82
+ state.requestToken = data.requestToken;
83
+ state.accounts = data.accounts;
84
+ state.sessions = data.sessions;
85
+ state.config = data.config;
86
+ state.codex = data.codex;
87
+ state.codexRuntime = data.codexRuntime;
88
+ state.codexModels = data.codexModels || [];
89
+ renderAll();
90
+ showView(location.hash.slice(1) || "accounts", false);
91
+ window.setInterval(() => void refreshData(false), 5000);
92
+ } catch (error) {
93
+ toast(error.message, true);
94
+ els.accountsList.innerHTML = emptyState("server-off", "无法连接本机服务", "请重新启动 codex-weixin");
95
+ }
96
+ }
97
+
98
+ async function refreshData(notify) {
99
+ try {
100
+ const previousSession = selectedSession();
101
+ const [accounts, sessions] = await Promise.all([api("/api/accounts"), api("/api/sessions")]);
102
+ state.accounts = accounts.accounts;
103
+ state.sessions = sessions.sessions;
104
+ renderMetrics();
105
+ renderAccounts();
106
+ renderSessions();
107
+ drawIcons();
108
+ const currentSession = selectedSession();
109
+ if (
110
+ previousSession
111
+ && currentSession
112
+ && sessionKey(previousSession) === sessionKey(currentSession)
113
+ && previousSession.updatedAt !== currentSession.updatedAt
114
+ && state.loadedSessionKey === state.selectedSessionKey
115
+ && !state.sendingMessage
116
+ ) {
117
+ void loadSelectedSessionMessages();
118
+ } else if (previousSession?.responding !== currentSession?.responding) {
119
+ window.requestAnimationFrame(scrollChatToEnd);
120
+ }
121
+ if (notify) toast("状态已刷新");
122
+ } catch (error) {
123
+ if (notify) toast(error.message, true);
124
+ }
125
+ }
126
+
127
+ function renderAll() {
128
+ renderMetrics();
129
+ renderAccounts();
130
+ renderSessions();
131
+ renderSettings();
132
+ drawIcons();
133
+ }
134
+
135
+ function renderMetrics() {
136
+ els.runningAccountMetric.textContent = String(state.accounts.filter((account) => account.status === "running").length);
137
+ els.sessionMetric.textContent = String(state.sessions.length);
138
+ els.workspaceMetric.textContent = state.config?.defaultCwd || "--";
139
+ els.workspaceMetric.title = state.config?.defaultCwd || "";
140
+ const serviceText = document.querySelector("#serviceStateText");
141
+ const serviceDot = document.querySelector("#serviceDot");
142
+ if (state.codex?.ready) {
143
+ serviceText.textContent = state.codex.version || "Codex 已就绪";
144
+ serviceDot.classList.remove("is-error");
145
+ } else {
146
+ serviceText.textContent = "未检测到 Codex CLI";
147
+ serviceDot.classList.add("is-error");
148
+ }
149
+ }
150
+
151
+ function renderAccounts() {
152
+ if (!state.accounts.length) {
153
+ 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>`);
154
+ drawIcons();
155
+ return;
156
+ }
157
+ els.accountsList.innerHTML = state.accounts.map((account) => {
158
+ const pendingSender = account.lastActiveSenderId && !account.pairedSenderIds.includes(account.lastActiveSenderId)
159
+ ? account.lastActiveSenderId : "";
160
+ const authorized = account.pairedSenderIds.length > 0;
161
+ return `<article class="account-card">
162
+ <div class="account-main">
163
+ <div class="account-identity">
164
+ <span class="account-avatar"><i data-lucide="message-circle"></i></span>
165
+ <div class="account-name">
166
+ <strong>${escapeHtml(accountDisplayName(account.accountId))}</strong>
167
+ <div class="account-description">个人微信接入</div>
168
+ </div>
169
+ </div>
170
+ <div class="account-stat"><span>状态</span><strong class="status-label status-${escapeAttr(account.status)}">${statusText(account.status)}</strong></div>
171
+ <div class="account-stat"><span>会话</span><strong>${account.sessionCount}</strong></div>
172
+ <div class="account-actions">
173
+ <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>
174
+ <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>
175
+ <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>
176
+ </div>
177
+ </div>
178
+ <div class="account-detail">${renderAuthorizationState(account, pendingSender)}</div>
179
+ ${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>` : ""}
180
+ ${account.error ? `<div class="pending-access"><div><strong>账号运行错误</strong><span>${escapeHtml(account.error)}</span></div></div>` : ""}
181
+ </article>`;
182
+ }).join("");
183
+ }
184
+
185
+ function renderAuthorizationState(account, pendingSender) {
186
+ const accountId = escapeAttr(account.accountId);
187
+ if (account.pairedSenderIds.length) {
188
+ return `<div class="authorization-state is-authorized" aria-label="授权状态:已授权">
189
+ <span class="authorization-icon"><i data-lucide="shield-check"></i></span>
190
+ <div class="authorization-copy"><strong>已授权</strong><span>可以从微信控制 Codex</span></div>
191
+ <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>
192
+ </div>`;
193
+ }
194
+ if (pendingSender) {
195
+ return `<div class="authorization-state is-pending" aria-label="授权状态:待授权">
196
+ <span class="authorization-icon"><i data-lucide="shield-alert"></i></span>
197
+ <div class="authorization-copy"><strong>待授权</strong><span>检测到新的微信访问请求</span></div>
198
+ <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>
199
+ </div>`;
200
+ }
201
+ return `<div class="authorization-state is-unauthorized" aria-label="授权状态:未授权">
202
+ <span class="authorization-icon"><i data-lucide="shield"></i></span>
203
+ <div class="authorization-copy"><strong>未授权</strong><span>请先从微信向此账号发送一条消息</span></div>
204
+ </div>`;
205
+ }
206
+
207
+ function renderSessions() {
208
+ if (!state.sessions.length) {
209
+ els.sessionListCount.textContent = "0";
210
+ els.sessionAccountTabs.innerHTML = "";
211
+ 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>`);
212
+ state.selectedSessionKey = "";
213
+ state.sessionMessages = [];
214
+ state.loadedSessionKey = "";
215
+ renderChatPanel();
216
+ drawIcons();
217
+ return;
218
+ }
219
+ const accountIds = [...new Set([
220
+ ...state.accounts.map((account) => account.accountId),
221
+ ...state.sessions.map((session) => session.accountId)
222
+ ])].filter((accountId) => state.sessions.some((session) => session.accountId === accountId));
223
+ if (!accountIds.includes(state.selectedAccountId)) {
224
+ const selected = selectedSession();
225
+ state.selectedAccountId = selected && accountIds.includes(selected.accountId) ? selected.accountId : accountIds[0];
226
+ }
227
+ const visibleSessions = state.sessions.filter((session) => session.accountId === state.selectedAccountId);
228
+ els.sessionListCount.textContent = String(visibleSessions.length);
229
+ els.sessionAccountTabs.innerHTML = accountIds.map((accountId) => {
230
+ const active = accountId === state.selectedAccountId;
231
+ const count = state.sessions.filter((session) => session.accountId === accountId).length;
232
+ 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>`;
233
+ }).join("");
234
+ let shouldLoad = false;
235
+ if (!visibleSessions.some((session) => sessionKey(session) === state.selectedSessionKey)) {
236
+ state.selectedSessionKey = sessionKey(visibleSessions[0]);
237
+ state.sessionMessages = [];
238
+ state.loadedSessionKey = "";
239
+ shouldLoad = true;
240
+ }
241
+ els.sessionsList.innerHTML = visibleSessions.map((session) => {
242
+ const selected = sessionKey(session) === state.selectedSessionKey;
243
+ return `<article class="session-card${selected ? " is-selected" : ""}">
244
+ <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}">
245
+ <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>
246
+ <span class="session-owner"><strong>${escapeHtml(accountDisplayName(session.accountId))}</strong></span>
247
+ <span class="session-workspace" title="${escapeAttr(session.workspace)}">${escapeHtml(session.workspace)}</span>
248
+ <span class="session-thread${session.responding ? " is-responding" : ""}">${session.responding ? "对方正在输入…" : session.threadId ? "已连接 Codex" : "等待首条消息"}</span>
249
+ </button>
250
+ <div class="session-actions">
251
+ <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>
252
+ <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>
253
+ <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>
254
+ <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>
255
+ </div>
256
+ </article>`;
257
+ }).join("");
258
+ renderChatPanel();
259
+ drawIcons();
260
+ if (shouldLoad) void loadSelectedSessionMessages();
261
+ }
262
+
263
+ function renderSettings() {
264
+ if (!state.config) return;
265
+ document.querySelector("#defaultCwdInput").value = state.config.defaultCwd || "";
266
+ document.querySelector("#allowedWorkspacesInput").value = (state.config.allowedWorkspaces || []).join("\n");
267
+ document.querySelector("#backendInput").value = state.config.codexBackend || "auto";
268
+ document.querySelector("#sandboxInput").value = state.config.codexExecSandbox || "";
269
+ renderModelOptions();
270
+ document.querySelector("#effectiveModelValue").textContent = state.codexRuntime?.model || state.config.model || "Codex 默认";
271
+ document.querySelector("#effectiveEffortValue").textContent = state.codexRuntime?.effort || state.config.effort || "Codex 默认";
272
+ }
273
+
274
+ function renderModelOptions() {
275
+ const select = document.querySelector("#modelInput");
276
+ const configuredModel = state.config?.model || "";
277
+ const effectiveModel = state.codexRuntime?.model || "";
278
+ const models = Array.isArray(state.codexModels) ? state.codexModels : [];
279
+ const options = [{
280
+ value: "",
281
+ label: effectiveModel ? `沿用 Codex 设置(当前:${effectiveModel})` : "沿用 Codex 设置"
282
+ }, ...models.map((model) => ({
283
+ value: model.model,
284
+ label: model.displayName && model.displayName !== model.model
285
+ ? `${model.displayName} · ${model.model}`
286
+ : model.model,
287
+ title: model.description || ""
288
+ }))];
289
+ if (configuredModel && !options.some((option) => option.value === configuredModel)) {
290
+ options.push({ value: configuredModel, label: `${configuredModel}(当前配置)` });
291
+ }
292
+ select.innerHTML = options.map((option) => `<option value="${escapeAttr(option.value)}"${option.title ? ` title="${escapeAttr(option.title)}"` : ""}>${escapeHtml(option.label)}</option>`).join("");
293
+ select.value = configuredModel;
294
+ renderEffortOptions(state.config?.effort || "");
295
+ }
296
+
297
+ function renderEffortOptions(preferredEffort) {
298
+ const modelValue = document.querySelector("#modelInput").value;
299
+ const effectiveModel = modelValue || state.codexRuntime?.model || "";
300
+ const model = state.codexModels.find((candidate) => candidate.model === effectiveModel);
301
+ const allEfforts = model?.supportedEfforts?.length
302
+ ? model.supportedEfforts
303
+ : state.codexModels.flatMap((candidate) => candidate.supportedEfforts || []);
304
+ const efforts = [...new Map(allEfforts.map((option) => [option.effort, option])).values()]
305
+ .sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
306
+ if (preferredEffort && !efforts.some((option) => option.effort === preferredEffort)) {
307
+ efforts.push({ effort: preferredEffort, description: "当前配置" });
308
+ }
309
+ const inheritedEffort = state.codexRuntime?.effort || model?.defaultEffort;
310
+ const inheritedLabel = inheritedEffort
311
+ ? `沿用 Codex 设置(当前:${effortInlineName(inheritedEffort)})`
312
+ : "沿用 Codex 设置";
313
+ const select = document.querySelector("#effortInput");
314
+ select.innerHTML = [
315
+ `<option value="">${escapeHtml(inheritedLabel)}</option>`,
316
+ ...efforts.map((option) => `<option value="${escapeAttr(option.effort)}"${option.description ? ` title="${escapeAttr(option.description)}"` : ""}>${escapeHtml(effortDisplayName(option.effort))}</option>`)
317
+ ].join("");
318
+ select.value = preferredEffort;
319
+ }
320
+
321
+ function effortDisplayName(effort) {
322
+ const label = ({ minimal: "最小", low: "低", medium: "中", high: "高", xhigh: "超高", max: "最大", ultra: "极高" })[effort];
323
+ return label ? `${label}(${effort})` : effort;
324
+ }
325
+
326
+ function effortInlineName(effort) {
327
+ const label = ({ minimal: "最小", low: "低", medium: "中", high: "高", xhigh: "超高", max: "最大", ultra: "极高" })[effort];
328
+ return label ? `${label} · ${effort}` : effort;
329
+ }
330
+
331
+ function effortRank(effort) {
332
+ const index = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"].indexOf(effort);
333
+ return index < 0 ? Number.MAX_SAFE_INTEGER : index;
334
+ }
335
+
336
+ async function handleAccountAction(event) {
337
+ const button = event.target.closest("[data-account-action]");
338
+ if (!button) return;
339
+ const action = button.dataset.accountAction;
340
+ if (action === "add") return beginLogin();
341
+ const accountId = button.dataset.accountId;
342
+ const account = state.accounts.find((item) => item.accountId === accountId);
343
+ if (action === "rename") {
344
+ if (account) openAccountRemarkDialog(account);
345
+ return;
346
+ }
347
+ try {
348
+ button.disabled = true;
349
+ if (action === "start" || action === "stop") await api(`/api/accounts/${encodeURIComponent(accountId)}/${action}`, { method: "POST" });
350
+ if (action === "allow") {
351
+ await api(`/api/accounts/${encodeURIComponent(accountId)}/senders/${encodeURIComponent(button.dataset.senderId)}/allow`, { method: "POST" });
352
+ }
353
+ if (action === "revoke-all" && account) {
354
+ if (!window.confirm("撤销此微信账号的全部控制授权?撤销后需要重新允许才能继续使用。")) return;
355
+ await Promise.all(account.pairedSenderIds.map((senderId) => api(
356
+ `/api/accounts/${encodeURIComponent(accountId)}/senders/${encodeURIComponent(senderId)}/remove`,
357
+ { method: "POST" }
358
+ )));
359
+ }
360
+ if (action === "remove") {
361
+ if (!window.confirm("移除此微信账号?本服务保存的该账号会话状态也会被删除。")) return;
362
+ await api(`/api/accounts/${encodeURIComponent(accountId)}`, { method: "DELETE" });
363
+ }
364
+ await refreshData(false);
365
+ } catch (error) {
366
+ toast(error.message, true);
367
+ } finally {
368
+ button.disabled = false;
369
+ }
370
+ }
371
+
372
+ function openAccountRemarkDialog(account) {
373
+ document.querySelector("#editingRemarkAccountId").value = account.accountId;
374
+ document.querySelector("#accountRemarkInput").value = account.displayName || "";
375
+ els.accountDialog.showModal();
376
+ document.querySelector("#accountRemarkInput").select();
377
+ }
378
+
379
+ async function saveAccountRemark(event) {
380
+ event.preventDefault();
381
+ const button = event.submitter;
382
+ const accountId = document.querySelector("#editingRemarkAccountId").value;
383
+ const displayName = document.querySelector("#accountRemarkInput").value.trim();
384
+ try {
385
+ button.disabled = true;
386
+ await api(`/api/accounts/${encodeURIComponent(accountId)}`, {
387
+ method: "PATCH",
388
+ body: { displayName }
389
+ });
390
+ els.accountDialog.close();
391
+ await refreshData(false);
392
+ toast(displayName ? "账号备注已保存" : "账号备注已清除");
393
+ } catch (error) {
394
+ toast(error.message, true);
395
+ } finally {
396
+ button.disabled = false;
397
+ }
398
+ }
399
+
400
+ async function handleSessionAction(event) {
401
+ const button = event.target.closest("[data-session-action]");
402
+ if (!button) return;
403
+ const action = button.dataset.sessionAction;
404
+ if (action === "new") return openNewSessionDialog();
405
+ const accountId = button.dataset.accountId;
406
+ const sessionId = button.dataset.sessionId;
407
+ const session = state.sessions.find((item) => item.accountId === accountId && item.id === sessionId);
408
+ if (!session) return;
409
+ if (action === "open") return selectSession(session);
410
+ if (action === "rename") return openRenameSessionDialog(session);
411
+ try {
412
+ button.disabled = true;
413
+ if (action === "activate" || action === "reset") {
414
+ if (action === "reset" && !window.confirm("重置此会话的 Codex 上下文?下一条微信或 Web 消息会创建新的 thread。")) return;
415
+ await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}/${action}`, { method: "POST" });
416
+ }
417
+ if (action === "delete") {
418
+ if (!window.confirm("删除此受管会话?Codex 自身保存的历史文件不会被删除。")) return;
419
+ await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
420
+ }
421
+ await refreshData(false);
422
+ } catch (error) {
423
+ toast(error.message, true);
424
+ } finally {
425
+ button.disabled = false;
426
+ }
427
+ }
428
+
429
+ function handleSessionAccountTab(event) {
430
+ const button = event.target.closest("[data-session-account]");
431
+ if (!button || button.dataset.sessionAccount === state.selectedAccountId) return;
432
+ state.selectedAccountId = button.dataset.sessionAccount;
433
+ state.selectedSessionKey = "";
434
+ state.sessionMessages = [];
435
+ state.loadedSessionKey = "";
436
+ resetComposer();
437
+ renderSessions();
438
+ }
439
+
440
+ function selectSession(session) {
441
+ const key = sessionKey(session);
442
+ if (key === state.selectedSessionKey && state.loadedSessionKey === key) {
443
+ return;
444
+ }
445
+ state.selectedSessionKey = key;
446
+ state.sessionMessages = [];
447
+ state.loadedSessionKey = "";
448
+ state.loadingMessages = true;
449
+ resetComposer();
450
+ renderSessions();
451
+ void loadSelectedSessionMessages();
452
+ }
453
+
454
+ async function loadSelectedSessionMessages() {
455
+ const session = selectedSession();
456
+ if (!session) {
457
+ renderChatPanel();
458
+ return;
459
+ }
460
+ const key = sessionKey(session);
461
+ state.loadingMessages = true;
462
+ renderChatPanel();
463
+ try {
464
+ const result = await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`);
465
+ if (state.selectedSessionKey !== key) return;
466
+ state.sessionMessages = result.messages || [];
467
+ state.loadedSessionKey = key;
468
+ } catch (error) {
469
+ if (state.selectedSessionKey !== key) return;
470
+ state.sessionMessages = [];
471
+ state.loadedSessionKey = key;
472
+ toast(error.message, true);
473
+ } finally {
474
+ if (state.selectedSessionKey === key) {
475
+ state.loadingMessages = false;
476
+ renderChatPanel();
477
+ scrollChatToEnd();
478
+ }
479
+ }
480
+ }
481
+
482
+ function renderChatPanel() {
483
+ const session = selectedSession();
484
+ const enabled = Boolean(session) && !state.sendingMessage;
485
+ els.chatInput.disabled = !enabled;
486
+ els.chatAttachButton.disabled = !enabled;
487
+ els.chatFileInput.disabled = !enabled;
488
+ els.refreshMessagesButton.disabled = !session || state.loadingMessages || state.sendingMessage;
489
+ renderComposerFiles();
490
+ updateComposerState();
491
+ if (!session) {
492
+ els.chatTitle.textContent = "选择一个会话";
493
+ els.chatContext.textContent = "查看历史消息并继续聊天";
494
+ setChatMessagesHtml(emptyChatState("messages-square", "从左侧选择会话"), "no-session");
495
+ return;
496
+ }
497
+
498
+ els.chatTitle.textContent = session.title;
499
+ els.chatContext.textContent = `${accountDisplayName(session.accountId)} · ${session.workspace}`;
500
+ const responding = Boolean(session.responding || state.sendingMessage);
501
+ if (state.loadingMessages) {
502
+ setChatMessagesHtml(
503
+ `<div class="chat-loading"><div class="spinner" aria-label="正在加载历史消息"></div><span>正在读取 Codex 历史</span></div>`,
504
+ `loading:${sessionKey(session)}`
505
+ );
506
+ return;
507
+ }
508
+ if (!state.sessionMessages.length) {
509
+ setChatMessagesHtml(
510
+ responding
511
+ ? renderTypingIndicator()
512
+ : emptyChatState("message-circle", session.threadId ? "这个 thread 暂无可显示消息" : "发送第一条消息开始会话", session.threadId ? "" : "历史会在 Codex 创建 thread 后显示"),
513
+ `empty:${sessionKey(session)}:${session.threadId || "new"}:${responding}`
514
+ );
515
+ return;
516
+ }
517
+ const renderKey = `messages:${sessionKey(session)}:${responding}:${JSON.stringify(state.sessionMessages)}`;
518
+ const html = state.sessionMessages.map((message) => `<article class="chat-message is-${escapeAttr(message.role)}${message.attachments?.length ? " has-attachments" : ""}">
519
+ <div class="message-meta"><span>${message.role === "user" ? "你" : "Codex"}</span>${message.createdAt ? `<time datetime="${escapeAttr(message.createdAt)}">${escapeHtml(messageTime(message.createdAt))}</time>` : ""}</div>
520
+ <div class="message-bubble">${message.text ? renderMarkdown(message.text) : ""}${renderMessageAttachments(message.attachments)}</div>
521
+ </article>`).join("") + (responding ? renderTypingIndicator() : "");
522
+ setChatMessagesHtml(html, renderKey);
523
+ }
524
+
525
+ function renderTypingIndicator() {
526
+ return `<div class="chat-typing" role="status" aria-label="对方正在输入">
527
+ <span class="typing-dots" aria-hidden="true"><i></i><i></i><i></i></span>
528
+ <span>对方正在输入…</span>
529
+ </div>`;
530
+ }
531
+
532
+ function setChatMessagesHtml(html, renderKey) {
533
+ if (els.chatMessages.dataset.renderKey === renderKey) return;
534
+ els.chatMessages.innerHTML = html;
535
+ els.chatMessages.dataset.renderKey = renderKey;
536
+ drawIcons();
537
+ }
538
+
539
+ function renderMessageAttachments(attachments) {
540
+ if (!Array.isArray(attachments) || !attachments.length) return "";
541
+ return `<div class="message-attachments">${attachments.map((attachment) => {
542
+ const name = escapeHtml(attachment.name || "附件");
543
+ const url = attachment.url ? escapeAttr(attachment.url) : "";
544
+ const pending = Boolean(attachment.pending);
545
+ const available = Boolean(attachment.available && url);
546
+ const icon = attachment.type === "video" ? "file-video" : attachment.type === "image" ? "image" : "file";
547
+ let preview = "";
548
+ if (available && attachment.type === "video") {
549
+ preview = `<video controls playsinline preload="metadata" src="${url}" aria-label="视频:${escapeAttr(attachment.name || "附件")}"></video>`;
550
+ } else if (available && attachment.type === "image") {
551
+ preview = `<img loading="lazy" src="${url}" alt="${escapeAttr(attachment.name || "图片附件")}">`;
552
+ }
553
+ return `<div class="message-attachment is-${escapeAttr(attachment.type || "file")}${available || pending ? "" : " is-missing"}">
554
+ ${preview}
555
+ <div class="attachment-meta">
556
+ <span class="attachment-type-icon"><i data-lucide="${icon}"></i></span>
557
+ <span class="attachment-copy"><strong title="${escapeAttr(attachment.name || "附件")}">${name}</strong><small>${pending ? "正在上传" : available ? formatBytes(attachment.size) : "文件已移动或删除"}</small></span>
558
+ ${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>` : ""}
559
+ </div>
560
+ </div>`;
561
+ }).join("")}</div>`;
562
+ }
563
+
564
+ function formatBytes(value) {
565
+ const bytes = Number(value);
566
+ if (!Number.isFinite(bytes) || bytes < 0) return "本机文件";
567
+ if (bytes < 1024) return `${bytes} B`;
568
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
569
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
570
+ return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
571
+ }
572
+
573
+ async function sendSessionMessage(event) {
574
+ event.preventDefault();
575
+ const session = selectedSession();
576
+ const text = els.chatInput.value.trim();
577
+ const files = [...state.chatFiles];
578
+ if (!session || (!text && !files.length) || state.sendingMessage) return;
579
+ const key = sessionKey(session);
580
+ state.sendingMessage = true;
581
+ state.sessionMessages.push({
582
+ id: `pending-${Date.now()}`,
583
+ role: "user",
584
+ text,
585
+ createdAt: new Date().toISOString(),
586
+ attachments: files.map((file, index) => ({
587
+ index,
588
+ type: fileKind(file),
589
+ name: file.name,
590
+ size: file.size,
591
+ pending: true
592
+ }))
593
+ });
594
+ els.chatInput.value = "";
595
+ state.chatFiles = [];
596
+ renderChatPanel();
597
+ scrollChatToEnd();
598
+ try {
599
+ const body = new FormData();
600
+ body.append("text", text);
601
+ files.forEach((file) => body.append("files", file, file.name));
602
+ await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`, {
603
+ method: "POST",
604
+ body
605
+ });
606
+ await refreshData(false);
607
+ if (state.selectedSessionKey === key) {
608
+ await loadSelectedSessionMessages();
609
+ }
610
+ } catch (error) {
611
+ toast(error.message, true);
612
+ if (state.selectedSessionKey === key) {
613
+ els.chatInput.value = text;
614
+ state.chatFiles = files;
615
+ await loadSelectedSessionMessages();
616
+ }
617
+ } finally {
618
+ state.sendingMessage = false;
619
+ renderChatPanel();
620
+ els.chatInput.focus();
621
+ }
622
+ }
623
+
624
+ function handleChatFileSelection(event) {
625
+ const nextFiles = [...event.target.files];
626
+ event.target.value = "";
627
+ if (!nextFiles.length) return;
628
+ const combined = [...state.chatFiles, ...nextFiles];
629
+ if (combined.length > MAX_CHAT_FILES) {
630
+ toast(`一次最多添加 ${MAX_CHAT_FILES} 个文件`, true);
631
+ return;
632
+ }
633
+ const totalBytes = combined.reduce((total, file) => total + file.size, 0);
634
+ if (totalBytes > MAX_CHAT_FILE_BYTES) {
635
+ toast("附件总大小不能超过 50 MB", true);
636
+ return;
637
+ }
638
+ state.chatFiles = combined;
639
+ renderComposerFiles();
640
+ updateComposerState();
641
+ }
642
+
643
+ function handleComposerFileAction(event) {
644
+ const button = event.target.closest("[data-remove-chat-file]");
645
+ if (!button || state.sendingMessage) return;
646
+ state.chatFiles.splice(Number(button.dataset.removeChatFile), 1);
647
+ renderComposerFiles();
648
+ updateComposerState();
649
+ }
650
+
651
+ function renderComposerFiles() {
652
+ els.composerFiles.hidden = state.chatFiles.length === 0;
653
+ els.composerFiles.innerHTML = state.chatFiles.map((file, index) => `<div class="composer-file">
654
+ <i data-lucide="${fileKind(file) === "image" ? "image" : fileKind(file) === "video" ? "file-video" : "file"}"></i>
655
+ <span class="composer-file-copy"><strong title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</strong><small>${formatBytes(file.size)}</small></span>
656
+ <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>
657
+ </div>`).join("");
658
+ drawIcons();
659
+ }
660
+
661
+ function updateComposerState() {
662
+ const canCompose = Boolean(selectedSession()) && !state.sendingMessage;
663
+ els.chatInput.disabled = !canCompose;
664
+ els.chatAttachButton.disabled = !canCompose;
665
+ els.chatFileInput.disabled = !canCompose;
666
+ els.chatSendButton.disabled = !canCompose || (!els.chatInput.value.trim() && !state.chatFiles.length);
667
+ }
668
+
669
+ function resetComposer() {
670
+ state.chatFiles = [];
671
+ if (els.chatInput) els.chatInput.value = "";
672
+ if (els.chatFileInput) els.chatFileInput.value = "";
673
+ if (els.composerFiles) renderComposerFiles();
674
+ }
675
+
676
+ function fileKind(file) {
677
+ if (file.type.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|heic)$/i.test(file.name)) return "image";
678
+ if (file.type.startsWith("video/") || /\.(mp4|mov|webm|mkv|avi|m4v)$/i.test(file.name)) return "video";
679
+ return "file";
680
+ }
681
+
682
+ function handleChatInputKeydown(event) {
683
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
684
+ event.preventDefault();
685
+ els.chatComposer.requestSubmit();
686
+ }
687
+
688
+ function selectedSession() {
689
+ return state.sessions.find((session) => sessionKey(session) === state.selectedSessionKey);
690
+ }
691
+
692
+ function sessionKey(session) {
693
+ return `${session.accountId}\n${session.id}`;
694
+ }
695
+
696
+ function emptyChatState(icon, title, description = "") {
697
+ return `<div class="chat-empty"><span><i data-lucide="${escapeAttr(icon)}"></i></span><strong>${escapeHtml(title)}</strong>${description ? `<p>${escapeHtml(description)}</p>` : ""}</div>`;
698
+ }
699
+
700
+ function scrollChatToEnd() {
701
+ window.requestAnimationFrame(() => {
702
+ window.requestAnimationFrame(() => {
703
+ els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
704
+ });
705
+ });
706
+ }
707
+
708
+ async function beginLogin() {
709
+ stopLoginPoll();
710
+ els.qrFrame.innerHTML = `<div class="spinner" aria-label="正在生成二维码"></div>`;
711
+ setQrStatus("正在生成二维码");
712
+ if (!els.qrDialog.open) els.qrDialog.showModal();
713
+ drawIcons();
714
+ try {
715
+ const login = await api("/api/logins", { method: "POST" });
716
+ els.qrFrame.innerHTML = `<img src="${escapeAttr(login.qrDataUrl)}" alt="微信登录二维码">`;
717
+ setQrStatus("等待微信扫码");
718
+ state.loginPoll = window.setInterval(() => void pollLogin(login.id), 1800);
719
+ } catch (error) {
720
+ setQrStatus(error.message, "error");
721
+ }
722
+ }
723
+
724
+ async function pollLogin(id) {
725
+ try {
726
+ const result = await api(`/api/logins/${encodeURIComponent(id)}`, { token: false });
727
+ if (result.status === "waiting") setQrStatus("等待微信扫码");
728
+ if (result.status === "scanned") setQrStatus("已扫码,请在微信中确认");
729
+ if (result.status === "expired") {
730
+ stopLoginPoll();
731
+ setQrStatus("二维码已过期", "error");
732
+ }
733
+ if (result.status === "confirmed") {
734
+ stopLoginPoll();
735
+ setQrStatus("账号已连接", "success");
736
+ await refreshData(false);
737
+ window.setTimeout(() => els.qrDialog.close(), 900);
738
+ }
739
+ } catch (error) {
740
+ stopLoginPoll();
741
+ setQrStatus(error.message, "error");
742
+ }
743
+ }
744
+
745
+ function stopLoginPoll() {
746
+ if (state.loginPoll) window.clearInterval(state.loginPoll);
747
+ state.loginPoll = null;
748
+ }
749
+
750
+ function setQrStatus(text, kind = "") {
751
+ els.qrStatus.textContent = text;
752
+ els.qrStatus.className = `qr-status${kind ? ` is-${kind}` : ""}`;
753
+ }
754
+
755
+ function openNewSessionDialog() {
756
+ const options = state.accounts.flatMap((account) => {
757
+ const sender = account.lastActiveSenderId && account.pairedSenderIds.includes(account.lastActiveSenderId)
758
+ ? account.lastActiveSenderId
759
+ : account.pairedSenderIds[0];
760
+ return sender ? [{ account, sender }] : [];
761
+ });
762
+ if (!options.length) {
763
+ toast("请先在微信发送消息,并在账号页允许该联系人", true);
764
+ showView("accounts");
765
+ return;
766
+ }
767
+ document.querySelector("#sessionDialogTitle").textContent = "新建会话";
768
+ document.querySelector("#editingSessionId").value = "";
769
+ document.querySelector("#editingAccountId").value = "";
770
+ document.querySelector("#senderField").hidden = false;
771
+ document.querySelector("#sessionWorkspaceField").hidden = false;
772
+ const senderInput = document.querySelector("#sessionSenderInput");
773
+ senderInput.innerHTML = options.map(({ account, sender }) => `<option value="${escapeAttr(`${account.accountId}\n${sender}`)}">${escapeHtml(accountDisplayName(account.accountId))}</option>`).join("");
774
+ const selectedOption = options.find(({ account }) => account.accountId === state.selectedAccountId) ?? options[0];
775
+ senderInput.value = `${selectedOption.account.accountId}\n${selectedOption.sender}`;
776
+ updateNewSessionDefaultTitle();
777
+ document.querySelector("#sessionWorkspaceInput").value = state.config.defaultCwd;
778
+ els.sessionDialog.showModal();
779
+ document.querySelector("#sessionTitleInput").focus();
780
+ }
781
+
782
+ function updateNewSessionDefaultTitle() {
783
+ if (document.querySelector("#editingSessionId").value) return;
784
+ const [accountId] = document.querySelector("#sessionSenderInput").value.split("\n");
785
+ const accountSessionCount = state.sessions.filter((session) => session.accountId === accountId).length;
786
+ document.querySelector("#sessionTitleInput").value = `会话 ${accountSessionCount + 1}`;
787
+ }
788
+
789
+ function openRenameSessionDialog(session) {
790
+ document.querySelector("#sessionDialogTitle").textContent = "重命名会话";
791
+ document.querySelector("#editingSessionId").value = session.id;
792
+ document.querySelector("#editingAccountId").value = session.accountId;
793
+ document.querySelector("#senderField").hidden = true;
794
+ document.querySelector("#sessionWorkspaceField").hidden = true;
795
+ document.querySelector("#sessionTitleInput").value = session.title;
796
+ els.sessionDialog.showModal();
797
+ document.querySelector("#sessionTitleInput").select();
798
+ }
799
+
800
+ async function saveSession(event) {
801
+ event.preventDefault();
802
+ const title = document.querySelector("#sessionTitleInput").value.trim();
803
+ const sessionId = document.querySelector("#editingSessionId").value;
804
+ try {
805
+ if (sessionId) {
806
+ const accountId = document.querySelector("#editingAccountId").value;
807
+ await api(`/api/sessions/${encodeURIComponent(accountId)}/${encodeURIComponent(sessionId)}`, { method: "PATCH", body: { title } });
808
+ } else {
809
+ const [accountId, senderId] = document.querySelector("#sessionSenderInput").value.split("\n");
810
+ const created = await api("/api/sessions", { method: "POST", body: { accountId, senderId, title, workspace: document.querySelector("#sessionWorkspaceInput").value.trim() } });
811
+ state.selectedAccountId = accountId;
812
+ state.selectedSessionKey = sessionKey(created.session);
813
+ state.sessionMessages = [];
814
+ state.loadedSessionKey = "";
815
+ }
816
+ els.sessionDialog.close();
817
+ await refreshData(false);
818
+ } catch (error) {
819
+ toast(error.message, true);
820
+ }
821
+ }
822
+
823
+ async function saveSettings(event) {
824
+ event.preventDefault();
825
+ const button = event.submitter;
826
+ try {
827
+ button.disabled = true;
828
+ const result = await api("/api/config", {
829
+ method: "PUT",
830
+ body: {
831
+ defaultCwd: document.querySelector("#defaultCwdInput").value.trim(),
832
+ allowedWorkspaces: document.querySelector("#allowedWorkspacesInput").value.split("\n").map((line) => line.trim()).filter(Boolean),
833
+ codexBackend: document.querySelector("#backendInput").value,
834
+ codexExecSandbox: document.querySelector("#sandboxInput").value || null,
835
+ model: document.querySelector("#modelInput").value.trim(),
836
+ effort: document.querySelector("#effortInput").value.trim()
837
+ }
838
+ });
839
+ state.config = result.config;
840
+ state.codexRuntime = result.codexRuntime;
841
+ state.codexModels = result.codexModels || state.codexModels;
842
+ renderAll();
843
+ toast("设置已保存");
844
+ } catch (error) {
845
+ toast(error.message, true);
846
+ } finally {
847
+ button.disabled = false;
848
+ }
849
+ }
850
+
851
+ function showView(name, updateHash = true) {
852
+ const valid = ["accounts", "sessions", "settings"].includes(name) ? name : "accounts";
853
+ document.querySelectorAll("[data-view-panel]").forEach((panel) => {
854
+ const visible = panel.dataset.viewPanel === valid;
855
+ panel.hidden = !visible;
856
+ panel.classList.toggle("is-visible", visible);
857
+ });
858
+ document.querySelectorAll(".tab[data-view]").forEach((tab) => tab.classList.toggle("is-active", tab.dataset.view === valid));
859
+ if (updateHash && location.hash !== `#${valid}`) history.replaceState(null, "", `#${valid}`);
860
+ }
861
+
862
+ function closeDialog(id) {
863
+ document.querySelector(`#${CSS.escape(id)}`)?.close();
864
+ }
865
+
866
+ async function api(url, options = {}) {
867
+ const isFormData = options.body instanceof FormData;
868
+ const headers = { ...(options.body && !isFormData ? { "Content-Type": "application/json" } : {}) };
869
+ if (options.token !== false && state.requestToken) headers["X-Codex-Weixin-Token"] = state.requestToken;
870
+ const response = await fetch(url, {
871
+ method: options.method || "GET",
872
+ headers,
873
+ body: options.body ? (isFormData ? options.body : JSON.stringify(options.body)) : undefined
874
+ });
875
+ const data = await response.json().catch(() => ({}));
876
+ if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
877
+ return data;
878
+ }
879
+
880
+ function emptyState(icon, title, description = "", action = "") {
881
+ 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>`;
882
+ }
883
+
884
+ function accountDisplayName(accountId) {
885
+ const account = state.accounts.find((item) => item.accountId === accountId);
886
+ const index = state.accounts.findIndex((item) => item.accountId === accountId);
887
+ return account?.displayName || `微信账号 ${index >= 0 ? index + 1 : ""}`.trim();
888
+ }
889
+
890
+ function statusText(status) {
891
+ return ({ running: "运行中", starting: "启动中", stopped: "已停止", error: "异常" })[status] || status;
892
+ }
893
+
894
+ function shortId(value) {
895
+ if (!value || value.length <= 26) return value || "--";
896
+ return `${value.slice(0, 12)}...${value.slice(-8)}`;
897
+ }
898
+
899
+ function relativeTime(value) {
900
+ const seconds = Math.round((Date.now() - new Date(value).getTime()) / 1000);
901
+ if (seconds < 60) return "刚刚";
902
+ const minutes = Math.floor(seconds / 60);
903
+ if (minutes < 60) return `${minutes} 分钟前`;
904
+ const hours = Math.floor(minutes / 60);
905
+ if (hours < 24) return `${hours} 小时前`;
906
+ return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }).format(new Date(value));
907
+ }
908
+
909
+ function messageTime(value) {
910
+ const date = new Date(value);
911
+ if (Number.isNaN(date.getTime())) return "";
912
+ return new Intl.DateTimeFormat("zh-CN", {
913
+ month: "2-digit",
914
+ day: "2-digit",
915
+ hour: "2-digit",
916
+ minute: "2-digit"
917
+ }).format(date);
918
+ }
919
+
920
+ function toast(message, error = false) {
921
+ const node = document.createElement("div");
922
+ node.className = `toast${error ? " is-error" : ""}`;
923
+ node.textContent = message;
924
+ document.querySelector("#toastRegion").append(node);
925
+ window.setTimeout(() => node.remove(), 3800);
926
+ }
927
+
928
+ function drawIcons() {
929
+ window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
930
+ }
931
+
932
+ function renderMarkdown(value) {
933
+ const source = String(value ?? "");
934
+ if (!window.marked?.parse || !window.DOMPurify?.sanitize) {
935
+ return escapeHtml(source).replace(/\n/g, "<br>");
936
+ }
937
+ const rendered = window.marked.parse(source, { gfm: true, breaks: true });
938
+ const clean = window.DOMPurify.sanitize(rendered, {
939
+ USE_PROFILES: { html: true },
940
+ FORBID_TAGS: ["style", "img"],
941
+ FORBID_ATTR: ["style"]
942
+ });
943
+ const template = document.createElement("template");
944
+ template.innerHTML = clean;
945
+ template.content.querySelectorAll("a").forEach((link) => {
946
+ link.target = "_blank";
947
+ link.rel = "noreferrer noopener";
948
+ });
949
+ return template.innerHTML;
950
+ }
951
+
952
+ function escapeHtml(value) {
953
+ return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
954
+ }
955
+
956
+ function escapeAttr(value) {
957
+ return escapeHtml(value).replace(/`/g, "&#96;");
958
+ }