chatccc 0.2.92 → 0.2.93

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.92",
3
+ "version": "0.2.93",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -41,7 +41,8 @@ import {
41
41
  setSessionPlatform,
42
42
  recordChatPlatform,
43
43
  _getPlatformForChatForTest,
44
- ensureDisplayLoop,
44
+ startUnifiedDisplayLoop,
45
+ stopUnifiedDisplayLoop,
45
46
  } from "../session.ts";
46
47
  import {
47
48
  activePrompts,
@@ -184,7 +185,7 @@ describe("chat platform routing", () => {
184
185
  // processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
185
186
  // ---------------------------------------------------------------------------
186
187
 
187
- describe("ensureDisplayLoop WeChat delta", () => {
188
+ describe("unified display loop WeChat delta", () => {
188
189
  beforeEach(() => {
189
190
  vi.useFakeTimers();
190
191
  resetState();
@@ -193,6 +194,7 @@ describe("ensureDisplayLoop WeChat delta", () => {
193
194
  });
194
195
 
195
196
  afterEach(() => {
197
+ stopUnifiedDisplayLoop();
196
198
  resetBindingState();
197
199
  vi.useRealTimers();
198
200
  });
@@ -212,16 +214,31 @@ describe("ensureDisplayLoop WeChat delta", () => {
212
214
  tool: "claude",
213
215
  });
214
216
 
217
+ // 模拟 runAgentSession 创建的 WeChat display 条目
218
+ displayCards.set("chat-wechat", {
219
+ cardId: "",
220
+ sequence: 0,
221
+ cardBusy: false,
222
+ cardCreatedAt: Date.now(),
223
+ lastSentContent: "",
224
+ streamErrorNotified: false,
225
+ sessionId: "sid-wechat",
226
+ turnCount: 1,
227
+ dotCount: 0,
228
+ });
229
+
215
230
  mockStreamStates.set("sid-wechat", {
216
231
  accumulatedContent: "",
217
232
  finalReply: "partial reply",
233
+ status: "running",
218
234
  });
219
- ensureDisplayLoop("sid-wechat");
235
+ startUnifiedDisplayLoop();
220
236
  await vi.advanceTimersByTimeAsync(3000);
221
237
 
222
238
  mockStreamStates.set("sid-wechat", {
223
239
  accumulatedContent: "tool output\n",
224
240
  finalReply: "partial reply",
241
+ status: "running",
225
242
  });
226
243
  await vi.advanceTimersByTimeAsync(3000);
227
244
 
package/src/index.ts CHANGED
@@ -92,6 +92,7 @@ import {
92
92
  rebuildBindingsFromRegistry,
93
93
  resetState,
94
94
  setSessionPlatform,
95
+ startUnifiedDisplayLoop,
95
96
  } from "./session.ts";
96
97
  import {
97
98
  rebuildSessionChatsFromRegistry,
@@ -595,13 +596,14 @@ async function startBotServiceCore(): Promise<void> {
595
596
  // 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
596
597
  // 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
597
598
  resetState();
599
+ startUnifiedDisplayLoop();
598
600
  fixStaleStreamStates().then(async () => {
599
601
  const registry = await loadSessionRegistryForBinding();
600
602
  rebuildSessionChatsFromRegistry(registry);
601
603
  }).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
602
604
 
603
605
  // ⚠️ 关键设计:onReady / onReconnected 只重建只读映射,**绝不**清空运行时
604
- // 状态(activePrompts、displayLoops、sessionInfoMap、processedMessages)。
606
+ // 状态(activePrompts、sessionInfoMap、processedMessages)。
605
607
  // SDK 重连只是底层 WebSocket 抖动,业务层不受影响:
606
608
  // - 后台 generator 仍在跑、stream-state 仍在被写
607
609
  // - display loop 仍在向群推送
@@ -740,11 +740,6 @@ export async function handleCommand(
740
740
  .setChatAvatar(chatId, descriptionTool, "new")
741
741
  .catch(() => {});
742
742
 
743
- if (isSessionRunning(newSessionId)) {
744
- const { ensureDisplayLoop } = await import("./session.ts");
745
- ensureDisplayLoop(newSessionId);
746
- }
747
-
748
743
  await platform.sendCard(
749
744
  chatId,
750
745
  `${toolLabel} Session Reset`,
@@ -901,11 +896,6 @@ export async function handleCommand(
901
896
 
902
897
  platform.setChatAvatar(chatId, target.tool, "new").catch(() => {});
903
898
 
904
- if (isSessionRunning(target.sessionId)) {
905
- const { ensureDisplayLoop } = await import("./session.ts");
906
- ensureDisplayLoop(target.sessionId);
907
- }
908
-
909
899
  const targetToolLabel = toolDisplayName(target.tool);
910
900
  const busyNote = isSessionRunning(target.sessionId)
911
901
  ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。"
@@ -130,6 +130,8 @@ export interface DisplayCardState {
130
130
  cardCreatedAt: number;
131
131
  lastSentContent: string;
132
132
  streamErrorNotified: boolean;
133
+ /** 所属 session */
134
+ sessionId: string;
133
135
  /** 所属 turn */
134
136
  turnCount: number;
135
137
  /** 卡片轮转时记录的基线,轮转后只展示增量 */
@@ -139,12 +141,18 @@ export interface DisplayCardState {
139
141
  lastSentAccLen?: number;
140
142
  /** WeChat delta: 上次发送时的 finalReply */
141
143
  lastSentFinalReply?: string;
144
+ /** 点点点动画计数器(统一 display loop 每个卡片独立计数) */
145
+ dotCount: number;
142
146
  }
143
147
 
144
148
  export const displayCards = new Map<string, DisplayCardState>();
145
149
 
146
- /** displayLoops: sessionId 展示循环的 stop 函数 */
147
- export const displayLoops = new Map<string, () => void>();
150
+ /** 统一 display loop interval handle,由 session.ts 管理 */
151
+ export let unifiedDisplayLoopHandle: ReturnType<typeof setInterval> | null = null;
152
+
153
+ export function setUnifiedDisplayLoopHandle(h: ReturnType<typeof setInterval> | null): void {
154
+ unifiedDisplayLoopHandle = h;
155
+ }
148
156
 
149
157
  // ---------------------------------------------------------------------------
150
158
  // queuedMessages: sessionId → 缓存消息(生成中排队,队列最大长度 1)
@@ -201,6 +209,8 @@ export function resetBindingState(): void {
201
209
  activePrompts.clear();
202
210
  queuedMessages.clear();
203
211
  displayCards.clear();
204
- for (const stop of displayLoops.values()) stop();
205
- displayLoops.clear();
212
+ if (unifiedDisplayLoopHandle !== null) {
213
+ clearInterval(unifiedDisplayLoopHandle);
214
+ unifiedDisplayLoopHandle = null;
215
+ }
206
216
  }
package/src/session.ts CHANGED
@@ -46,7 +46,8 @@ import {
46
46
  isSessionRunning,
47
47
  activePrompts,
48
48
  displayCards,
49
- displayLoops,
49
+ unifiedDisplayLoopHandle,
50
+ setUnifiedDisplayLoopHandle,
50
51
  rebuildSessionChatsFromRegistry,
51
52
  recordLastActiveChat,
52
53
  getLastActiveChat,
@@ -171,8 +172,7 @@ export function resetState(): void {
171
172
  chatPlatformMap.clear();
172
173
  activePrompts.clear();
173
174
  displayCards.clear();
174
- for (const stop of displayLoops.values()) stop();
175
- displayLoops.clear();
175
+ stopUnifiedDisplayLoop();
176
176
  console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
177
177
  }
178
178
 
@@ -321,7 +321,7 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
321
321
  * 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
322
322
  * 导致同一 sessionId 双开 generator
323
323
  * - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
324
- * - **不动** displayCards / displayLoops:正在跑的 prompt 还需要它们继续推卡片
324
+ * - **不动** displayCards:正在跑的 prompt 还需要它们继续推卡片
325
325
  * - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
326
326
  * 去重 set 还在才能避免同一 prompt 跑两遍
327
327
  *
@@ -805,10 +805,9 @@ export async function runAgentSession(
805
805
  if (displayChatId) {
806
806
  const pp = platformForChat(displayChatId);
807
807
  const display = displayCards.get(displayChatId);
808
- const oldLoopExisted = displayLoops.has(sessionId);
809
808
 
810
809
  if (display && pp) {
811
- // display loop 被 kill 前来不及终结卡片现在终结
810
+ // 统一 display loop 被 cardBusy 挡住或尚未 tick 现在终结卡片
812
811
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
813
812
  const nextSeq = display.sequence + 1;
814
813
  const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
@@ -826,13 +825,13 @@ export async function runAgentSession(
826
825
  await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
827
826
  }
828
827
  pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
829
- } else if (oldLoopExisted && pp && prevState.finalReply) {
830
- // display loop 存在但尚未创建卡片(极快轮次),至少发送 finalReply
831
- // 同时持久化终结旧 turn 卡片:旧 loop mismatch 守卫可能已删 display entry
828
+ } else if (pp && prevState.finalReply) {
829
+ // display 记录但上一轮有 finalReply(极快轮次),至少发送
830
+ const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
832
831
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
833
832
  await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
834
833
  }
835
- // else: display loop 已自行终结(displayCards 无记录)→ 无需处理
834
+ // else: displayCards 无记录且无 finalReply 无需处理
836
835
  }
837
836
  }
838
837
 
@@ -841,7 +840,7 @@ export async function runAgentSession(
841
840
  await writeStreamState(initialState);
842
841
 
843
842
  // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
844
- // 放在 ensureDisplayLoop 之前,确保卡片立即可见(不等 3s 首次 tick)。
843
+ // 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
845
844
  const displayChatIdForNew = pickDisplayChat(sessionId);
846
845
  if (displayChatIdForNew) {
847
846
  const ppNew = platformForChat(displayChatIdForNew);
@@ -858,16 +857,28 @@ export async function runAgentSession(
858
857
  cardCreatedAt: Date.now(),
859
858
  lastSentContent: "",
860
859
  streamErrorNotified: false,
860
+ sessionId,
861
861
  turnCount: nextTurnCount,
862
+ dotCount: 0,
862
863
  });
863
864
  addCardToTurn(sessionId, nextTurnCount, cardId).catch(() => {});
864
865
  }
866
+ } else if (ppNew && ppNew.kind === "wechat") {
867
+ // WeChat: 无卡片,但需要 display entry 追踪已发送内容
868
+ displayCards.set(displayChatIdForNew, {
869
+ cardId: "",
870
+ sequence: 0,
871
+ cardBusy: false,
872
+ cardCreatedAt: Date.now(),
873
+ lastSentContent: "",
874
+ streamErrorNotified: false,
875
+ sessionId,
876
+ turnCount: nextTurnCount,
877
+ dotCount: 0,
878
+ });
865
879
  }
866
880
  }
867
881
 
868
- // 启动 display loop
869
- ensureDisplayLoop(sessionId);
870
-
871
882
  // 设置最后活跃群头像为 busy
872
883
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
873
884
  if (activeCid) {
@@ -1019,90 +1030,74 @@ export async function runAgentSession(
1019
1030
  }
1020
1031
 
1021
1032
  // ---------------------------------------------------------------------------
1022
- // ensureDisplayLoop每个 session 一个 display 循环,读文件更新最后活跃群的卡片
1033
+ // startUnifiedDisplayLoop全局统一 display 循环,遍历 displayCards 更新卡片
1034
+ // ---------------------------------------------------------------------------
1035
+ // 替代旧的 per-session ensureDisplayLoop,消除 kill/restart 竞态条件。
1036
+ // 单一定时器遍历所有 displayCards 条目,通过条目内的 sessionId 查找 stream state。
1023
1037
  // ---------------------------------------------------------------------------
1024
1038
 
1025
1039
  const CARD_ROTATE_MS = 9 * 60 * 1000;
1026
1040
 
1027
- export function ensureDisplayLoop(sessionId: string): void {
1028
- // 杀掉旧 loop(如果存在),避免竞态:旧 loop 正在处理 terminal 分支
1029
- // (发送完成卡片、清理 display)尚未 delete 自己,新 turn 的
1030
- // runAgentSession 调用 ensureDisplayLoop 时因 guard 直接返回,
1031
- // 导致新 turn 没有 display loop → 卡片永久不更新。
1032
- const oldStop = displayLoops.get(sessionId);
1033
- if (oldStop) {
1034
- console.log(`[${ts()}] [DISPLAY] ensureDisplayLoop restarting loop for ${sessionId} (old loop existed)`);
1035
- oldStop();
1036
- displayLoops.delete(sessionId);
1037
- }
1038
-
1039
- let dotCount = 0;
1041
+ export function startUnifiedDisplayLoop(): void {
1042
+ if (unifiedDisplayLoopHandle !== null) return;
1040
1043
 
1041
1044
  const interval = setInterval(() => {
1042
1045
  void (async () => {
1043
- const state = await readStreamState(sessionId);
1044
- if (!state) return;
1045
-
1046
- // pickDisplayChat lastActiveChat 已与本 session 解绑时返回 undefined,
1047
- // 避免 /newh 等改嫁场景下旧 session 仍向已离开群推卡片。
1048
- const chatId = pickDisplayChat(sessionId);
1049
- if (!chatId) {
1050
- // 无活跃群,若 session 已结束则停止 loop
1051
- if (state.status !== "running") {
1052
- clearInterval(interval);
1053
- displayLoops.delete(sessionId);
1054
- // 兜底:lastActiveChatMap 可能因进程重启丢失,从 registry 映射恢复头像
1055
- const fallbackChat = getChatsForSession(sessionId)[0];
1056
- const fallbackPlatform = fallbackChat ? platformForChat(fallbackChat) : null;
1057
- if (fallbackChat && fallbackPlatform) {
1058
- fallbackPlatform.setChatAvatar(fallbackChat, state.tool, "idle").catch(() => {});
1046
+ for (const [chatId, display] of displayCards) {
1047
+ if (display.cardBusy) continue;
1048
+
1049
+ const sessionId = display.sessionId;
1050
+ const state = await readStreamState(sessionId);
1051
+ if (!state) {
1052
+ displayCards.delete(chatId);
1053
+ continue;
1054
+ }
1055
+
1056
+ // 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
1057
+ // chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
1058
+ const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
1059
+ if (currentSessionForChat && currentSessionForChat !== sessionId) {
1060
+ if (state.status !== "running") {
1061
+ displayCards.delete(chatId);
1059
1062
  }
1063
+ continue;
1060
1064
  }
1061
- return;
1062
- }
1063
1065
 
1064
- // 交叉验证:chat 当前绑定的 session 是否仍是本 display loop 的 session。
1065
- // chat 已被切换到其他 session(如 /new p2p 未解绑旧 session 的历史遗留
1066
- // 或任何未来新增的切换路径),旧 loop 必须停推,避免向已离开的 chat 推送内容。
1067
- const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
1068
- if (currentSessionForChat && currentSessionForChat !== sessionId) {
1069
- if (state.status !== "running") {
1070
- clearInterval(interval);
1071
- displayLoops.delete(sessionId);
1066
+ // 验证 chat 仍是该 session 的最后活跃群
1067
+ const lastActive = getLastActiveChat(sessionId);
1068
+ if (lastActive !== chatId) {
1069
+ if (state.status !== "running") {
1070
+ displayCards.delete(chatId);
1071
+ }
1072
+ continue;
1072
1073
  }
1073
- return;
1074
- }
1075
1074
 
1076
- const isTerminal = state.status !== "running";
1077
-
1078
- try {
1079
- const p = platformForChat(chatId);
1080
- if (!p) return;
1081
- const display = displayCards.get(chatId);
1082
-
1083
- const isWechat = p.kind === "wechat";
1084
-
1085
- if (isTerminal) {
1086
- if (isWechat) {
1087
- // WeChat: 没有卡片需要终结,用 delta 逻辑发剩余内容,避免重发已推送的部分
1088
- // 分开追踪 accumulatedContent 和 finalReply,而非拼接后对比
1089
- const prevAccLen = display?.lastSentAccLen ?? 0;
1090
- const prevFinalReply = display?.lastSentFinalReply ?? "";
1091
- const accDelta = state.accumulatedContent.slice(prevAccLen);
1092
- let replyDelta: string;
1093
- if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1094
- replyDelta = state.finalReply.slice(prevFinalReply.length);
1075
+ const isTerminal = state.status !== "running";
1076
+
1077
+ try {
1078
+ const p = platformForChat(chatId);
1079
+ if (!p) continue;
1080
+
1081
+ const isWechat = p.kind === "wechat";
1082
+
1083
+ if (isTerminal) {
1084
+ if (isWechat) {
1085
+ const prevAccLen = display.lastSentAccLen ?? 0;
1086
+ const prevFinalReply = display.lastSentFinalReply ?? "";
1087
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
1088
+ let replyDelta: string;
1089
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1090
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
1091
+ } else {
1092
+ replyDelta = state.finalReply;
1093
+ }
1094
+ const remaining = (accDelta + replyDelta).trim();
1095
+ const tail = "━━━ 回答结束 ━━━";
1096
+ const finalMsg = remaining ? remaining + "\n" + tail : tail;
1097
+ await p.sendText(chatId, finalMsg).catch(() => {});
1098
+ displayCards.delete(chatId);
1095
1099
  } else {
1096
- replyDelta = state.finalReply;
1097
- }
1098
- const remaining = (accDelta + replyDelta).trim();
1099
- const tail = "━━━ 回答结束 ━━━";
1100
- const finalMsg = remaining ? remaining + "\n" + tail : tail;
1101
- await p.sendText(chatId, finalMsg).catch(() => {});
1102
- if (display) displayCards.delete(chatId);
1103
- } else {
1104
- // 发送最终结果(卡片平台)
1105
- if (display) {
1100
+ // 发送最终结果(卡片平台)
1106
1101
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
1107
1102
  const nextSeq = display.sequence + 1;
1108
1103
  const headerTitle = state.status === "stopped" ? "已停止" : "完成";
@@ -1113,99 +1108,55 @@ export function ensureDisplayLoop(sessionId: string): void {
1113
1108
  const finalSt = state.status === "stopped" ? "stopped" : "done";
1114
1109
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1115
1110
  displayCards.delete(chatId);
1116
- }
1117
- if (state.finalReply) {
1118
- await p.sendText(chatId, state.finalReply).catch(() => {});
1119
- } else if (!display && state.accumulatedContent.trim()) {
1120
- const short = truncateContent(state.accumulatedContent, 30, 4000);
1121
- await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
1122
- }
1123
- }
1124
- p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
1125
- } else {
1126
- // running: 创建或更新展示
1127
- if (isWechat) {
1128
- // WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
1129
- if (!display) {
1130
- displayCards.set(chatId, {
1131
- cardId: "",
1132
- sequence: 0,
1133
- cardBusy: false,
1134
- cardCreatedAt: Date.now(),
1135
- lastSentContent: "",
1136
- streamErrorNotified: false,
1137
- });
1138
- }
1139
- const d = displayCards.get(chatId);
1140
- if (!d || d.cardBusy) return;
1141
-
1142
- // 分开追踪 accumulatedContent 和 finalReply 的已发送位置。
1143
- // 如果只用 rawFull.startsWith(prevRaw),当新的 tool_use/tool_result
1144
- // 插入到 accumulatedContent 中间时,rawFull 不再以 prevRaw 开头,
1145
- // 会回退到发送完整内容 → 产生大量重复。
1146
- const prevAccLen = d.lastSentAccLen ?? 0;
1147
- const prevFinalReply = d.lastSentFinalReply ?? "";
1148
- const accDelta = state.accumulatedContent.slice(prevAccLen);
1149
- let replyDelta: string;
1150
- if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1151
- replyDelta = state.finalReply.slice(prevFinalReply.length);
1152
- } else {
1153
- replyDelta = state.finalReply;
1154
- }
1155
- const delta = (accDelta + replyDelta).trim();
1156
- if (!delta) return;
1157
-
1158
- d.cardBusy = true;
1159
- try {
1160
- const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
1161
- if (ok) {
1162
- d.lastSentAccLen = state.accumulatedContent.length;
1163
- d.lastSentFinalReply = state.finalReply;
1164
- d.lastSentContent = delta;
1165
- } else {
1166
- // 发送失败(限流等),不更新光标,下次合并重试
1167
- return;
1168
- }
1169
- } catch (err) {
1170
- console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
1171
- if (!d.streamErrorNotified) {
1172
- d.streamErrorNotified = true;
1173
- p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
1111
+ if (state.finalReply) {
1112
+ await p.sendText(chatId, state.finalReply).catch(() => {});
1113
+ } else if (state.accumulatedContent.trim()) {
1114
+ const short = truncateContent(state.accumulatedContent, 30, 4000);
1115
+ await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
1174
1116
  }
1175
- } finally {
1176
- d.cardBusy = false;
1177
1117
  }
1118
+ p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
1119
+ console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
1178
1120
  } else {
1179
- // 非 WeChat: 卡片流程
1180
- if (!display) {
1181
- // 兜底:runAgentSession 应已创建卡片,若没有则补建
1182
- const cardId = await p.cardCreate(buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
1183
- if (cardId) {
1184
- await p.cardSend(chatId, cardId).catch(() => null);
1185
- displayCards.set(chatId, {
1186
- cardId,
1187
- sequence: 1,
1188
- cardBusy: false,
1189
- cardCreatedAt: Date.now(),
1190
- lastSentContent: "",
1191
- streamErrorNotified: false,
1192
- turnCount: state.turnCount,
1193
- });
1194
- addCardToTurn(sessionId, state.turnCount, cardId).catch(() => {});
1121
+ // running: 创建或更新展示
1122
+ if (isWechat) {
1123
+ // WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
1124
+ const prevAccLen = display.lastSentAccLen ?? 0;
1125
+ const prevFinalReply = display.lastSentFinalReply ?? "";
1126
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
1127
+ let replyDelta: string;
1128
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1129
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
1130
+ } else {
1131
+ replyDelta = state.finalReply;
1195
1132
  }
1196
- } else {
1197
- if (display.cardBusy) return;
1133
+ const delta = (accDelta + replyDelta).trim();
1134
+ if (!delta) continue;
1198
1135
 
1199
- // 守卫:display 归属的 turn 与当前 stream state 不一致。
1200
- // 正常流程 runAgentSession 会终结旧 turn 并建新卡,
1201
- // 此处仅兜底处理异常情况(如进程重启后 display 残留)。
1136
+ display.cardBusy = true;
1137
+ try {
1138
+ const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
1139
+ if (ok) {
1140
+ display.lastSentAccLen = state.accumulatedContent.length;
1141
+ display.lastSentFinalReply = state.finalReply;
1142
+ display.lastSentContent = delta;
1143
+ }
1144
+ } catch (err) {
1145
+ console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
1146
+ if (!display.streamErrorNotified) {
1147
+ display.streamErrorNotified = true;
1148
+ p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
1149
+ }
1150
+ } finally {
1151
+ display.cardBusy = false;
1152
+ }
1153
+ } else {
1154
+ // 非 WeChat: 卡片流程
1202
1155
  if (display.turnCount !== state.turnCount) {
1203
1156
  console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
1204
- // 兜底:turn mismatch 意味着旧 turn 已结束但卡片未在飞书端终结,
1205
- // 持久化标记为 done,避免 turn-cards.json 残留 "active"
1206
1157
  finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
1207
1158
  displayCards.delete(chatId);
1208
- return;
1159
+ continue;
1209
1160
  }
1210
1161
 
1211
1162
  // 卡片轮转
@@ -1227,8 +1178,6 @@ export function ensureDisplayLoop(sessionId: string): void {
1227
1178
  display.cardId = newCardId;
1228
1179
  display.sequence = 1;
1229
1180
  display.cardCreatedAt = Date.now();
1230
- // 记录基线:分开追踪 accumulatedContent 长度和 finalReply,
1231
- // 避免 tool 内容中间插入时前缀匹配失败 → 回退发完整内容
1232
1181
  display.rotationAccLen = state.accumulatedContent.length;
1233
1182
  display.rotationFinalReply = state.finalReply;
1234
1183
  display.lastSentContent = "";
@@ -1239,11 +1188,10 @@ export function ensureDisplayLoop(sessionId: string): void {
1239
1188
  } finally {
1240
1189
  display.cardBusy = false;
1241
1190
  }
1242
- return;
1191
+ continue;
1243
1192
  }
1244
1193
 
1245
- // 轮转后:分开追踪 accumulatedContent 和 finalReply 增量,
1246
- // 避免 tool 内容插入中间时前缀匹配失败
1194
+ // 轮转后:分开追踪 accumulatedContent 和 finalReply 增量
1247
1195
  if (display.rotationAccLen !== undefined) {
1248
1196
  const accDelta = state.accumulatedContent.slice(display.rotationAccLen);
1249
1197
  const rotReply = display.rotationFinalReply ?? "";
@@ -1255,10 +1203,9 @@ export function ensureDisplayLoop(sessionId: string): void {
1255
1203
  }
1256
1204
  const delta = (accDelta + replyDelta).trim();
1257
1205
 
1258
- // 无论有无新内容,都追加点点点动画,避免轮转后卡片静止
1259
- dotCount = (dotCount % 9) + 1;
1260
- const displayContent = (delta || "") + "\n" + "。" .repeat(dotCount);
1261
- if (displayContent === display.lastSentContent) return;
1206
+ display.dotCount = (display.dotCount % 9) + 1;
1207
+ const displayContent = (delta || "") + "\n" + "。" .repeat(display.dotCount);
1208
+ if (displayContent === display.lastSentContent) continue;
1262
1209
 
1263
1210
  display.lastSentContent = displayContent;
1264
1211
  const deltaCard = buildProgressCard(truncateContent(displayContent) || "处理中...", { showStop: true, headerTitle: "生成中..." });
@@ -1276,12 +1223,12 @@ export function ensureDisplayLoop(sessionId: string): void {
1276
1223
  } finally {
1277
1224
  display.cardBusy = false;
1278
1225
  }
1279
- return;
1226
+ continue;
1280
1227
  }
1281
1228
 
1282
- dotCount = (dotCount % 9) + 1;
1283
- const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount);
1284
- if (fullContent === display.lastSentContent) return;
1229
+ display.dotCount = (display.dotCount % 9) + 1;
1230
+ const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(display.dotCount);
1231
+ if (fullContent === display.lastSentContent) continue;
1285
1232
 
1286
1233
  display.lastSentContent = fullContent;
1287
1234
  const cardContent = truncateContent(fullContent);
@@ -1302,23 +1249,26 @@ export function ensureDisplayLoop(sessionId: string): void {
1302
1249
  }
1303
1250
  }
1304
1251
  }
1252
+ } catch (err) {
1253
+ console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
1305
1254
  }
1306
- } catch (err) {
1307
- console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
1308
- }
1309
-
1310
- if (isTerminal) {
1311
- console.log(`[${ts()}] [DISPLAY] loop stopping for ${sessionId} (terminal state: ${state.status})`);
1312
- clearInterval(interval);
1313
- displayLoops.delete(sessionId);
1314
1255
  }
1315
1256
  })().catch((err: unknown) => {
1316
1257
  const e = err instanceof Error ? err : new Error(String(err));
1317
- console.error(`[${ts()}] Display loop uncaught for ${sessionId}: ${e.message}`);
1258
+ console.error(`[${ts()}] Unified display loop uncaught: ${e.message}`);
1318
1259
  });
1319
1260
  }, 3000);
1320
1261
 
1321
- displayLoops.set(sessionId, () => clearInterval(interval));
1262
+ setUnifiedDisplayLoopHandle(interval);
1263
+ console.log(`[${ts()}] [DISPLAY] Unified display loop started`);
1264
+ }
1265
+
1266
+ export function stopUnifiedDisplayLoop(): void {
1267
+ if (unifiedDisplayLoopHandle !== null) {
1268
+ clearInterval(unifiedDisplayLoopHandle);
1269
+ setUnifiedDisplayLoopHandle(null);
1270
+ console.log(`[${ts()}] [DISPLAY] Unified display loop stopped`);
1271
+ }
1322
1272
  }
1323
1273
 
1324
1274
  // ---------------------------------------------------------------------------