codex-weixin 0.2.6 → 0.2.7

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
@@ -25,6 +25,7 @@ const MAX_CHAT_FILE_BYTES = 50 * 1024 * 1024;
25
25
  const DISMISSED_UPDATE_KEY = "codex-weixin.dismissed-update";
26
26
  const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
27
27
  const UPDATE_RECONNECT_TIMEOUT_MS = 90 * 1000;
28
+ let streamingRenderFrame = 0;
28
29
 
29
30
  const els = {};
30
31
 
@@ -48,6 +49,7 @@ document.addEventListener("DOMContentLoaded", () => {
48
49
  sessionRuntimeToolbar: document.querySelector("#sessionRuntimeToolbar"),
49
50
  sessionModelInput: document.querySelector("#sessionModelInput"),
50
51
  sessionEffortInput: document.querySelector("#sessionEffortInput"),
52
+ sessionStreamInput: document.querySelector("#sessionStreamInput"),
51
53
  runningAccountMetric: document.querySelector("#runningAccountMetric"),
52
54
  sessionMetric: document.querySelector("#sessionMetric"),
53
55
  workspaceMetric: document.querySelector("#workspaceMetric"),
@@ -80,6 +82,7 @@ function bindEvents() {
80
82
  document.querySelector("#modelInput").addEventListener("change", () => renderEffortOptions(""));
81
83
  els.sessionModelInput.addEventListener("change", () => void handleSessionModelChange());
82
84
  els.sessionEffortInput.addEventListener("change", () => void saveSessionRuntimeSettings());
85
+ els.sessionStreamInput.addEventListener("change", () => void saveSessionRuntimeSettings());
83
86
  document.querySelector("#accountForm").addEventListener("submit", (event) => void saveAccountRemark(event));
84
87
  document.querySelector("#sessionForm").addEventListener("submit", (event) => void saveSession(event));
85
88
  document.querySelector("#sessionSenderInput").addEventListener("change", updateNewSessionDefaultTitle);
@@ -441,6 +444,7 @@ function renderSettings() {
441
444
  document.querySelector("#allowedWorkspacesInput").value = (state.config.allowedWorkspaces || []).join("\n");
442
445
  document.querySelector("#backendInput").value = state.config.codexBackend || "auto";
443
446
  document.querySelector("#sandboxInput").value = state.config.codexExecSandbox || "";
447
+ document.querySelector("#streamRepliesInput").checked = Boolean(state.config.streamReplies);
444
448
  renderModelOptions();
445
449
  document.querySelector("#effectiveModelValue").textContent = state.codexRuntime?.model || state.config.model || "Codex 默认";
446
450
  document.querySelector("#effectiveEffortValue").textContent = state.codexRuntime?.effort || state.config.effort || "Codex 默认";
@@ -691,23 +695,79 @@ function renderChatPanel() {
691
695
  return;
692
696
  }
693
697
  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" : ""}">
698
+ const html = renderConversationMessages(state.sessionMessages, responding) + (responding ? renderTypingIndicator() : "");
699
+ setChatMessagesHtml(html, renderKey);
700
+ }
701
+
702
+ function renderConversationMessages(messages, responding) {
703
+ const html = [];
704
+ let lastUserCreatedAt;
705
+ let index = 0;
706
+ while (index < messages.length) {
707
+ const message = messages[index];
708
+ if (message.kind === "progress") {
709
+ const progress = [];
710
+ while (index < messages.length && messages[index].kind === "progress") {
711
+ progress.push(messages[index]);
712
+ index += 1;
713
+ }
714
+ const nextMessage = messages[index];
715
+ const active = responding && !nextMessage;
716
+ const completedAt = nextMessage?.role === "assistant"
717
+ ? nextMessage.createdAt
718
+ : progress.at(-1)?.createdAt;
719
+ html.push(renderProgressGroup(progress, lastUserCreatedAt, completedAt, active));
720
+ continue;
721
+ }
722
+ if (message.role === "user") lastUserCreatedAt = message.createdAt;
723
+ html.push(renderChatMessage(message));
724
+ index += 1;
725
+ }
726
+ return html.join("");
727
+ }
728
+
729
+ function renderChatMessage(message) {
730
+ return `<article class="chat-message is-${escapeAttr(message.role)}${message.attachments?.length ? " has-attachments" : ""}">
695
731
  <div class="message-meta"><span>${message.role === "user" ? "你" : "Codex"}</span>${message.createdAt ? `<time datetime="${escapeAttr(message.createdAt)}">${escapeHtml(messageTime(message.createdAt))}</time>` : ""}</div>
696
732
  <div class="message-bubble">${message.text ? renderMarkdown(message.text) : ""}${renderMessageAttachments(message.attachments)}</div>
697
- </article>`).join("") + (responding ? renderTypingIndicator() : "");
698
- setChatMessagesHtml(html, renderKey);
733
+ </article>`;
734
+ }
735
+
736
+ function renderProgressGroup(messages, startedAt, completedAt, active) {
737
+ const duration = formatProcessingDuration(startedAt, active ? new Date().toISOString() : completedAt);
738
+ return `<details class="chat-progress-group"${active ? " open" : ""}>
739
+ <summary>
740
+ <span class="progress-summary-title"><i data-lucide="chevron-right"></i>处理过程</span>
741
+ <span>${active ? "已处理" : "处理用时"} ${escapeHtml(duration)}</span>
742
+ </summary>
743
+ <ol class="progress-list">${messages.map((message) => `<li>${renderMarkdown(message.text)}</li>`).join("")}</ol>
744
+ </details>`;
745
+ }
746
+
747
+ function formatProcessingDuration(startValue, endValue) {
748
+ const start = new Date(startValue || "").getTime();
749
+ const end = new Date(endValue || "").getTime();
750
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--";
751
+ const seconds = Math.max(0, Math.round((end - start) / 1000));
752
+ if (seconds < 60) return `${seconds} 秒`;
753
+ const minutes = Math.floor(seconds / 60);
754
+ const remaining = seconds % 60;
755
+ return remaining ? `${minutes} 分 ${remaining} 秒` : `${minutes} 分钟`;
699
756
  }
700
757
 
701
758
  function renderSessionRuntimeControls(session) {
702
759
  const disabled = !session || state.sendingMessage || state.savingSessionRuntime;
703
760
  els.sessionModelInput.disabled = disabled;
704
761
  els.sessionEffortInput.disabled = disabled;
762
+ els.sessionStreamInput.disabled = disabled;
705
763
  const renderKey = session ? [
706
764
  sessionKey(session),
707
765
  session.model || "",
708
766
  session.effort || "",
767
+ typeof session.streamReplies === "boolean" ? String(session.streamReplies) : "inherit",
709
768
  state.config?.model || "",
710
769
  state.config?.effort || "",
770
+ String(Boolean(state.config?.streamReplies)),
711
771
  state.codexRuntime?.model || "",
712
772
  state.codexRuntime?.effort || "",
713
773
  state.codexModels.length
@@ -718,6 +778,7 @@ function renderSessionRuntimeControls(session) {
718
778
  if (!session) {
719
779
  els.sessionModelInput.innerHTML = '<option value="">选择会话后设置</option>';
720
780
  els.sessionEffortInput.innerHTML = '<option value="">选择会话后设置</option>';
781
+ els.sessionStreamInput.innerHTML = '<option value="">选择会话后设置</option>';
721
782
  return;
722
783
  }
723
784
 
@@ -739,6 +800,14 @@ function renderSessionRuntimeControls(session) {
739
800
  els.sessionModelInput.innerHTML = options.map((option) => `<option value="${escapeAttr(option.value)}"${option.title ? ` title="${escapeAttr(option.title)}"` : ""}>${escapeHtml(option.label)}</option>`).join("");
740
801
  els.sessionModelInput.value = session.model || "";
741
802
  setSessionEffortOptions(session.model || "", session.effort || "");
803
+ els.sessionStreamInput.innerHTML = [
804
+ `<option value="">继承全局(${state.config?.streamReplies ? "开启" : "关闭"})</option>`,
805
+ '<option value="on">开启</option>',
806
+ '<option value="off">关闭</option>'
807
+ ].join("");
808
+ els.sessionStreamInput.value = typeof session.streamReplies === "boolean"
809
+ ? session.streamReplies ? "on" : "off"
810
+ : "";
742
811
  }
743
812
 
744
813
  function setSessionEffortOptions(modelOverride, preferredEffort) {
@@ -783,18 +852,23 @@ async function saveSessionRuntimeSettings() {
783
852
  const key = sessionKey(session);
784
853
  const model = els.sessionModelInput.value;
785
854
  const effort = els.sessionEffortInput.value;
855
+ const stream = els.sessionStreamInput.value;
786
856
  state.savingSessionRuntime = true;
787
857
  renderChatPanel();
788
858
  try {
789
859
  const result = await api(`/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}`, {
790
860
  method: "PATCH",
791
- body: { model: model || null, effort: effort || null }
861
+ body: {
862
+ model: model || null,
863
+ effort: effort || null,
864
+ streamReplies: stream ? stream === "on" : null
865
+ }
792
866
  });
793
867
  const index = state.sessions.findIndex((candidate) => sessionKey(candidate) === key);
794
868
  if (index >= 0) state.sessions[index] = result.session;
795
869
  els.sessionRuntimeToolbar.dataset.renderKey = "";
796
870
  renderSessions();
797
- toast("会话模型设置已更新");
871
+ toast("会话设置已更新");
798
872
  } catch (error) {
799
873
  toast(error.message, true);
800
874
  await refreshData(false);
@@ -859,6 +933,8 @@ async function sendSessionMessage(event) {
859
933
  const files = [...state.chatFiles];
860
934
  if (!session || (!text && !files.length) || state.sendingMessage) return;
861
935
  const key = sessionKey(session);
936
+ const streaming = session.streamReplies ?? Boolean(state.config?.streamReplies);
937
+ let progressSequence = 0;
862
938
  state.sendingMessage = true;
863
939
  state.sessionMessages.push({
864
940
  id: `pending-${Date.now()}`,
@@ -881,10 +957,29 @@ async function sendSessionMessage(event) {
881
957
  const body = new FormData();
882
958
  body.append("text", text);
883
959
  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
- });
960
+ const url = `/api/sessions/${encodeURIComponent(session.accountId)}/${encodeURIComponent(session.id)}/messages`;
961
+ if (streaming) {
962
+ await streamApi(`${url}?stream=1`, { method: "POST", body }, (streamEvent) => {
963
+ if (state.selectedSessionKey !== key) return;
964
+ if (streamEvent.type === "progress" && streamEvent.message?.trim()) {
965
+ state.sessionMessages.push({
966
+ id: `progress-${Date.now()}-${progressSequence++}`,
967
+ role: "assistant",
968
+ text: streamEvent.message.trim(),
969
+ kind: "progress",
970
+ createdAt: new Date().toISOString(),
971
+ attachments: []
972
+ });
973
+ scheduleStreamingRender();
974
+ }
975
+ if (streamEvent.type === "done" && streamEvent.result?.message) {
976
+ state.sessionMessages.push(streamEvent.result.message);
977
+ scheduleStreamingRender();
978
+ }
979
+ });
980
+ } else {
981
+ await api(url, { method: "POST", body });
982
+ }
888
983
  await refreshData(false);
889
984
  if (state.selectedSessionKey === key) {
890
985
  await loadSelectedSessionMessages();
@@ -903,6 +998,15 @@ async function sendSessionMessage(event) {
903
998
  }
904
999
  }
905
1000
 
1001
+ function scheduleStreamingRender() {
1002
+ if (streamingRenderFrame) return;
1003
+ streamingRenderFrame = requestAnimationFrame(() => {
1004
+ streamingRenderFrame = 0;
1005
+ renderChatPanel();
1006
+ scrollChatToEnd();
1007
+ });
1008
+ }
1009
+
906
1010
  function handleChatFileSelection(event) {
907
1011
  const nextFiles = [...event.target.files];
908
1012
  event.target.value = "";
@@ -1115,7 +1219,8 @@ async function saveSettings(event) {
1115
1219
  codexBackend: document.querySelector("#backendInput").value,
1116
1220
  codexExecSandbox: document.querySelector("#sandboxInput").value || null,
1117
1221
  model: document.querySelector("#modelInput").value.trim(),
1118
- effort: document.querySelector("#effortInput").value.trim()
1222
+ effort: document.querySelector("#effortInput").value.trim(),
1223
+ streamReplies: document.querySelector("#streamRepliesInput").checked
1119
1224
  }
1120
1225
  });
1121
1226
  state.config = result.config;
@@ -1159,6 +1264,46 @@ async function api(url, options = {}) {
1159
1264
  return data;
1160
1265
  }
1161
1266
 
1267
+ async function streamApi(url, options, onEvent) {
1268
+ const headers = {};
1269
+ if (state.requestToken) headers["X-Codex-Weixin-Token"] = state.requestToken;
1270
+ const response = await fetch(url, {
1271
+ method: options.method || "POST",
1272
+ headers,
1273
+ body: options.body
1274
+ });
1275
+ const contentType = response.headers.get("content-type") || "";
1276
+ if (!contentType.startsWith("application/x-ndjson")) {
1277
+ const data = await response.json().catch(() => ({}));
1278
+ if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
1279
+ return data.result;
1280
+ }
1281
+ if (!response.ok || !response.body) {
1282
+ throw new Error(`请求失败 (${response.status})`);
1283
+ }
1284
+ const reader = response.body.getReader();
1285
+ const decoder = new TextDecoder();
1286
+ let buffer = "";
1287
+ let result;
1288
+ const consumeLine = (line) => {
1289
+ if (!line.trim()) return;
1290
+ const streamEvent = JSON.parse(line);
1291
+ if (streamEvent.type === "error") throw new Error(streamEvent.error || "过程进度失败");
1292
+ onEvent(streamEvent);
1293
+ if (streamEvent.type === "done") result = streamEvent.result;
1294
+ };
1295
+ while (true) {
1296
+ const { value, done } = await reader.read();
1297
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
1298
+ const lines = buffer.split(/\r?\n/);
1299
+ buffer = lines.pop() || "";
1300
+ for (const line of lines) consumeLine(line);
1301
+ if (done) break;
1302
+ }
1303
+ if (buffer) consumeLine(buffer);
1304
+ return result;
1305
+ }
1306
+
1162
1307
  function emptyState(icon, title, description = "", action = "") {
1163
1308
  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
1309
  }
@@ -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,13 @@
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>
187
202
  </div>
188
203
  </div>
189
204
  <div class="form-actions">
@@ -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; }
@@ -250,6 +260,14 @@ h2 { margin: 0; font-size: 19px; line-height: 1.3; }
250
260
  .settings-group h2 { font-size: 14px; }
251
261
  .settings-group > label, .field-grid { width: 100%; max-width: 720px; grid-column: 2; }
252
262
  .field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
263
+ .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; }
264
+ .toggle-control { width: 38px; height: 22px; position: relative; flex: 0 0 38px; }
265
+ .toggle-control input { position: absolute; width: 1px; height: 1px; opacity: 0; }
266
+ .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; }
267
+ .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; }
268
+ .toggle-control input:checked + span { border-color: var(--green-dark); background: var(--green); }
269
+ .toggle-control input:checked + span::after { transform: translateX(16px); }
270
+ .toggle-control input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--focus) 30%, transparent); outline-offset: 2px; }
253
271
  label { min-width: 0; display: grid; gap: 8px; color: var(--ink-soft); font-size: 11px; font-weight: 700; }
254
272
  .field-hint { color: var(--ink-soft); font-size: 10px; font-weight: 500; line-height: 1.5; }
255
273
  .effective-setting { min-width: 0; display: flex; align-items: center; gap: 7px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-weixin",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Local multi-account WeChat server for OpenAI Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Xuechao Zou",