chatccc 0.2.51 → 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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * platform-adapter.ts — IM 平台适配器接口
3
+ *
4
+ * 独立于 orchestrator.ts,避免 orchestrator ↔ session 循环依赖。
5
+ * 每个方法内部自行管理认证,消费者不感知 token 等认证细节。
6
+ */
7
+
8
+ export interface PlatformAdapter {
9
+ /** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
10
+ kind?: string;
11
+
12
+ /** 发送纯文本回复 */
13
+ sendText(chatId: string, text: string): Promise<boolean>;
14
+
15
+ /** 发送卡片回复(标题 + 内容 + 颜色模板) */
16
+ sendCard(
17
+ chatId: string,
18
+ title: string,
19
+ content: string,
20
+ template: string,
21
+ ): Promise<boolean>;
22
+
23
+ /** 发送原始卡片 JSON */
24
+ sendRawCard(chatId: string, cardJson: string): Promise<boolean>;
25
+
26
+ /** 创建群聊,返回新群 ID */
27
+ createGroup(name: string, userIds: string[]): Promise<string>;
28
+
29
+ /** 更新群名称和描述 */
30
+ updateChatInfo(
31
+ chatId: string,
32
+ name: string,
33
+ description: string,
34
+ ): Promise<void>;
35
+
36
+ /** 获取群信息 */
37
+ getChatInfo(chatId: string): Promise<{ name: string; description: string }>;
38
+
39
+ /** 解散群聊 */
40
+ disbandChat(chatId: string): Promise<void>;
41
+
42
+ /** 设置群头像 */
43
+ setChatAvatar(chatId: string, tool: string, status: string): Promise<void>;
44
+
45
+ /** 从群描述中提取 session 信息 */
46
+ extractSessionInfo(
47
+ description: string,
48
+ ): { sessionId: string; tool: string } | null;
49
+
50
+ // ---- 进度展示(display loop 使用) ----
51
+ // 不同平台能力不同:飞书用 CardKit 实时卡片,微信降级为纯文本。
52
+
53
+ /** 创建进度展示实体,返回唯一标识;不支持进度展示的平台返回 null */
54
+ cardCreate(cardJson: string): Promise<string | null>;
55
+
56
+ /** 将进度展示发送到指定会话,返回 message_id */
57
+ cardSend(chatId: string, cardId: string): Promise<string>;
58
+
59
+ /** 更新已发送的进度展示,sequence 保证有序 */
60
+ cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
61
+ }
@@ -109,6 +109,8 @@ export interface DisplayCardState {
109
109
  cardCreatedAt: number;
110
110
  lastSentContent: string;
111
111
  streamErrorNotified: boolean;
112
+ /** 卡片轮转时记录的基线,轮转后只展示增量 */
113
+ rotationBaseline?: string;
112
114
  }
113
115
 
114
116
  export const displayCards = new Map<string, DisplayCardState>();
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, {
@@ -117,6 +142,7 @@ export function resetState(): void {
117
142
  sessionInfoMap.clear();
118
143
  processedMessages.clear();
119
144
  lastMsgTimestamps.clear();
145
+ chatPlatformMap.clear();
120
146
  activePrompts.clear();
121
147
  displayCards.clear();
122
148
  for (const stop of displayLoops.values()) stop();
@@ -582,13 +608,13 @@ export async function initClaudeSession(tool: string, overrideCwd?: string, chat
582
608
  export async function resumeAndPrompt(
583
609
  sessionId: string,
584
610
  userText: string,
585
- token: string,
611
+ platform: PlatformAdapter,
586
612
  chatId: string,
587
613
  msgTimestamp: number,
588
614
  tool: string,
589
615
  traceId?: string,
590
616
  ): Promise<void> {
591
- return runAgentSession(sessionId, userText, token, chatId, msgTimestamp, tool, traceId);
617
+ return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
592
618
  }
593
619
 
594
620
  // ---------------------------------------------------------------------------
@@ -598,7 +624,7 @@ export async function resumeAndPrompt(
598
624
  export async function runAgentSession(
599
625
  sessionId: string,
600
626
  userText: string,
601
- token: string,
627
+ platform: PlatformAdapter,
602
628
  _chatId: string,
603
629
  msgTimestamp: number,
604
630
  tool: string,
@@ -607,47 +633,23 @@ export async function runAgentSession(
607
633
  const tid = traceId ?? "";
608
634
 
609
635
  // 记录用户最后发送消息的群(display loop 只推送到该群)
636
+ recordChatPlatform(_chatId, platform);
610
637
  recordLastActiveChat(sessionId, _chatId);
611
638
 
612
639
  // 并发检查:同一 session 只能有一个活跃 prompt
613
640
  if (activePrompts.has(sessionId)) {
614
641
  if (tid) logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
615
642
  console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating`);
616
- 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(() => {});
617
648
  return;
618
649
  }
619
650
 
620
- const adapter = getAdapterForTool(tool);
621
- const info = await adapter.getSessionInfo(sessionId);
622
- const cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
623
- if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
624
- console.log(
625
- `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
626
- );
627
-
628
- // 构建 IM skills prompt(sessionId 方式,无 token)
629
- const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
630
- const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
631
- const skillVariables = {
632
- cwd,
633
- session_id: sessionId,
634
- im_skills_cache_dir: imSkillsCacheDir,
635
- send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
636
- send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
637
- send_image_script: join(feishuSkillDir, "send-image.mjs"),
638
- send_file_script: join(feishuSkillDir, "send-file.mjs"),
639
- download_video_script: join(feishuSkillDir, "download-video.mjs"),
640
- };
641
- const imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
642
- await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
643
- const userTextWithCapabilities = [
644
- ...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
645
- "[User message]",
646
- userText,
647
- "[/User message]",
648
- ].join("\n");
649
-
650
- // 设置活跃 prompt
651
+ // 立即标记活跃,确保 /sessions、isSessionRunning 等查询在异步准备阶段就能看到运行状态。
652
+ // 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
651
653
  const controller = new AbortController();
652
654
  const now = Date.now();
653
655
  activePrompts.set(sessionId, {
@@ -656,6 +658,46 @@ export async function runAgentSession(
656
658
  startTime: now,
657
659
  });
658
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
+
659
701
  // 更新 sessionInfoMap(所有绑定群共用)
660
702
  const existingInfo = sessionInfoMap.get(_chatId);
661
703
  const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
@@ -702,7 +744,7 @@ export async function runAgentSession(
702
744
  // 设置最后活跃群头像为 busy
703
745
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
704
746
  if (activeCid) {
705
- setChatAvatar(token, activeCid, tool, "busy").catch(() => {});
747
+ platform.setChatAvatar(activeCid, tool, "busy").catch(() => {});
706
748
  }
707
749
 
708
750
  const state: AccumulatorState = {
@@ -793,7 +835,7 @@ export async function runAgentSession(
793
835
  });
794
836
  }
795
837
  const active1 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
796
- if (active1) setChatAvatar(token, active1, tool, "idle").catch(() => {});
838
+ if (active1) platform.setChatAvatar(active1, tool, "idle").catch(() => {});
797
839
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
798
840
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
799
841
  } else {
@@ -810,7 +852,7 @@ export async function runAgentSession(
810
852
  });
811
853
  }
812
854
  const active2 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
813
- if (active2) setChatAvatar(token, active2, tool, "idle").catch(() => {});
855
+ if (active2) platform.setChatAvatar(active2, tool, "idle").catch(() => {});
814
856
  console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
815
857
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
816
858
  }
@@ -843,103 +885,221 @@ export function ensureDisplayLoop(sessionId: string): void {
843
885
  displayLoops.delete(sessionId);
844
886
  // 兜底:lastActiveChatMap 可能因进程重启丢失,从 registry 映射恢复头像
845
887
  const fallbackChat = getChatsForSession(sessionId)[0];
846
- if (fallbackChat) {
847
- const t = await import("./feishu-platform.ts").then(m => m.getTenantAccessToken());
848
- 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(() => {});
849
891
  }
850
892
  }
851
893
  return;
852
894
  }
853
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
+
854
908
  const isTerminal = state.status !== "running";
855
909
 
856
910
  try {
857
- // getTenantAccessToken 有缓存,多次调用开销低
858
- const tokenModule = await import("./feishu-platform.ts");
859
- const token = await tokenModule.getTenantAccessToken();
911
+ const p = platformForChat(chatId);
912
+ if (!p) return;
860
913
  const display = displayCards.get(chatId);
861
914
 
915
+ const isWechat = p.kind === "wechat";
916
+
862
917
  if (isTerminal) {
863
- // 发送最终结果
864
- if (display) {
865
- while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
866
- const nextSeq = display.sequence + 1;
867
- const headerTitle = state.status === "stopped" ? "已停止" : "完成";
868
- const headerTemplate = state.status === "stopped" ? "red" : undefined;
869
- const cardContent = state.accumulatedContent || " ";
870
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
871
- await updateCardKitCard(token, display.cardId, doneCard, nextSeq).catch(() => {});
872
- displayCards.delete(chatId);
873
- }
874
- if (state.finalReply) {
875
- await sendTextReply(token, chatId, state.finalReply).catch(() => {});
876
- } else if (!display && state.accumulatedContent.trim()) {
877
- const short = truncateContent(state.accumulatedContent, 30, 4000);
878
- 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
+ }
879
950
  }
880
- setChatAvatar(token, chatId, state.tool, "idle").catch(() => {});
951
+ p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
881
952
  } else {
882
- // running: 创建或更新卡片
883
- if (!display) {
884
- const cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
885
- if (cardId) {
886
- await sendCardKitMessage(token, chatId, cardId).catch(() => null);
953
+ // running: 创建或更新展示
954
+ if (isWechat) {
955
+ // WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
956
+ if (!display) {
887
957
  displayCards.set(chatId, {
888
- cardId,
889
- sequence: 1,
958
+ cardId: "",
959
+ sequence: 0,
890
960
  cardBusy: false,
891
961
  cardCreatedAt: Date.now(),
892
962
  lastSentContent: "",
893
963
  streamErrorNotified: false,
894
964
  });
895
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
+ }
896
997
  } else {
897
- 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
+ }
1041
+
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
+ }
898
1081
 
899
- // 卡片轮转
900
- if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
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);
901
1088
  display.cardBusy = true;
1089
+ const mySeq = display.sequence + 1;
902
1090
  try {
903
- const oldSeqBase = display.sequence;
904
- const oldDisplayContent = truncateContent(state.accumulatedContent + state.finalReply) || "处理中...";
905
- const oldCard = buildProgressCard(oldDisplayContent, { showStop: false, headerTitle: "生成中...(上轮)" });
906
- await updateCardKitCard(token, display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
907
- const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
908
- if (newCardId) {
909
- await sendCardKitMessage(token, chatId, newCardId);
910
- display.cardId = newCardId;
911
- display.sequence = 1;
912
- display.cardCreatedAt = Date.now();
913
- display.lastSentContent = "";
914
- display.streamErrorNotified = false;
915
- }
1091
+ const card = buildProgressCard(cardContent, { showStop: true, headerTitle: "生成中..." });
1092
+ await p.cardUpdate(display.cardId, card, mySeq);
1093
+ display.sequence = mySeq;
916
1094
  } catch (err) {
917
- 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
+ }
918
1100
  } finally {
919
1101
  display.cardBusy = false;
920
1102
  }
921
- return;
922
- }
923
-
924
- dotCount = (dotCount % 9) + 1;
925
- const content = truncateContent(state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount));
926
- if (content === display.lastSentContent) return;
927
-
928
- display.lastSentContent = content;
929
- display.cardBusy = true;
930
- const mySeq = display.sequence + 1;
931
- try {
932
- const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
933
- await updateCardKitCard(token, display.cardId, card, mySeq);
934
- display.sequence = mySeq;
935
- } catch (err) {
936
- console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${(err as Error).message}`);
937
- if (!display.streamErrorNotified) {
938
- display.streamErrorNotified = true;
939
- sendTextReply(token, chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
940
- }
941
- } finally {
942
- display.cardBusy = false;
943
1103
  }
944
1104
  }
945
1105
  }
package/src/sim-agent.ts CHANGED
@@ -71,6 +71,45 @@ export function createSimAgent(userId: string): SimAgent {
71
71
  // 确保用户有至少一个会话(bot 私聊自动创建)
72
72
  simStore.createP2pChat(userId);
73
73
 
74
+ function onEvent(event: "message", handler: MessageEventCallback): void;
75
+ function onEvent(event: "invited_to_group", handler: InvitedToGroupCallback): void;
76
+ function onEvent(
77
+ event: "message" | "invited_to_group",
78
+ handler: MessageEventCallback | InvitedToGroupCallback,
79
+ ): void {
80
+ if (event === "message") {
81
+ const messageHandler = handler as MessageEventCallback;
82
+ const filteredHandler = (payload: { chatId: string; message: SimMessage }) => {
83
+ // 只推送给该用户所在群的消息
84
+ const chat = simStore.getChat(payload.chatId);
85
+ if (chat && chat.memberIds.includes(userId)) {
86
+ messageHandler(payload.chatId, payload.message);
87
+ }
88
+ };
89
+ // 保存映射以便 off 能移除
90
+ (handler as any).__filtered = filteredHandler;
91
+ simStore.on("message", filteredHandler);
92
+ } else {
93
+ const invitedHandler = handler as InvitedToGroupCallback;
94
+ const filteredHandler = (payload: { chatId: string; userId: string }) => {
95
+ if (payload.userId === userId) {
96
+ invitedHandler(payload.chatId, payload.userId);
97
+ }
98
+ };
99
+ (handler as any).__filtered = filteredHandler;
100
+ simStore.on("member_added", filteredHandler);
101
+ }
102
+ }
103
+
104
+ function offEvent(event: string, handler: (...args: any[]) => void): void {
105
+ const filtered = (handler as any).__filtered;
106
+ if (event === "message" && filtered) {
107
+ simStore.off("message", filtered);
108
+ } else if (event === "invited_to_group" && filtered) {
109
+ simStore.off("member_added", filtered);
110
+ }
111
+ }
112
+
74
113
  const agent: SimAgent = {
75
114
  userId,
76
115
  account,
@@ -101,37 +140,9 @@ export function createSimAgent(userId: string): SimAgent {
101
140
  }));
102
141
  },
103
142
 
104
- on(event, handler) {
105
- if (event === "message") {
106
- const filteredHandler = (payload: { chatId: string; message: SimMessage }) => {
107
- // 只推送给该用户所在群的消息
108
- const chat = simStore.getChat(payload.chatId);
109
- if (chat && chat.memberIds.includes(userId)) {
110
- handler(payload.chatId, payload.message);
111
- }
112
- };
113
- // 保存映射以便 off 能移除
114
- (handler as any).__filtered = filteredHandler;
115
- simStore.on("message", filteredHandler);
116
- } else if (event === "invited_to_group") {
117
- const filteredHandler = (payload: { chatId: string; userId: string }) => {
118
- if (payload.userId === userId) {
119
- handler(payload.chatId, payload.userId);
120
- }
121
- };
122
- (handler as any).__filtered = filteredHandler;
123
- simStore.on("member_added", filteredHandler);
124
- }
125
- },
143
+ on: onEvent,
126
144
 
127
- off(event, handler) {
128
- const filtered = (handler as any).__filtered;
129
- if (event === "message" && filtered) {
130
- simStore.off("message", filtered);
131
- } else if (event === "invited_to_group" && filtered) {
132
- simStore.off("member_added", filtered);
133
- }
134
- },
145
+ off: offEvent,
135
146
 
136
147
  waitForReply(chatId, timeoutMs = 30000) {
137
148
  return new Promise((resolve) => {
@@ -153,4 +164,4 @@ export function createSimAgent(userId: string): SimAgent {
153
164
  };
154
165
 
155
166
  return agent;
156
- }
167
+ }