codex-weixin 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web/app.js CHANGED
@@ -15,6 +15,7 @@ const state = {
15
15
  sendingMessage: false,
16
16
  savingSessionRuntime: false,
17
17
  updateInfo: null,
18
+ updateChecking: false,
18
19
  updateInstalling: false,
19
20
  selectedAccountId: "",
20
21
  chatFiles: []
@@ -25,6 +26,7 @@ const MAX_CHAT_FILE_BYTES = 50 * 1024 * 1024;
25
26
  const DISMISSED_UPDATE_KEY = "codex-weixin.dismissed-update";
26
27
  const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
27
28
  const UPDATE_RECONNECT_TIMEOUT_MS = 90 * 1000;
29
+ let streamingRenderFrame = 0;
28
30
 
29
31
  const els = {};
30
32
 
@@ -48,6 +50,7 @@ document.addEventListener("DOMContentLoaded", () => {
48
50
  sessionRuntimeToolbar: document.querySelector("#sessionRuntimeToolbar"),
49
51
  sessionModelInput: document.querySelector("#sessionModelInput"),
50
52
  sessionEffortInput: document.querySelector("#sessionEffortInput"),
53
+ sessionStreamInput: document.querySelector("#sessionStreamInput"),
51
54
  runningAccountMetric: document.querySelector("#runningAccountMetric"),
52
55
  sessionMetric: document.querySelector("#sessionMetric"),
53
56
  workspaceMetric: document.querySelector("#workspaceMetric"),
@@ -62,6 +65,8 @@ document.addEventListener("DOMContentLoaded", () => {
62
65
  updateProgressDetail: document.querySelector("#updateProgressDetail"),
63
66
  updateLaterButton: document.querySelector("#updateLaterButton"),
64
67
  updateNowButton: document.querySelector("#updateNowButton"),
68
+ updateCheckButton: document.querySelector("#updateCheckButton"),
69
+ settingsVersionValue: document.querySelector("#settingsVersionValue"),
65
70
  accountDialog: document.querySelector("#accountDialog"),
66
71
  sessionDialog: document.querySelector("#sessionDialog")
67
72
  });
@@ -80,6 +85,7 @@ function bindEvents() {
80
85
  document.querySelector("#modelInput").addEventListener("change", () => renderEffortOptions(""));
81
86
  els.sessionModelInput.addEventListener("change", () => void handleSessionModelChange());
82
87
  els.sessionEffortInput.addEventListener("change", () => void saveSessionRuntimeSettings());
88
+ els.sessionStreamInput.addEventListener("change", () => void saveSessionRuntimeSettings());
83
89
  document.querySelector("#accountForm").addEventListener("submit", (event) => void saveAccountRemark(event));
84
90
  document.querySelector("#sessionForm").addEventListener("submit", (event) => void saveSession(event));
85
91
  document.querySelector("#sessionSenderInput").addEventListener("change", updateNewSessionDefaultTitle);
@@ -96,6 +102,7 @@ function bindEvents() {
96
102
  els.qrDialog.addEventListener("close", stopLoginPoll);
97
103
  els.updateLaterButton.addEventListener("click", dismissUpdate);
98
104
  els.updateNowButton.addEventListener("click", () => void installUpdate());
105
+ els.updateCheckButton.addEventListener("click", () => void checkForUpdateNow());
99
106
  els.updateDialog.addEventListener("cancel", (event) => {
100
107
  event.preventDefault();
101
108
  if (!state.updateInstalling) dismissUpdate();
@@ -126,23 +133,54 @@ async function bootstrap() {
126
133
  }
127
134
 
128
135
  async function checkForUpdate() {
129
- if (state.updateInstalling) return;
136
+ if (state.updateInstalling || state.updateChecking) return;
130
137
  try {
131
138
  const info = await api("/api/update", { token: false });
132
139
  if (!info.updateAvailable || !info.latestVersion || dismissedUpdateVersion() === info.latestVersion) {
133
140
  return;
134
141
  }
135
- state.updateInfo = info;
136
- els.updateCurrentVersion.textContent = `v${String(info.currentVersion).replace(/^v/i, "")}`;
137
- els.updateLatestVersion.textContent = `v${String(info.latestVersion).replace(/^v/i, "")}`;
138
- resetUpdateDialog();
139
- if (!els.updateDialog.open) els.updateDialog.showModal();
140
- drawIcons();
142
+ showAvailableUpdate(info);
141
143
  } catch {
142
144
  // Update checks are best-effort and must not interrupt the local management page.
143
145
  }
144
146
  }
145
147
 
148
+ async function checkForUpdateNow() {
149
+ if (state.updateChecking || state.updateInstalling) return;
150
+ const label = els.updateCheckButton.querySelector("span");
151
+ state.updateChecking = true;
152
+ els.updateCheckButton.disabled = true;
153
+ els.updateCheckButton.classList.add("is-loading");
154
+ label.textContent = "检查中";
155
+ try {
156
+ const info = await api("/api/update?force=1");
157
+ if (info.error) throw new Error(info.error);
158
+ if (info.updateAvailable && info.latestVersion) {
159
+ showAvailableUpdate(info);
160
+ return;
161
+ }
162
+ const current = String(info.currentVersion || state.version || "").replace(/^v/i, "");
163
+ toast(current ? `已是最新版本 v${current}` : "已是最新版本");
164
+ } catch (error) {
165
+ toast(error.message || "无法检查新版本", true);
166
+ } finally {
167
+ state.updateChecking = false;
168
+ els.updateCheckButton.disabled = false;
169
+ els.updateCheckButton.classList.remove("is-loading");
170
+ label.textContent = "检查更新";
171
+ drawIcons();
172
+ }
173
+ }
174
+
175
+ function showAvailableUpdate(info) {
176
+ state.updateInfo = info;
177
+ els.updateCurrentVersion.textContent = `v${String(info.currentVersion).replace(/^v/i, "")}`;
178
+ els.updateLatestVersion.textContent = `v${String(info.latestVersion).replace(/^v/i, "")}`;
179
+ resetUpdateDialog();
180
+ if (!els.updateDialog.open) els.updateDialog.showModal();
181
+ drawIcons();
182
+ }
183
+
146
184
  function dismissUpdate() {
147
185
  if (state.updateInstalling) return;
148
186
  const version = state.updateInfo?.latestVersion;
@@ -290,6 +328,7 @@ function renderProductVersion() {
290
328
  const version = state.version.trim();
291
329
  els.productVersion.hidden = !version;
292
330
  els.productVersion.textContent = version ? `v${version.replace(/^v/i, "")}` : "";
331
+ els.settingsVersionValue.textContent = version ? `v${version.replace(/^v/i, "")}` : "--";
293
332
  }
294
333
 
295
334
  function renderMetrics() {
@@ -441,6 +480,7 @@ function renderSettings() {
441
480
  document.querySelector("#allowedWorkspacesInput").value = (state.config.allowedWorkspaces || []).join("\n");
442
481
  document.querySelector("#backendInput").value = state.config.codexBackend || "auto";
443
482
  document.querySelector("#sandboxInput").value = state.config.codexExecSandbox || "";
483
+ document.querySelector("#streamRepliesInput").checked = Boolean(state.config.streamReplies);
444
484
  renderModelOptions();
445
485
  document.querySelector("#effectiveModelValue").textContent = state.codexRuntime?.model || state.config.model || "Codex 默认";
446
486
  document.querySelector("#effectiveEffortValue").textContent = state.codexRuntime?.effort || state.config.effort || "Codex 默认";
@@ -691,23 +731,79 @@ function renderChatPanel() {
691
731
  return;
692
732
  }
693
733
  const renderKey = `messages:${sessionKey(session)}:${responding}:${JSON.stringify(state.sessionMessages)}`;
694
- const html = state.sessionMessages.map((message) => `<article class="chat-message is-${escapeAttr(message.role)}${message.attachments?.length ? " has-attachments" : ""}">
734
+ const html = renderConversationMessages(state.sessionMessages, responding) + (responding ? renderTypingIndicator() : "");
735
+ setChatMessagesHtml(html, renderKey);
736
+ }
737
+
738
+ function renderConversationMessages(messages, responding) {
739
+ const html = [];
740
+ let lastUserCreatedAt;
741
+ let index = 0;
742
+ while (index < messages.length) {
743
+ const message = messages[index];
744
+ if (message.kind === "progress") {
745
+ const progress = [];
746
+ while (index < messages.length && messages[index].kind === "progress") {
747
+ progress.push(messages[index]);
748
+ index += 1;
749
+ }
750
+ const nextMessage = messages[index];
751
+ const active = responding && !nextMessage;
752
+ const completedAt = nextMessage?.role === "assistant"
753
+ ? nextMessage.createdAt
754
+ : progress.at(-1)?.createdAt;
755
+ html.push(renderProgressGroup(progress, lastUserCreatedAt, completedAt, active));
756
+ continue;
757
+ }
758
+ if (message.role === "user") lastUserCreatedAt = message.createdAt;
759
+ html.push(renderChatMessage(message));
760
+ index += 1;
761
+ }
762
+ return html.join("");
763
+ }
764
+
765
+ function renderChatMessage(message) {
766
+ return `<article class="chat-message is-${escapeAttr(message.role)}${message.attachments?.length ? " has-attachments" : ""}">
695
767
  <div class="message-meta"><span>${message.role === "user" ? "你" : "Codex"}</span>${message.createdAt ? `<time datetime="${escapeAttr(message.createdAt)}">${escapeHtml(messageTime(message.createdAt))}</time>` : ""}</div>
696
768
  <div class="message-bubble">${message.text ? renderMarkdown(message.text) : ""}${renderMessageAttachments(message.attachments)}</div>
697
- </article>`).join("") + (responding ? renderTypingIndicator() : "");
698
- setChatMessagesHtml(html, renderKey);
769
+ </article>`;
770
+ }
771
+
772
+ function renderProgressGroup(messages, startedAt, completedAt, active) {
773
+ const duration = formatProcessingDuration(startedAt, active ? new Date().toISOString() : completedAt);
774
+ return `<details class="chat-progress-group"${active ? " open" : ""}>
775
+ <summary>
776
+ <span class="progress-summary-title"><i data-lucide="chevron-right"></i>处理过程</span>
777
+ <span>${active ? "已处理" : "处理用时"} ${escapeHtml(duration)}</span>
778
+ </summary>
779
+ <ol class="progress-list">${messages.map((message) => `<li>${renderMarkdown(message.text)}</li>`).join("")}</ol>
780
+ </details>`;
781
+ }
782
+
783
+ function formatProcessingDuration(startValue, endValue) {
784
+ const start = new Date(startValue || "").getTime();
785
+ const end = new Date(endValue || "").getTime();
786
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--";
787
+ const seconds = Math.max(0, Math.round((end - start) / 1000));
788
+ if (seconds < 60) return `${seconds} 秒`;
789
+ const minutes = Math.floor(seconds / 60);
790
+ const remaining = seconds % 60;
791
+ return remaining ? `${minutes} 分 ${remaining} 秒` : `${minutes} 分钟`;
699
792
  }
700
793
 
701
794
  function renderSessionRuntimeControls(session) {
702
795
  const disabled = !session || state.sendingMessage || state.savingSessionRuntime;
703
796
  els.sessionModelInput.disabled = disabled;
704
797
  els.sessionEffortInput.disabled = disabled;
798
+ els.sessionStreamInput.disabled = disabled;
705
799
  const renderKey = session ? [
706
800
  sessionKey(session),
707
801
  session.model || "",
708
802
  session.effort || "",
803
+ typeof session.streamReplies === "boolean" ? String(session.streamReplies) : "inherit",
709
804
  state.config?.model || "",
710
805
  state.config?.effort || "",
806
+ String(Boolean(state.config?.streamReplies)),
711
807
  state.codexRuntime?.model || "",
712
808
  state.codexRuntime?.effort || "",
713
809
  state.codexModels.length
@@ -718,6 +814,7 @@ function renderSessionRuntimeControls(session) {
718
814
  if (!session) {
719
815
  els.sessionModelInput.innerHTML = '<option value="">选择会话后设置</option>';
720
816
  els.sessionEffortInput.innerHTML = '<option value="">选择会话后设置</option>';
817
+ els.sessionStreamInput.innerHTML = '<option value="">选择会话后设置</option>';
721
818
  return;
722
819
  }
723
820
 
@@ -739,6 +836,14 @@ function renderSessionRuntimeControls(session) {
739
836
  els.sessionModelInput.innerHTML = options.map((option) => `<option value="${escapeAttr(option.value)}"${option.title ? ` title="${escapeAttr(option.title)}"` : ""}>${escapeHtml(option.label)}</option>`).join("");
740
837
  els.sessionModelInput.value = session.model || "";
741
838
  setSessionEffortOptions(session.model || "", session.effort || "");
839
+ els.sessionStreamInput.innerHTML = [
840
+ `<option value="">继承全局(${state.config?.streamReplies ? "开启" : "关闭"})</option>`,
841
+ '<option value="on">开启</option>',
842
+ '<option value="off">关闭</option>'
843
+ ].join("");
844
+ els.sessionStreamInput.value = typeof session.streamReplies === "boolean"
845
+ ? session.streamReplies ? "on" : "off"
846
+ : "";
742
847
  }
743
848
 
744
849
  function setSessionEffortOptions(modelOverride, preferredEffort) {
@@ -783,18 +888,23 @@ async function saveSessionRuntimeSettings() {
783
888
  const key = sessionKey(session);
784
889
  const model = els.sessionModelInput.value;
785
890
  const effort = els.sessionEffortInput.value;
891
+ const stream = els.sessionStreamInput.value;
786
892
  state.savingSessionRuntime = true;
787
893
  renderChatPanel();
788
894
  try {
789
895
  const result = await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}`, {
790
896
  method: "PATCH",
791
- body: { model: model || null, effort: effort || null }
897
+ body: {
898
+ model: model || null,
899
+ effort: effort || null,
900
+ streamReplies: stream ? stream === "on" : null
901
+ }
792
902
  });
793
903
  const index = state.sessions.findIndex((candidate) => sessionKey(candidate) === key);
794
904
  if (index >= 0) state.sessions[index] = result.session;
795
905
  els.sessionRuntimeToolbar.dataset.renderKey = "";
796
906
  renderSessions();
797
- toast("会话模型设置已更新");
907
+ toast("会话设置已更新");
798
908
  } catch (error) {
799
909
  toast(error.message, true);
800
910
  await refreshData(false);
@@ -859,6 +969,8 @@ async function sendSessionMessage(event) {
859
969
  const files = [...state.chatFiles];
860
970
  if (!session || (!text && !files.length) || state.sendingMessage) return;
861
971
  const key = sessionKey(session);
972
+ const streaming = session.streamReplies ?? Boolean(state.config?.streamReplies);
973
+ let progressSequence = 0;
862
974
  state.sendingMessage = true;
863
975
  state.sessionMessages.push({
864
976
  id: `pending-${Date.now()}`,
@@ -881,10 +993,29 @@ async function sendSessionMessage(event) {
881
993
  const body = new FormData();
882
994
  body.append("text", text);
883
995
  files.forEach((file) => body.append("files", file, file.name));
884
- await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`, {
885
- method: "POST",
886
- body
887
- });
996
+ const url = `/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`;
997
+ if (streaming) {
998
+ await streamApi(`${url}?stream=1`, { method: "POST", body }, (streamEvent) => {
999
+ if (state.selectedSessionKey !== key) return;
1000
+ if (streamEvent.type === "progress" && streamEvent.message?.trim()) {
1001
+ state.sessionMessages.push({
1002
+ id: `progress-${Date.now()}-${progressSequence++}`,
1003
+ role: "assistant",
1004
+ text: streamEvent.message.trim(),
1005
+ kind: "progress",
1006
+ createdAt: new Date().toISOString(),
1007
+ attachments: []
1008
+ });
1009
+ scheduleStreamingRender();
1010
+ }
1011
+ if (streamEvent.type === "done" && streamEvent.result?.message) {
1012
+ state.sessionMessages.push(streamEvent.result.message);
1013
+ scheduleStreamingRender();
1014
+ }
1015
+ });
1016
+ } else {
1017
+ await api(url, { method: "POST", body });
1018
+ }
888
1019
  await refreshData(false);
889
1020
  if (state.selectedSessionKey === key) {
890
1021
  await loadSelectedSessionMessages();
@@ -903,6 +1034,15 @@ async function sendSessionMessage(event) {
903
1034
  }
904
1035
  }
905
1036
 
1037
+ function scheduleStreamingRender() {
1038
+ if (streamingRenderFrame) return;
1039
+ streamingRenderFrame = requestAnimationFrame(() => {
1040
+ streamingRenderFrame = 0;
1041
+ renderChatPanel();
1042
+ scrollChatToEnd();
1043
+ });
1044
+ }
1045
+
906
1046
  function handleChatFileSelection(event) {
907
1047
  const nextFiles = [...event.target.files];
908
1048
  event.target.value = "";
@@ -1115,7 +1255,8 @@ async function saveSettings(event) {
1115
1255
  codexBackend: document.querySelector("#backendInput").value,
1116
1256
  codexExecSandbox: document.querySelector("#sandboxInput").value || null,
1117
1257
  model: document.querySelector("#modelInput").value.trim(),
1118
- effort: document.querySelector("#effortInput").value.trim()
1258
+ effort: document.querySelector("#effortInput").value.trim(),
1259
+ streamReplies: document.querySelector("#streamRepliesInput").checked
1119
1260
  }
1120
1261
  });
1121
1262
  state.config = result.config;
@@ -1159,6 +1300,46 @@ async function api(url, options = {}) {
1159
1300
  return data;
1160
1301
  }
1161
1302
 
1303
+ async function streamApi(url, options, onEvent) {
1304
+ const headers = {};
1305
+ if (state.requestToken) headers["X-Codex-Weixin-Token"] = state.requestToken;
1306
+ const response = await fetch(url, {
1307
+ method: options.method || "POST",
1308
+ headers,
1309
+ body: options.body
1310
+ });
1311
+ const contentType = response.headers.get("content-type") || "";
1312
+ if (!contentType.startsWith("application/x-ndjson")) {
1313
+ const data = await response.json().catch(() => ({}));
1314
+ if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
1315
+ return data.result;
1316
+ }
1317
+ if (!response.ok || !response.body) {
1318
+ throw new Error(`请求失败 (${response.status})`);
1319
+ }
1320
+ const reader = response.body.getReader();
1321
+ const decoder = new TextDecoder();
1322
+ let buffer = "";
1323
+ let result;
1324
+ const consumeLine = (line) => {
1325
+ if (!line.trim()) return;
1326
+ const streamEvent = JSON.parse(line);
1327
+ if (streamEvent.type === "error") throw new Error(streamEvent.error || "过程进度失败");
1328
+ onEvent(streamEvent);
1329
+ if (streamEvent.type === "done") result = streamEvent.result;
1330
+ };
1331
+ while (true) {
1332
+ const { value, done } = await reader.read();
1333
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
1334
+ const lines = buffer.split(/\r?\n/);
1335
+ buffer = lines.pop() || "";
1336
+ for (const line of lines) consumeLine(line);
1337
+ if (done) break;
1338
+ }
1339
+ if (buffer) consumeLine(buffer);
1340
+ return result;
1341
+ }
1342
+
1162
1343
  function emptyState(icon, title, description = "", action = "") {
1163
1344
  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>`;
1164
1345
  }
@@ -109,6 +109,14 @@
109
109
  <span>推理强度</span>
110
110
  <select id="sessionEffortInput" disabled><option value="">继承全局设置</option></select>
111
111
  </label>
112
+ <label>
113
+ <span>过程进度</span>
114
+ <select id="sessionStreamInput" disabled>
115
+ <option value="">继承全局设置</option>
116
+ <option value="on">开启</option>
117
+ <option value="off">关闭</option>
118
+ </select>
119
+ </label>
112
120
  </div>
113
121
  <div class="chat-messages" id="chatMessages" aria-live="polite"></div>
114
122
  <form class="chat-composer" id="chatComposer">
@@ -184,6 +192,26 @@
184
192
  </select>
185
193
  <small class="field-hint effective-setting"><span>当前生效</span><strong id="effectiveEffortValue">正在读取</strong></small>
186
194
  </label>
195
+ <label class="toggle-field" for="streamRepliesInput">
196
+ <span>过程进度</span>
197
+ <span class="toggle-control">
198
+ <input id="streamRepliesInput" name="streamReplies" type="checkbox">
199
+ <span aria-hidden="true"></span>
200
+ </span>
201
+ </label>
202
+ </div>
203
+ </div>
204
+ <div class="settings-group">
205
+ <h2>版本</h2>
206
+ <div class="settings-update">
207
+ <span class="settings-update-version">
208
+ <small>当前版本</small>
209
+ <strong id="settingsVersionValue">--</strong>
210
+ </span>
211
+ <button class="button button-secondary" id="updateCheckButton" type="button">
212
+ <i data-lucide="refresh-cw"></i>
213
+ <span>检查更新</span>
214
+ </button>
187
215
  </div>
188
216
  </div>
189
217
  <div class="form-actions">
@@ -206,9 +234,9 @@
206
234
  </div>
207
235
  <div class="modal-body update-modal-body">
208
236
  <div class="update-version-flow" aria-label="版本升级">
209
- <span><small>当前版本</small><strong id="updateCurrentVersion">--</strong></span>
210
- <i data-lucide="arrow-right" aria-hidden="true"></i>
211
- <span class="is-latest"><small>最新版本</small><strong id="updateLatestVersion">--</strong></span>
237
+ <span class="update-version-item"><small>当前版本</small><strong id="updateCurrentVersion">--</strong></span>
238
+ <span class="update-version-arrow" aria-hidden="true"><i data-lucide="arrow-right"></i></span>
239
+ <span class="update-version-item is-latest"><small>最新版本</small><strong id="updateLatestVersion">--</strong></span>
212
240
  </div>
213
241
  <p class="update-description">更新期间微信连接会短暂中断,完成后服务将自动重启并恢复连接。</p>
214
242
  <div class="update-progress" id="updateProgress" aria-live="polite" hidden>
@@ -165,7 +165,7 @@ h2 { margin: 0; font-size: 19px; line-height: 1.3; }
165
165
  .chat-header { min-height: 82px; padding: 17px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 18px; }
166
166
  .chat-header h2 { max-width: 620px; overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; }
167
167
  .chat-context { max-width: 720px; margin: 5px 0 0; overflow: hidden; color: var(--ink-soft); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
168
- .chat-runtime-toolbar { min-height: 54px; padding: 9px 20px; border-bottom: 1px solid var(--line); background: #f7f9f7; display: grid; grid-template-columns: auto minmax(190px, 1fr) minmax(170px, .72fr); align-items: center; gap: 14px; }
168
+ .chat-runtime-toolbar { min-height: 54px; padding: 9px 20px; border-bottom: 1px solid var(--line); background: #f7f9f7; display: grid; grid-template-columns: auto minmax(170px, 1fr) minmax(150px, .72fr) minmax(130px, .55fr); align-items: center; gap: 12px; }
169
169
  .chat-runtime-title { color: var(--ink-soft); display: inline-flex; align-items: center; gap: 6px; font-size: 9px; font-weight: 800; letter-spacing: .04em; white-space: nowrap; }
170
170
  .chat-runtime-title svg { width: 14px; height: 14px; color: var(--green-dark); }
171
171
  .chat-runtime-toolbar label { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 7px; }
@@ -176,6 +176,16 @@ h2 { margin: 0; font-size: 19px; line-height: 1.3; }
176
176
  .chat-message.is-user { margin-left: auto; justify-items: end; }
177
177
  .chat-message.is-assistant { margin-right: auto; justify-items: start; }
178
178
  .chat-message.has-attachments { width: min(78%, 720px); }
179
+ .chat-progress-group { width: min(78%, 720px); margin: 0 0 22px; color: var(--ink-soft); }
180
+ .chat-progress-group summary { min-height: 32px; padding: 0 3px; list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 14px; cursor: pointer; font-size: 10px; font-weight: 700; }
181
+ .chat-progress-group summary::-webkit-details-marker { display: none; }
182
+ .chat-progress-group summary:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
183
+ .progress-summary-title { color: var(--ink); display: inline-flex; align-items: center; gap: 6px; }
184
+ .progress-summary-title svg { width: 14px; height: 14px; color: var(--green-dark); transition: transform 160ms ease; }
185
+ .chat-progress-group[open] .progress-summary-title svg { transform: rotate(90deg); }
186
+ .progress-list { margin: 5px 0 0 8px; padding: 8px 12px 8px 24px; border-left: 2px solid #b9c9c0; font-size: 11px; line-height: 1.6; }
187
+ .progress-list li + li { margin-top: 7px; }
188
+ .progress-list p { margin: 0; }
179
189
  .message-meta { padding: 0 3px; color: var(--ink-soft); display: flex; align-items: center; gap: 8px; font-size: 9px; font-weight: 750; }
180
190
  .message-meta time { font-weight: 550; }
181
191
  .message-bubble { max-width: 100%; padding: 13px 15px; border: 1px solid var(--line); border-radius: 7px; background: white; box-shadow: 0 4px 14px rgb(23 34 29 / 6%); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
@@ -248,8 +258,21 @@ h2 { margin: 0; font-size: 19px; line-height: 1.3; }
248
258
  .settings-form { border-top: 1px solid var(--ink); }
249
259
  .settings-group { padding: 26px 0 30px; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 30px; }
250
260
  .settings-group h2 { font-size: 14px; }
251
- .settings-group > label, .field-grid { width: 100%; max-width: 720px; grid-column: 2; }
261
+ .settings-group > label, .field-grid, .settings-update { width: 100%; max-width: 720px; grid-column: 2; }
252
262
  .field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
263
+ .settings-update { min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
264
+ .settings-update-version { min-width: 0; display: grid; gap: 4px; }
265
+ .settings-update-version small { color: var(--ink-soft); font-size: 9px; font-weight: 700; }
266
+ .settings-update-version strong { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 14px; }
267
+ .button.is-loading svg { animation: spin .8s linear infinite; }
268
+ .toggle-field { min-height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); background: #f8faf8; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
269
+ .toggle-control { width: 38px; height: 22px; position: relative; flex: 0 0 38px; }
270
+ .toggle-control input { position: absolute; width: 1px; height: 1px; opacity: 0; }
271
+ .toggle-control > span { position: absolute; inset: 0; border: 1px solid #aeb8b2; border-radius: 11px; background: #dfe4e1; transition: background-color .16s ease, border-color .16s ease; }
272
+ .toggle-control > span::after { content: ""; width: 16px; height: 16px; position: absolute; left: 2px; top: 2px; border-radius: 50%; background: white; box-shadow: 0 1px 3px rgb(23 34 29 / 24%); transition: transform .16s ease; }
273
+ .toggle-control input:checked + span { border-color: var(--green-dark); background: var(--green); }
274
+ .toggle-control input:checked + span::after { transform: translateX(16px); }
275
+ .toggle-control input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--focus) 30%, transparent); outline-offset: 2px; }
253
276
  label { min-width: 0; display: grid; gap: 8px; color: var(--ink-soft); font-size: 11px; font-weight: 700; }
254
277
  .field-hint { color: var(--ink-soft); font-size: 10px; font-weight: 500; line-height: 1.5; }
255
278
  .effective-setting { min-width: 0; display: flex; align-items: center; gap: 7px; }
@@ -273,11 +296,14 @@ input:focus, select:focus, textarea:focus { border-color: var(--focus); outline:
273
296
  .update-modal-icon { width: 42px; height: 42px; flex: 0 0 42px; border-radius: var(--radius); color: var(--green-dark); background: var(--green-soft); display: grid; place-items: center; }
274
297
  .update-modal-icon svg { width: 21px; height: 21px; }
275
298
  .update-modal-body { display: grid; gap: 17px; }
276
- .update-version-flow { min-height: 86px; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--canvas); display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 14px; }
277
- .update-version-flow > span { min-width: 0; }
299
+ .update-version-flow { min-height: 86px; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--canvas); display: grid; grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr); align-items: center; gap: 12px; }
300
+ .update-version-item { min-width: 0; }
278
301
  .update-version-flow small { margin-bottom: 5px; color: var(--ink-soft); font-size: 9px; font-weight: 750; display: block; }
279
302
  .update-version-flow strong { overflow: hidden; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; display: block; }
280
- .update-version-flow > svg { width: 17px; height: 17px; color: #84908a; }
303
+ .update-version-arrow { width: 100%; color: #7e8b84; display: flex; align-items: center; gap: 5px; }
304
+ .update-version-arrow::before { content: ""; height: 1px; flex: 1; background: #c5cec9; }
305
+ .update-version-arrow svg { width: 15px; height: 15px; flex: 0 0 15px; stroke-width: 1.8; }
306
+ .update-version-flow .is-latest { text-align: right; }
281
307
  .update-version-flow .is-latest strong { color: var(--green-dark); }
282
308
  .update-description { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.65; }
283
309
  .update-progress { min-height: 64px; padding: 12px 14px; border: 1px solid #b9ddc7; border-radius: var(--radius); background: var(--green-soft); display: flex; align-items: center; gap: 13px; }
@@ -365,7 +391,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--focus); outline:
365
391
  .composer-files { padding-left: 45px; }
366
392
  .composer-hint { margin-left: 45px; }
367
393
  .settings-group { grid-template-columns: 1fr; gap: 16px; }
368
- .settings-group > label, .field-grid { grid-column: 1; }
394
+ .settings-group > label, .field-grid, .settings-update { grid-column: 1; }
369
395
  .field-grid { grid-template-columns: 1fr; }
370
396
  .qr-stage { min-height: 342px; padding-inline: 16px; }
371
397
  .qr-frame { width: min(280px, calc(100vw - 72px)); height: min(280px, calc(100vw - 72px)); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-weixin",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Local multi-account WeChat server for OpenAI Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Xuechao Zou",