chatccc 0.2.50 → 0.2.52

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/src/session.ts CHANGED
@@ -20,12 +20,6 @@ import {
20
20
  ts,
21
21
  } from "./config.ts";
22
22
  import { buildProgressCard, getToolEmoji, truncateContent } from "./cards.ts";
23
- import {
24
- createCardKitCard,
25
- sendCardKitMessage,
26
- updateCardKitCard,
27
- } from "./cardkit.ts";
28
- import { sendTextReply, setChatAvatar } from "./feishu-platform.ts";
29
23
  import { logTrace } from "./trace.ts";
30
24
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
31
25
  import type { ToolAdapter } from "./adapters/adapter-interface.ts";
@@ -33,6 +27,7 @@ import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
33
27
  import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
34
28
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
35
29
  import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
30
+ import type { PlatformAdapter } from "./platform-adapter.ts";
36
31
  import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
37
32
  import {
38
33
  bindChatToSession,
@@ -58,6 +53,36 @@ export const MAX_PROCESSED = 5000;
58
53
  /** 每个 chatId 上一次已处理消息的时间戳,用于拦截延迟送达的旧消息 */
59
54
  export const lastMsgTimestamps = new Map<string, number>();
60
55
 
56
+ // ---------------------------------------------------------------------------
57
+ // 平台引用 —— session 模块通过此引用访问 IM 平台操作,
58
+ // 避免 import feishu-platform.ts 造成的耦合。
59
+ // 由 index.ts 在启动时调用 setSessionPlatform 注入。
60
+ // ---------------------------------------------------------------------------
61
+
62
+ let platformRef: PlatformAdapter | null = null;
63
+ const chatPlatformMap = new Map<string, PlatformAdapter>();
64
+
65
+ /** 注入当前 IM 平台适配器,供 session 模块使用 */
66
+ export function setSessionPlatform(platform: PlatformAdapter): void {
67
+ platformRef = platform;
68
+ }
69
+
70
+ export function recordChatPlatform(chatId: string, platform: PlatformAdapter): void {
71
+ chatPlatformMap.set(chatId, platform);
72
+ }
73
+
74
+ export function forgetChatPlatform(chatId: string): void {
75
+ chatPlatformMap.delete(chatId);
76
+ }
77
+
78
+ function platformForChat(chatId: string): PlatformAdapter | null {
79
+ return chatPlatformMap.get(chatId) ?? platformRef;
80
+ }
81
+
82
+ export function _getPlatformForChatForTest(chatId: string): PlatformAdapter | null {
83
+ return platformForChat(chatId);
84
+ }
85
+
61
86
  export let sessionGen = 0;
62
87
  /** @deprecated 使用 activePrompts (session-chat-binding.ts) + displayCards 替代 */
63
88
  export const chatSessionMap = new Map<string, {
@@ -85,6 +110,29 @@ export const sessionInfoMap = new Map<string, {
85
110
  tool: string;
86
111
  }>();
87
112
 
113
+ /**
114
+ * 清空所有进程内运行时状态。
115
+ *
116
+ * ⚠️ 红线:**绝对不要**在飞书 SDK 的 onReady / onReconnected 回调里调用本函数。
117
+ * SDK 的 WebSocket 重连只是底层连接抖动,业务层(活跃 prompt、display loop、
118
+ * stream-state 文件、轮数计数)完全不受影响。在重连里调 resetState 会:
119
+ * 1) `activePrompts.clear()` 只是删 Map,**不会** abort 后台 generator。
120
+ * generator 继续跑、继续写 stream-state.json,但 display loop 已被
121
+ * stop,用户群里再也看不到任何更新;最终回复永远不发到群。
122
+ * 2) 该 sessionId 在内存里"看似空闲",下一条用户消息进来会**第二次进入**
123
+ * `runAgentSession`,同一个 cursor/claude session 同时跑两条 prompt,
124
+ * 输出互相串扰、token 计费翻倍。
125
+ * 3) `processedMessages` / `lastMsgTimestamps` 被清,SDK 重连后若服务端
126
+ * 重推已 ack 的消息,去重失效会让同一 prompt 被处理两次。
127
+ * 4) `sessionInfoMap` 清空后,群再发消息时 nextTurnCount 从 1 重新计数。
128
+ *
129
+ * 合法调用点:
130
+ * - 单元测试 setup(清测试间状态)
131
+ * - 进程首次启动(此时 Map 都是空的,调用纯粹是为了打 LOG)
132
+ *
133
+ * SDK 重连场景请改用 `rebuildBindingsFromRegistry()`,它只重建 sessionId →
134
+ * chatId 映射,不动任何运行时状态。
135
+ */
88
136
  export function resetState(): void {
89
137
  for (const entry of chatSessionMap.values()) {
90
138
  if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
@@ -94,7 +142,7 @@ export function resetState(): void {
94
142
  sessionInfoMap.clear();
95
143
  processedMessages.clear();
96
144
  lastMsgTimestamps.clear();
97
- // 清理新的全局状态
145
+ chatPlatformMap.clear();
98
146
  activePrompts.clear();
99
147
  displayCards.clear();
100
148
  for (const stop of displayLoops.values()) stop();
@@ -102,6 +150,9 @@ export function resetState(): void {
102
150
  console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
103
151
  }
104
152
 
153
+ // 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
154
+ // 是 onReady/onReconnected 取代 resetState 的正确入口。
155
+
105
156
  // ---------------------------------------------------------------------------
106
157
  // Adapter: 按 tool 创建并缓存
107
158
  // ---------------------------------------------------------------------------
@@ -235,6 +286,26 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
235
286
  return loadSessionRegistry();
236
287
  }
237
288
 
289
+ /**
290
+ * 从持久化的 registry 重建 sessionId → chatId 映射。
291
+ *
292
+ * 设计契约(替代之前 onReady/onReconnected 误用的 resetState):
293
+ * - **不动** activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃,
294
+ * 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
295
+ * 导致同一 sessionId 双开 generator
296
+ * - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
297
+ * - **不动** displayCards / displayLoops:正在跑的 prompt 还需要它们继续推卡片
298
+ * - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
299
+ * 去重 set 还在才能避免同一 prompt 跑两遍
300
+ *
301
+ * 唯一被重建的是 sessionChatsMap(通过调用 rebuildSessionChatsFromRegistry)——
302
+ * 该 Map 是从 registry 派生的纯只读映射,重建是幂等且廉价的。
303
+ */
304
+ export async function rebuildBindingsFromRegistry(): Promise<void> {
305
+ const registry = await loadSessionRegistry();
306
+ rebuildSessionChatsFromRegistry(registry);
307
+ }
308
+
238
309
  async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
239
310
  try {
240
311
  await mkdir(dirname(sessionRegistryFile), { recursive: true });
@@ -379,6 +450,118 @@ export function accumulateBlockContent(
379
450
  }
380
451
  }
381
452
 
453
+ // ---------------------------------------------------------------------------
454
+ // switchChatBinding — /newh、/session N 共用的事务式"切换 chat 绑定"
455
+ // ---------------------------------------------------------------------------
456
+ //
457
+ // 设计契约(解决三类历史 bug):
458
+ //
459
+ // 1. 私聊不能调 updateChatInfo(飞书 API 在 p2p chatId 上会返回非 0 → throw)。
460
+ // 之前的实现没判断 chatType,私聊 /newh、/session N 走到 updateChatInfo
461
+ // 就直接抛错,留下"内存已切换、registry 没更新"的脏状态。
462
+ //
463
+ // 2. updateChatInfo 群聊也可能因为网络/频控失败。之前的代码顺序是
464
+ // 先 unbind 旧 → bind 新 → 再调 updateChatInfo
465
+ // API 失败后内存绑定已经切走,但群 description 还是旧 sessionId。
466
+ // 下次用户在群里发消息时,extractSessionInfo 拿到旧 sessionId,而内存绑定
467
+ // 指向新 sessionId,路由完全错乱(参考 /newh 的 corner case 7)。
468
+ //
469
+ // 3. 改成"先 API 后内存"的顺序后,API 失败就完全不切换内存,下次消息按
470
+ // 旧 description 正常路由到旧 session,新创建的 session 留在 sessions.json
471
+ // 里成为可清理的 orphan。
472
+ //
473
+ // 调用方约定:
474
+ // - newSessionId 必须是已经 createSession 完成的真实 session(本函数不创建)
475
+ // - oldSessionId 为 null 表示当前 chat 没有任何旧绑定(比如私聊首次绑)
476
+ // - 私聊跳过 updateChatInfo,直接做内存切换 + registry 持久化
477
+ // - API 失败时:
478
+ // * 不动内存绑定 / sessionInfoMap / displayCards
479
+ // * 返回 { ok: false, error }
480
+ // * 调用方负责把错误反馈给用户
481
+ // ---------------------------------------------------------------------------
482
+
483
+ export interface SwitchChatBindingArgs {
484
+ chatId: string;
485
+ chatType: string;
486
+ oldSessionId: string | null;
487
+ newSessionId: string;
488
+ tool: string;
489
+ /** 群名(私聊忽略) */
490
+ chatName: string;
491
+ /** 群描述(私聊忽略),通常为 `${sessionPrefixForTool(tool)} ${newSessionId}` */
492
+ newDescription: string;
493
+ /** 切换后 sessionInfoMap 的初始 turnCount/lastContextTokens(如沿用历史) */
494
+ initialTurnCount?: number;
495
+ initialContextTokens?: number;
496
+ /** 飞书 updateChatInfo 实现,依赖注入便于测试 mock */
497
+ updateChatInfoFn: (chatId: string, name: string, description: string) => Promise<void>;
498
+ }
499
+
500
+ export interface SwitchChatBindingResult {
501
+ ok: boolean;
502
+ error?: Error;
503
+ }
504
+
505
+ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<SwitchChatBindingResult> {
506
+ const {
507
+ chatId,
508
+ chatType,
509
+ oldSessionId,
510
+ newSessionId,
511
+ tool,
512
+ chatName,
513
+ newDescription,
514
+ initialTurnCount = 0,
515
+ initialContextTokens = 0,
516
+ updateChatInfoFn,
517
+ } = args;
518
+
519
+ // Step 1: 群聊场景先调用飞书 API(不可逆操作放最前)。
520
+ // 私聊跳过——p2p chatId 调 updateChatInfo 必然失败。
521
+ if (chatType !== "p2p") {
522
+ try {
523
+ await updateChatInfoFn(chatId, chatName, newDescription);
524
+ } catch (err) {
525
+ // API 失败:完全不动内存,调用方负责回报用户。
526
+ return { ok: false, error: err as Error };
527
+ }
528
+ }
529
+
530
+ // Step 2: API 成功(或私聊跳过)后,原子地切换内存绑定。
531
+ // 这一段全是同步 Map 操作,不会失败。
532
+ if (oldSessionId) {
533
+ unbindChatFromSession(oldSessionId, chatId);
534
+ displayCards.delete(chatId);
535
+ }
536
+ bindChatToSession(newSessionId, chatId);
537
+ recordLastActiveChat(newSessionId, chatId);
538
+
539
+ const now = Date.now();
540
+ sessionInfoMap.set(chatId, {
541
+ sessionId: newSessionId,
542
+ turnCount: initialTurnCount,
543
+ lastContextTokens: initialContextTokens,
544
+ startTime: now,
545
+ tool,
546
+ });
547
+
548
+ // Step 3: 持久化(registry + sessions.json)。
549
+ // 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
550
+ await recordSessionRegistry({
551
+ chatId,
552
+ sessionId: newSessionId,
553
+ tool,
554
+ chatName,
555
+ turnCount: initialTurnCount,
556
+ lastContextTokens: initialContextTokens,
557
+ startTime: now,
558
+ running: false,
559
+ });
560
+ await saveSessionTool(newSessionId, tool, chatName);
561
+
562
+ return { ok: true };
563
+ }
564
+
382
565
  // ---------------------------------------------------------------------------
383
566
  // AI tool session management
384
567
  // ---------------------------------------------------------------------------
@@ -425,13 +608,13 @@ export async function initClaudeSession(tool: string, overrideCwd?: string, chat
425
608
  export async function resumeAndPrompt(
426
609
  sessionId: string,
427
610
  userText: string,
428
- token: string,
611
+ platform: PlatformAdapter,
429
612
  chatId: string,
430
613
  msgTimestamp: number,
431
614
  tool: string,
432
615
  traceId?: string,
433
616
  ): Promise<void> {
434
- return runAgentSession(sessionId, userText, token, chatId, msgTimestamp, tool, traceId);
617
+ return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
435
618
  }
436
619
 
437
620
  // ---------------------------------------------------------------------------
@@ -441,7 +624,7 @@ export async function resumeAndPrompt(
441
624
  export async function runAgentSession(
442
625
  sessionId: string,
443
626
  userText: string,
444
- token: string,
627
+ platform: PlatformAdapter,
445
628
  _chatId: string,
446
629
  msgTimestamp: number,
447
630
  tool: string,
@@ -450,47 +633,23 @@ export async function runAgentSession(
450
633
  const tid = traceId ?? "";
451
634
 
452
635
  // 记录用户最后发送消息的群(display loop 只推送到该群)
636
+ recordChatPlatform(_chatId, platform);
453
637
  recordLastActiveChat(sessionId, _chatId);
454
638
 
455
639
  // 并发检查:同一 session 只能有一个活跃 prompt
456
640
  if (activePrompts.has(sessionId)) {
457
641
  if (tid) logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
458
642
  console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating`);
459
- await sendTextReply(token, _chatId, "该会话正在生成回复中,请等待完成后再发送消息。").catch(() => {});
643
+ const isWechatBusy = platform.kind === "wechat";
644
+ const busyMsg = isWechatBusy
645
+ ? "当前正在生成回复中,请等待完成后再发送消息。如需中断生成,请发送 /stop 指令。"
646
+ : "该会话正在生成回复中,请等待完成后再发送消息。";
647
+ await platform.sendText(_chatId, busyMsg).catch(() => {});
460
648
  return;
461
649
  }
462
650
 
463
- const adapter = getAdapterForTool(tool);
464
- const info = await adapter.getSessionInfo(sessionId);
465
- const cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
466
- if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
467
- console.log(
468
- `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
469
- );
470
-
471
- // 构建 IM skills prompt(sessionId 方式,无 token)
472
- const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
473
- const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
474
- const skillVariables = {
475
- cwd,
476
- session_id: sessionId,
477
- im_skills_cache_dir: imSkillsCacheDir,
478
- send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
479
- send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
480
- send_image_script: join(feishuSkillDir, "send-image.mjs"),
481
- send_file_script: join(feishuSkillDir, "send-file.mjs"),
482
- download_video_script: join(feishuSkillDir, "download-video.mjs"),
483
- };
484
- const imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
485
- await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
486
- const userTextWithCapabilities = [
487
- ...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
488
- "[User message]",
489
- userText,
490
- "[/User message]",
491
- ].join("\n");
492
-
493
- // 设置活跃 prompt
651
+ // 立即标记活跃,确保 /sessions、isSessionRunning 等查询在异步准备阶段就能看到运行状态。
652
+ // 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
494
653
  const controller = new AbortController();
495
654
  const now = Date.now();
496
655
  activePrompts.set(sessionId, {
@@ -499,6 +658,46 @@ export async function runAgentSession(
499
658
  startTime: now,
500
659
  });
501
660
 
661
+ // 异步准备工作(session info、IM skills prompt 等)
662
+ let adapter: ToolAdapter;
663
+ let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
664
+ let cwd: string;
665
+ try {
666
+ adapter = getAdapterForTool(tool);
667
+ info = await adapter.getSessionInfo(sessionId);
668
+ cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
669
+ if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
670
+ console.log(
671
+ `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
672
+ );
673
+
674
+ // 构建 IM skills prompt(sessionId 方式,无 token)
675
+ const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
676
+ const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
677
+ const skillVariables = {
678
+ cwd,
679
+ session_id: sessionId,
680
+ im_skills_cache_dir: imSkillsCacheDir,
681
+ send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
682
+ send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
683
+ send_image_script: join(feishuSkillDir, "send-image.mjs"),
684
+ send_file_script: join(feishuSkillDir, "send-file.mjs"),
685
+ download_video_script: join(feishuSkillDir, "download-video.mjs"),
686
+ };
687
+ var imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
688
+ await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
689
+ var userTextWithCapabilities = [
690
+ ...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
691
+ "[User message]",
692
+ userText,
693
+ "[/User message]",
694
+ ].join("\n");
695
+ } catch (preambleErr) {
696
+ // 准备工作失败,清理活跃标记,避免"僵尸"活跃状态阻塞后续消息
697
+ activePrompts.delete(sessionId);
698
+ throw preambleErr;
699
+ }
700
+
502
701
  // 更新 sessionInfoMap(所有绑定群共用)
503
702
  const existingInfo = sessionInfoMap.get(_chatId);
504
703
  const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
@@ -545,7 +744,7 @@ export async function runAgentSession(
545
744
  // 设置最后活跃群头像为 busy
546
745
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
547
746
  if (activeCid) {
548
- setChatAvatar(token, activeCid, tool, "busy").catch(() => {});
747
+ platform.setChatAvatar(activeCid, tool, "busy").catch(() => {});
549
748
  }
550
749
 
551
750
  const state: AccumulatorState = {
@@ -636,7 +835,7 @@ export async function runAgentSession(
636
835
  });
637
836
  }
638
837
  const active1 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
639
- if (active1) setChatAvatar(token, active1, tool, "idle").catch(() => {});
838
+ if (active1) platform.setChatAvatar(active1, tool, "idle").catch(() => {});
640
839
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
641
840
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
642
841
  } else {
@@ -653,7 +852,7 @@ export async function runAgentSession(
653
852
  });
654
853
  }
655
854
  const active2 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
656
- if (active2) setChatAvatar(token, active2, tool, "idle").catch(() => {});
855
+ if (active2) platform.setChatAvatar(active2, tool, "idle").catch(() => {});
657
856
  console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
658
857
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
659
858
  }
@@ -686,103 +885,221 @@ export function ensureDisplayLoop(sessionId: string): void {
686
885
  displayLoops.delete(sessionId);
687
886
  // 兜底:lastActiveChatMap 可能因进程重启丢失,从 registry 映射恢复头像
688
887
  const fallbackChat = getChatsForSession(sessionId)[0];
689
- if (fallbackChat) {
690
- const t = await import("./feishu-platform.ts").then(m => m.getTenantAccessToken());
691
- setChatAvatar(t, fallbackChat, state.tool, "idle").catch(() => {});
888
+ const fallbackPlatform = fallbackChat ? platformForChat(fallbackChat) : null;
889
+ if (fallbackChat && fallbackPlatform) {
890
+ fallbackPlatform.setChatAvatar(fallbackChat, state.tool, "idle").catch(() => {});
692
891
  }
693
892
  }
694
893
  return;
695
894
  }
696
895
 
896
+ // 交叉验证:chat 当前绑定的 session 是否仍是本 display loop 的 session。
897
+ // 若 chat 已被切换到其他 session(如 /new p2p 未解绑旧 session 的历史遗留
898
+ // 或任何未来新增的切换路径),旧 loop 必须停推,避免向已离开的 chat 推送内容。
899
+ const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
900
+ if (currentSessionForChat && currentSessionForChat !== sessionId) {
901
+ if (state.status !== "running") {
902
+ clearInterval(interval);
903
+ displayLoops.delete(sessionId);
904
+ }
905
+ return;
906
+ }
907
+
697
908
  const isTerminal = state.status !== "running";
698
909
 
699
910
  try {
700
- // getTenantAccessToken 有缓存,多次调用开销低
701
- const tokenModule = await import("./feishu-platform.ts");
702
- const token = await tokenModule.getTenantAccessToken();
911
+ const p = platformForChat(chatId);
912
+ if (!p) return;
703
913
  const display = displayCards.get(chatId);
704
914
 
915
+ const isWechat = p.kind === "wechat";
916
+
705
917
  if (isTerminal) {
706
- // 发送最终结果
707
- if (display) {
708
- while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
709
- const nextSeq = display.sequence + 1;
710
- const headerTitle = state.status === "stopped" ? "已停止" : "完成";
711
- const headerTemplate = state.status === "stopped" ? "red" : undefined;
712
- const cardContent = state.accumulatedContent || " ";
713
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
714
- await updateCardKitCard(token, display.cardId, doneCard, nextSeq).catch(() => {});
715
- displayCards.delete(chatId);
716
- }
717
- if (state.finalReply) {
718
- await sendTextReply(token, chatId, state.finalReply).catch(() => {});
719
- } else if (!display && state.accumulatedContent.trim()) {
720
- const short = truncateContent(state.accumulatedContent, 30, 4000);
721
- await sendTextReply(token, chatId, `[生成过程]\n${short}`).catch(() => {});
918
+ if (isWechat) {
919
+ // WeChat: 没有卡片需要终结,用 delta 逻辑发剩余内容,避免重发已推送的部分
920
+ const rawFull = state.accumulatedContent + state.finalReply;
921
+ const prevRaw = display?.lastSentContent ?? "";
922
+ let remaining: string;
923
+ if (prevRaw && rawFull.startsWith(prevRaw)) {
924
+ remaining = rawFull.slice(prevRaw.length).trim();
925
+ } else {
926
+ remaining = rawFull.trim();
927
+ }
928
+ const tail = "━━━ 回答结束 ━━━";
929
+ const finalMsg = remaining ? remaining + "\n" + tail : tail;
930
+ await p.sendText(chatId, finalMsg).catch(() => {});
931
+ if (display) displayCards.delete(chatId);
932
+ } else {
933
+ // 发送最终结果(卡片平台)
934
+ if (display) {
935
+ while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
936
+ const nextSeq = display.sequence + 1;
937
+ const headerTitle = state.status === "stopped" ? "已停止" : "完成";
938
+ const headerTemplate = state.status === "stopped" ? "red" : undefined;
939
+ const cardContent = state.accumulatedContent || " ";
940
+ const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
941
+ await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
942
+ displayCards.delete(chatId);
943
+ }
944
+ if (state.finalReply) {
945
+ await p.sendText(chatId, state.finalReply).catch(() => {});
946
+ } else if (!display && state.accumulatedContent.trim()) {
947
+ const short = truncateContent(state.accumulatedContent, 30, 4000);
948
+ await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
949
+ }
722
950
  }
723
- setChatAvatar(token, chatId, state.tool, "idle").catch(() => {});
951
+ p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
724
952
  } else {
725
- // running: 创建或更新卡片
726
- if (!display) {
727
- const cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
728
- if (cardId) {
729
- await sendCardKitMessage(token, chatId, cardId).catch(() => null);
953
+ // running: 创建或更新展示
954
+ if (isWechat) {
955
+ // WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
956
+ if (!display) {
730
957
  displayCards.set(chatId, {
731
- cardId,
732
- sequence: 1,
958
+ cardId: "",
959
+ sequence: 0,
733
960
  cardBusy: false,
734
961
  cardCreatedAt: Date.now(),
735
962
  lastSentContent: "",
736
963
  streamErrorNotified: false,
737
964
  });
738
965
  }
966
+ const d = displayCards.get(chatId);
967
+ if (!d || d.cardBusy) return;
968
+
969
+ const rawFull = state.accumulatedContent + state.finalReply;
970
+ const prevRaw = d.lastSentContent;
971
+ let delta: string;
972
+ if (prevRaw && rawFull.startsWith(prevRaw)) {
973
+ delta = rawFull.slice(prevRaw.length).trim();
974
+ } else {
975
+ delta = rawFull.trim();
976
+ }
977
+ if (!delta) return;
978
+
979
+ d.cardBusy = true;
980
+ try {
981
+ const ok = await p.sendText(chatId, delta);
982
+ if (ok) {
983
+ d.lastSentContent = rawFull;
984
+ } else {
985
+ // 发送失败(限流等),不更新光标,下次合并重试
986
+ return;
987
+ }
988
+ } catch (err) {
989
+ console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
990
+ if (!d.streamErrorNotified) {
991
+ d.streamErrorNotified = true;
992
+ p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
993
+ }
994
+ } finally {
995
+ d.cardBusy = false;
996
+ }
739
997
  } else {
740
- if (display.cardBusy) return;
998
+ // WeChat: 卡片流程
999
+ if (!display) {
1000
+ const cardId = await p.cardCreate(buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
1001
+ if (cardId) {
1002
+ await p.cardSend(chatId, cardId).catch(() => null);
1003
+ displayCards.set(chatId, {
1004
+ cardId,
1005
+ sequence: 1,
1006
+ cardBusy: false,
1007
+ cardCreatedAt: Date.now(),
1008
+ lastSentContent: "",
1009
+ streamErrorNotified: false,
1010
+ });
1011
+ }
1012
+ } else {
1013
+ if (display.cardBusy) return;
1014
+
1015
+ // 卡片轮转
1016
+ if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1017
+ display.cardBusy = true;
1018
+ try {
1019
+ const oldSeqBase = display.sequence;
1020
+ const oldDisplayContent = truncateContent(state.accumulatedContent + state.finalReply) || "处理中...";
1021
+ const oldCard = buildProgressCard(oldDisplayContent, { showStop: false, headerTitle: "生成中...(上轮)" });
1022
+ await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
1023
+ const newCardId = await p.cardCreate(buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
1024
+ if (newCardId) {
1025
+ await p.cardSend(chatId, newCardId);
1026
+ display.cardId = newCardId;
1027
+ display.sequence = 1;
1028
+ display.cardCreatedAt = Date.now();
1029
+ // 记录基线:新卡片只展示轮转后新增的内容,不重复旧卡已有内容
1030
+ display.rotationBaseline = state.accumulatedContent + state.finalReply;
1031
+ display.lastSentContent = "";
1032
+ display.streamErrorNotified = false;
1033
+ }
1034
+ } catch (err) {
1035
+ console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
1036
+ } finally {
1037
+ display.cardBusy = false;
1038
+ }
1039
+ return;
1040
+ }
741
1041
 
742
- // 卡片轮转
743
- if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1042
+ // 轮转后:只展示增量(从 baseline 之后的新内容)
1043
+ if (display.rotationBaseline) {
1044
+ const fullRaw = state.accumulatedContent + state.finalReply;
1045
+ if (!fullRaw.startsWith(display.rotationBaseline)) {
1046
+ // baseline 失效(极端情况),回退到完整内容
1047
+ display.rotationBaseline = undefined;
1048
+ dotCount = (dotCount % 9) + 1;
1049
+ const fallback = fullRaw + "\n" + "。".repeat(dotCount);
1050
+ display.lastSentContent = fallback;
1051
+ const fbCard = buildProgressCard(truncateContent(fallback), { showStop: true, headerTitle: "生成中..." });
1052
+ display.cardBusy = true;
1053
+ try {
1054
+ await p.cardUpdate(display.cardId, fbCard, display.sequence + 1);
1055
+ display.sequence++;
1056
+ } finally {
1057
+ display.cardBusy = false;
1058
+ }
1059
+ return;
1060
+ }
1061
+ const delta = fullRaw.slice(display.rotationBaseline.length).trim();
1062
+ if (delta === display.lastSentContent) return;
1063
+ display.lastSentContent = delta;
1064
+ const deltaCard = buildProgressCard(truncateContent(delta) || "处理中...", { showStop: true, headerTitle: "生成中..." });
1065
+ display.cardBusy = true;
1066
+ const mySeq = display.sequence + 1;
1067
+ try {
1068
+ await p.cardUpdate(display.cardId, deltaCard, mySeq);
1069
+ display.sequence = mySeq;
1070
+ } catch (err) {
1071
+ console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${(err as Error).message}`);
1072
+ if (!display.streamErrorNotified) {
1073
+ display.streamErrorNotified = true;
1074
+ p.sendText(chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
1075
+ }
1076
+ } finally {
1077
+ display.cardBusy = false;
1078
+ }
1079
+ return;
1080
+ }
1081
+
1082
+ dotCount = (dotCount % 9) + 1;
1083
+ const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount);
1084
+ if (fullContent === display.lastSentContent) return;
1085
+
1086
+ display.lastSentContent = fullContent;
1087
+ const cardContent = truncateContent(fullContent);
744
1088
  display.cardBusy = true;
1089
+ const mySeq = display.sequence + 1;
745
1090
  try {
746
- const oldSeqBase = display.sequence;
747
- const oldDisplayContent = truncateContent(state.accumulatedContent + state.finalReply) || "处理中...";
748
- const oldCard = buildProgressCard(oldDisplayContent, { showStop: false, headerTitle: "生成中...(上轮)" });
749
- await updateCardKitCard(token, display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
750
- const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
751
- if (newCardId) {
752
- await sendCardKitMessage(token, chatId, newCardId);
753
- display.cardId = newCardId;
754
- display.sequence = 1;
755
- display.cardCreatedAt = Date.now();
756
- display.lastSentContent = "";
757
- display.streamErrorNotified = false;
758
- }
1091
+ const card = buildProgressCard(cardContent, { showStop: true, headerTitle: "生成中..." });
1092
+ await p.cardUpdate(display.cardId, card, mySeq);
1093
+ display.sequence = mySeq;
759
1094
  } catch (err) {
760
- console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
1095
+ console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${(err as Error).message}`);
1096
+ if (!display.streamErrorNotified) {
1097
+ display.streamErrorNotified = true;
1098
+ p.sendText(chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
1099
+ }
761
1100
  } finally {
762
1101
  display.cardBusy = false;
763
1102
  }
764
- return;
765
- }
766
-
767
- dotCount = (dotCount % 9) + 1;
768
- const content = truncateContent(state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount));
769
- if (content === display.lastSentContent) return;
770
-
771
- display.lastSentContent = content;
772
- display.cardBusy = true;
773
- const mySeq = display.sequence + 1;
774
- try {
775
- const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
776
- await updateCardKitCard(token, display.cardId, card, mySeq);
777
- display.sequence = mySeq;
778
- } catch (err) {
779
- console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${(err as Error).message}`);
780
- if (!display.streamErrorNotified) {
781
- display.streamErrorNotified = true;
782
- sendTextReply(token, chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
783
- }
784
- } finally {
785
- display.cardBusy = false;
786
1103
  }
787
1104
  }
788
1105
  }