chatccc 0.2.45 → 0.2.46

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
@@ -32,16 +32,18 @@ import type { ToolAdapter } from "./adapters/adapter-interface.ts";
32
32
  import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
33
33
  import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
34
34
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
35
- import {
36
- createAgentImageGrant,
37
- revokeAgentImageGrant,
38
- } from "./agent-image-rpc.ts";
39
- import {
40
- createAgentFileGrant,
41
- revokeAgentFileGrant,
42
- } from "./agent-file-rpc.ts";
43
- import { setSessionGrants, clearSessionGrants, AGENT_SESSION_GRANTS_PATH } from "./agent-grants-rpc.ts";
44
35
  import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
36
+ import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
37
+ import {
38
+ bindChatToSession,
39
+ unbindChatFromSession,
40
+ getChatsForSession,
41
+ isSessionRunning,
42
+ activePrompts,
43
+ displayCards,
44
+ displayLoops,
45
+ rebuildSessionChatsFromRegistry,
46
+ } from "./session-chat-binding.ts";
45
47
 
46
48
  // ---------------------------------------------------------------------------
47
49
  // Shared state (imported by index.ts)
@@ -54,6 +56,7 @@ export const MAX_PROCESSED = 5000;
54
56
  export const lastMsgTimestamps = new Map<string, number>();
55
57
 
56
58
  export let sessionGen = 0;
59
+ /** @deprecated 使用 activePrompts (session-chat-binding.ts) + displayCards 替代 */
57
60
  export const chatSessionMap = new Map<string, {
58
61
  gen: number;
59
62
  close: () => void;
@@ -68,17 +71,8 @@ export const chatSessionMap = new Map<string, {
68
71
  }>();
69
72
 
70
73
  /**
71
- * sessionInfoMap 记录每个 chatId 当前会话的"轻量元数据":
72
- *
73
- * 注意此处**不**保存 model / effort:
74
- * - Claude 会话:model/effort 由 ChatCCC 启动时的环境变量决定(CLAUDE_MODEL/EFFORT),
75
- * getSessionStatus 直接读全局配置即可。
76
- * - Cursor 会话:model 是 cursor-agent 自报的运行时值(如 Composer 2 Fast),
77
- * 由 cursor-adapter 持久化到 cursor-session-meta.json,
78
- * getSessionStatus 通过 adapter.getSessionInfo 实时获取;effort 概念不适用。
79
- *
80
- * 把 model/effort 从 sessionInfoMap 移除是为了消除"硬塞 CLAUDE_* 给 Cursor"
81
- * 的不一致 bug——/status、/sessions 必须显示真实工具的真实信息。
74
+ * sessionInfoMap 记录每个 chatId 当前绑定的会话元数据。
75
+ * 同一 session 可被多个 chatId 共享;model/effort 不在其中(按 tool 动态解析)。
82
76
  */
83
77
  export const sessionInfoMap = new Map<string, {
84
78
  sessionId: string;
@@ -97,7 +91,12 @@ export function resetState(): void {
97
91
  sessionInfoMap.clear();
98
92
  processedMessages.clear();
99
93
  lastMsgTimestamps.clear();
100
- console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions)`);
94
+ // 清理新的全局状态
95
+ activePrompts.clear();
96
+ displayCards.clear();
97
+ for (const stop of displayLoops.values()) stop();
98
+ displayLoops.clear();
99
+ console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
101
100
  }
102
101
 
103
102
  // ---------------------------------------------------------------------------
@@ -211,6 +210,11 @@ async function loadSessionRegistry(): Promise<SessionRegistryData> {
211
210
  }
212
211
  }
213
212
 
213
+ /** 供 session-chat-binding.ts 重建映射 */
214
+ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryData> {
215
+ return loadSessionRegistry();
216
+ }
217
+
214
218
  async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
215
219
  try {
216
220
  await mkdir(dirname(sessionRegistryFile), { recursive: true });
@@ -400,44 +404,55 @@ export async function resumeAndPrompt(
400
404
  msgTimestamp: number,
401
405
  tool: string,
402
406
  traceId?: string,
407
+ ): Promise<void> {
408
+ return runAgentSession(sessionId, userText, token, chatId, msgTimestamp, tool, traceId);
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
413
+ // ---------------------------------------------------------------------------
414
+
415
+ export async function runAgentSession(
416
+ sessionId: string,
417
+ userText: string,
418
+ token: string,
419
+ _chatId: string,
420
+ msgTimestamp: number,
421
+ tool: string,
422
+ traceId?: string,
403
423
  ): Promise<void> {
404
424
  const tid = traceId ?? "";
425
+
426
+ // 并发检查:同一 session 只能有一个活跃 prompt
427
+ if (activePrompts.has(sessionId)) {
428
+ if (tid) logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
429
+ console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating`);
430
+ await sendTextReply(token, _chatId, "该会话正在生成回复中,请等待完成后再发送消息。").catch(() => {});
431
+ return;
432
+ }
433
+
405
434
  const adapter = getAdapterForTool(tool);
406
435
  const info = await adapter.getSessionInfo(sessionId);
407
- const cwd = info?.cwd ?? (await getDefaultCwd(chatId));
408
- if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(chatId)?.turnCount ?? 0) + 1 });
436
+ const cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
437
+ if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
409
438
  console.log(
410
- `[${ts()}] Resuming ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
439
+ `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
411
440
  );
412
- const imageGrant = createAgentImageGrant({
413
- chatId,
414
- sessionId,
415
- cwd,
416
- port: CHATCCC_PORT,
417
- traceId: tid || undefined,
418
- });
419
- const fileGrant = createAgentFileGrant({
420
- chatId,
421
- sessionId,
422
- cwd,
423
- port: CHATCCC_PORT,
424
- traceId: tid || undefined,
425
- });
441
+
442
+ // 构建 IM skills prompt(sessionId 方式,无 token)
426
443
  const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
427
444
  const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
428
445
  const skillVariables = {
429
446
  cwd,
430
447
  session_id: sessionId,
431
448
  im_skills_cache_dir: imSkillsCacheDir,
432
- session_grants_url: `http://127.0.0.1:${CHATCCC_PORT}${AGENT_SESSION_GRANTS_PATH}`,
433
- send_image_url: imageGrant.url,
449
+ send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
450
+ send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
434
451
  send_image_script: join(feishuSkillDir, "send-image.mjs"),
435
452
  send_file_script: join(feishuSkillDir, "send-file.mjs"),
436
453
  download_video_script: join(feishuSkillDir, "download-video.mjs"),
437
454
  };
438
- setSessionGrants(sessionId, imageGrant, fileGrant);
439
455
  const imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
440
- // 渲染子文档到缓存目录,供 Agent 按需读取
441
456
  await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
442
457
  const userTextWithCapabilities = [
443
458
  ...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
@@ -446,37 +461,43 @@ export async function resumeAndPrompt(
446
461
  "[/User message]",
447
462
  ].join("\n");
448
463
 
464
+ // 设置活跃 prompt
449
465
  const controller = new AbortController();
450
-
451
- chatSessionMap.set(chatId, {
452
- gen: ++sessionGen,
453
- close: () => controller.abort(),
454
- cardId: null,
466
+ const now = Date.now();
467
+ activePrompts.set(sessionId, {
468
+ controller,
455
469
  stopped: false,
456
- accumulatedContent: "",
457
- finalText: "",
458
- spinnerTimer: null,
459
- msgTimestamp,
460
- sequence: 0,
461
- cardBusy: false,
470
+ startTime: now,
462
471
  });
463
- const myGen = sessionGen;
464
-
465
- setChatAvatar(token, chatId, tool, "busy").catch(() => {});
466
472
 
467
- const now = Date.now();
468
- const existingInfo = sessionInfoMap.get(chatId);
473
+ // 更新 sessionInfoMap(所有绑定群共用)
474
+ const existingInfo = sessionInfoMap.get(_chatId);
469
475
  const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
470
476
  const nextContextTokens = existingInfo?.lastContextTokens ?? 0;
471
- sessionInfoMap.set(chatId, {
472
- sessionId,
473
- turnCount: nextTurnCount,
474
- lastContextTokens: nextContextTokens,
475
- startTime: now,
476
- tool,
477
- });
477
+ // 对所有绑定的 chatId 更新 sessionInfoMap
478
+ for (const cid of getChatsForSession(sessionId)) {
479
+ const ei = sessionInfoMap.get(cid);
480
+ sessionInfoMap.set(cid, {
481
+ sessionId,
482
+ turnCount: nextTurnCount,
483
+ lastContextTokens: nextContextTokens,
484
+ startTime: now,
485
+ tool,
486
+ });
487
+ }
488
+ // 确保触发群也在 map 中
489
+ if (!sessionInfoMap.has(_chatId)) {
490
+ sessionInfoMap.set(_chatId, {
491
+ sessionId,
492
+ turnCount: nextTurnCount,
493
+ lastContextTokens: nextContextTokens,
494
+ startTime: now,
495
+ tool,
496
+ });
497
+ }
498
+
478
499
  await recordSessionRegistry({
479
- chatId,
500
+ chatId: _chatId,
480
501
  sessionId,
481
502
  tool,
482
503
  turnCount: nextTurnCount,
@@ -485,28 +506,16 @@ export async function resumeAndPrompt(
485
506
  running: true,
486
507
  });
487
508
 
488
- let cardId: string | null = null;
489
- cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
490
- if (tid) logTrace(tid, "CARD_CREATE_FAIL", { error: (err as Error).message });
491
- console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
492
- fileLog.flush();
493
- sendTextReply(token, chatId, "⚠️ 流式卡片创建失败(可能因限流),将使用文本回复。").catch(() => {});
494
- return null;
495
- });
496
- if (cardId) {
497
- const cEntry = chatSessionMap.get(chatId);
498
- if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
499
- const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
500
- if (tid) logTrace(tid, "CARD_SEND_FAIL", { cardId, error: (err as Error).message });
501
- console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
502
- fileLog.flush();
503
- return false;
504
- });
505
- if (!sendOk) {
506
- sendTextReply(token, chatId, "⚠️ 卡片发送失败,将使用文本回复。").catch(() => {});
507
- cardId = null;
508
- if (cEntry) { cEntry.cardId = null; cEntry.sequence = 0; }
509
- }
509
+ // 初始化 stream-state.json
510
+ const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
511
+ await writeStreamState(initialState);
512
+
513
+ // 启动 display loop
514
+ ensureDisplayLoop(sessionId);
515
+
516
+ // 设置所有绑定群头像为 busy
517
+ for (const cid of getChatsForSession(sessionId)) {
518
+ setChatAvatar(token, cid, tool, "busy").catch(() => {});
510
519
  }
511
520
 
512
521
  const state: AccumulatorState = {
@@ -516,96 +525,21 @@ export async function resumeAndPrompt(
516
525
  chunkCount: 0,
517
526
  };
518
527
 
519
- let cardCreatedAt = Date.now();
520
- const CARD_ROTATE_MS = 9 * 60 * 1000;
521
-
522
- let dotCount = 0;
523
- let lastSentContent = "";
524
- let streamErrorNotified = false;
525
- let healthLogTicks = 0;
526
- // 兜底:setInterval 不 await 异步回调,回调内任何漏接的异常都会变成
527
- // unhandledRejection 进而(在 Node 默认策略下)让进程崩。这里用 IIFE + .catch
528
- // 整体兜一层,配合内部两个细粒度 try/catch 一起守住。
529
- const sendInterval = cardId ? setInterval(() => {
530
- void (async () => {
531
- const cEntry = chatSessionMap.get(chatId);
532
- if (!cEntry || cEntry.stopped || cEntry.cardBusy) return;
533
- if (cEntry.cardId !== cardId) return;
534
-
535
- if (Date.now() - cardCreatedAt > CARD_ROTATE_MS) {
536
- cEntry.cardBusy = true;
537
- try {
538
- const oldSeqBase = cEntry.sequence;
539
- const oldDisplay = truncateContent(state.accumulatedContent + pickFinalReply(state)) || "处理中...";
540
- const oldCard = buildProgressCard(oldDisplay, { showStop: false, headerTitle: "生成中...(上轮)" });
541
- await updateCardKitCard(token, cardId!, oldCard, oldSeqBase + 1).catch(() => {});
542
- const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
543
- if (!newCardId) throw new Error("createCardKitCard returned empty");
544
- await sendCardKitMessage(token, chatId, newCardId);
545
- cardId = newCardId;
546
- cEntry.cardId = newCardId;
547
- cEntry.sequence = 1;
548
- cardCreatedAt = Date.now();
549
- lastSentContent = "";
550
- streamErrorNotified = false;
551
- console.log(`[${ts()}] [CARDIKT] rotated: old=${oldSeqBase} new=${newCardId} (9min timeout)`);
552
- } catch (err) {
553
- console.error(`[${ts()}] [CARDIKT] rotation FAIL: ${(err as Error).message}`);
554
- } finally {
555
- cEntry.cardBusy = false;
556
- }
557
- return;
558
- }
559
-
560
- dotCount = (dotCount % 9) + 1;
561
- const content = truncateContent(state.accumulatedContent + pickFinalReply(state) + "\n" + "。".repeat(dotCount));
562
- if (content === lastSentContent) return;
563
-
564
- lastSentContent = content;
565
- cEntry.cardBusy = true;
566
- const mySeq = cEntry.sequence + 1;
567
- try {
568
- const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
569
- await updateCardKitCard(token, cardId!, card, mySeq);
570
- cEntry.sequence = mySeq;
571
- cEntry.accumulatedContent = state.accumulatedContent;
572
- streamErrorNotified = false;
573
- healthLogTicks++;
574
- if (healthLogTicks % 10 === 0) {
575
- console.log(`[${ts()}] [CARDIKT] update health: seq=${mySeq} content=${state.accumulatedContent.length}chars text=${state.finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
576
- }
577
- } catch (err) {
578
- console.error(`[${ts()}] CardKit update error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
579
- if (!streamErrorNotified) {
580
- streamErrorNotified = true;
581
- sendTextReply(token, chatId, "⚠️ 卡片更新失败,结果将以文本形式发送。").catch(() => {});
582
- }
583
- } finally {
584
- cEntry.cardBusy = false;
585
- }
586
- })().catch((err: unknown) => {
587
- const e = err instanceof Error ? err : new Error(String(err));
588
- console.error(`[${ts()}] [CARDIKT] spinner tick uncaught: ${e.message}\n${e.stack ?? ""}`);
589
- const entry = chatSessionMap.get(chatId);
590
- if (entry) entry.cardBusy = false;
591
- });
592
- }, 3000) : null;
593
- if (sendInterval) {
594
- const entry = chatSessionMap.get(chatId);
595
- if (entry) entry.spinnerTimer = sendInterval;
596
- }
528
+ let lastFileWrite = Date.now();
529
+ const FILE_WRITE_INTERVAL_MS = 2000;
597
530
 
598
531
  try {
599
532
  for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
600
533
  for (const block of unifiedMsg.blocks) {
601
534
  accumulateBlockContent(block, state);
602
535
 
603
- // 更新持久化上下文 token 数(compact_boundary 事件)
604
536
  if (block.type === "compact_boundary" && block.post_tokens) {
605
- const info = sessionInfoMap.get(chatId);
606
- if (info) { info.lastContextTokens = block.post_tokens; }
537
+ for (const cid of getChatsForSession(sessionId)) {
538
+ const sinfo = sessionInfoMap.get(cid);
539
+ if (sinfo) sinfo.lastContextTokens = block.post_tokens;
540
+ }
607
541
  await recordSessionRegistry({
608
- chatId,
542
+ chatId: _chatId,
609
543
  sessionId,
610
544
  tool,
611
545
  lastContextTokens: block.post_tokens,
@@ -613,110 +547,234 @@ export async function resumeAndPrompt(
613
547
  });
614
548
  }
615
549
  }
550
+
551
+ // 定时写入文件
552
+ const now2 = Date.now();
553
+ if (now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
554
+ lastFileWrite = now2;
555
+ await writeStreamState({
556
+ sessionId,
557
+ status: "running",
558
+ accumulatedContent: state.accumulatedContent,
559
+ finalReply: pickFinalReply(state),
560
+ chunkCount: state.chunkCount,
561
+ turnCount: nextTurnCount,
562
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
563
+ updatedAt: now2,
564
+ cwd,
565
+ tool,
566
+ });
567
+ }
616
568
  }
617
569
  } catch (streamErr) {
618
- console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
570
+ console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
619
571
  } finally {
620
- if (sendInterval) clearInterval(sendInterval);
621
- revokeAgentImageGrant(imageGrant.token);
622
- revokeAgentFileGrant(fileGrant.token);
623
- }
572
+ // 标记 prompt 结束
573
+ const prompt = activePrompts.get(sessionId);
574
+ const wasStopped = prompt?.stopped ?? false;
575
+ activePrompts.delete(sessionId);
576
+
577
+ // 写最终状态
578
+ const finalStatus = wasStopped ? "stopped" : "done";
579
+ const finalReply = pickFinalReply(state).trim();
580
+ await writeStreamState({
581
+ sessionId,
582
+ status: finalStatus,
583
+ accumulatedContent: state.accumulatedContent,
584
+ finalReply,
585
+ chunkCount: state.chunkCount,
586
+ turnCount: nextTurnCount,
587
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
588
+ updatedAt: Date.now(),
589
+ cwd,
590
+ tool,
591
+ });
624
592
 
625
- const cEntry = chatSessionMap.get(chatId);
626
- if (!cEntry || cEntry.gen !== myGen) return;
627
- clearSessionGrants(sessionId);
628
- const wasStopped = cEntry.stopped;
629
- const prevTs = lastMsgTimestamps.get(chatId);
630
- if (prevTs === undefined || cEntry.msgTimestamp > prevTs) {
631
- lastMsgTimestamps.set(chatId, cEntry.msgTimestamp);
632
- }
633
- chatSessionMap.delete(chatId);
634
- setChatAvatar(token, chatId, tool, "idle").catch(() => {});
593
+ // display loop 下一轮会读到最终状态并发送消息
635
594
 
636
- const finalCardContent = state.accumulatedContent || " ";
637
- if (cardId) {
638
- while (cEntry.cardBusy) {
639
- await new Promise(r => setTimeout(r, 20));
640
- }
641
- const nextSeq = cEntry.sequence + 1;
642
595
  if (wasStopped) {
643
- const stopCard = buildProgressCard(finalCardContent, { showStop: false, headerTitle: "已停止", headerTemplate: "red" });
644
- await updateCardKitCard(token, cardId, stopCard, nextSeq).catch((err) => {
645
- console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
646
- fileLog.flush();
647
- });
596
+ for (const cid of getChatsForSession(sessionId)) {
597
+ const finfo = sessionInfoMap.get(cid);
598
+ await recordSessionRegistry({
599
+ chatId: cid,
600
+ sessionId,
601
+ tool,
602
+ turnCount: finfo?.turnCount ?? nextTurnCount,
603
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
604
+ startTime: finfo?.startTime ?? now,
605
+ running: false,
606
+ });
607
+ }
608
+ console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
609
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
648
610
  } else {
649
- const doneCard = buildProgressCard(finalCardContent, { showStop: false, headerTitle: "完成" });
650
- await updateCardKitCard(token, cardId, doneCard, nextSeq).catch((err) => {
651
- console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
652
- fileLog.flush();
653
- sendTextReply(token, chatId, "⚠️ 卡片最终更新失败。").catch(() => {});
654
- });
611
+ for (const cid of getChatsForSession(sessionId)) {
612
+ const finfo = sessionInfoMap.get(cid);
613
+ await recordSessionRegistry({
614
+ chatId: cid,
615
+ sessionId,
616
+ tool,
617
+ turnCount: finfo?.turnCount ?? nextTurnCount,
618
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
619
+ startTime: finfo?.startTime ?? now,
620
+ running: false,
621
+ });
622
+ setChatAvatar(token, cid, tool, "idle").catch(() => {});
623
+ }
624
+ console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
625
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
655
626
  }
656
627
  }
628
+ }
657
629
 
658
- // 在 partial 累加 vs 适配器给出的 final 完整文本之间挑选;
659
- // Cursor 流末会发 final 完整快照,若与 partial 累加都直接发会出现两段重复。
660
- const finalReply = pickFinalReply(state).trim();
630
+ // ---------------------------------------------------------------------------
631
+ // ensureDisplayLoop 每个 session 一个 display 循环,读文件更新所有绑定群的卡片
632
+ // ---------------------------------------------------------------------------
661
633
 
662
- if (wasStopped) {
663
- const finalInfo = sessionInfoMap.get(chatId);
664
- await recordSessionRegistry({
665
- chatId,
666
- sessionId,
667
- tool,
668
- turnCount: finalInfo?.turnCount ?? nextTurnCount,
669
- lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
670
- startTime: finalInfo?.startTime ?? now,
671
- running: false,
634
+ const CARD_ROTATE_MS = 9 * 60 * 1000;
635
+
636
+ export function ensureDisplayLoop(sessionId: string): void {
637
+ if (displayLoops.has(sessionId)) return;
638
+
639
+ let dotCount = 0;
640
+
641
+ const interval = setInterval(() => {
642
+ void (async () => {
643
+ const state = await readStreamState(sessionId);
644
+ if (!state) return;
645
+
646
+ const chats = getChatsForSession(sessionId);
647
+ if (chats.length === 0) {
648
+ // 无绑定群,若 session 已结束则停止 loop
649
+ if (state.status !== "running") {
650
+ clearInterval(interval);
651
+ displayLoops.delete(sessionId);
652
+ }
653
+ return;
654
+ }
655
+
656
+ const isTerminal = state.status !== "running";
657
+
658
+ for (const chatId of chats) {
659
+ try {
660
+ // getTenantAccessToken 有缓存,多次调用开销低
661
+ const tokenModule = await import("./feishu-platform.ts");
662
+ const token = await tokenModule.getTenantAccessToken();
663
+ const display = displayCards.get(chatId);
664
+
665
+ if (isTerminal) {
666
+ // 发送最终结果
667
+ if (display) {
668
+ while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
669
+ const nextSeq = display.sequence + 1;
670
+ const headerTitle = state.status === "stopped" ? "已停止" : "完成";
671
+ const headerTemplate = state.status === "stopped" ? "red" : undefined;
672
+ const cardContent = state.accumulatedContent || " ";
673
+ const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
674
+ await updateCardKitCard(token, display.cardId, doneCard, nextSeq).catch(() => {});
675
+ displayCards.delete(chatId);
676
+ }
677
+ if (state.finalReply) {
678
+ await sendTextReply(token, chatId, state.finalReply).catch(() => {});
679
+ } else if (!display && state.accumulatedContent.trim()) {
680
+ const short = truncateContent(state.accumulatedContent, 30, 4000);
681
+ await sendTextReply(token, chatId, `[生成过程]\n${short}`).catch(() => {});
682
+ }
683
+ setChatAvatar(token, chatId, state.tool, "idle").catch(() => {});
684
+ } else {
685
+ // running: 创建或更新卡片
686
+ if (!display) {
687
+ const cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
688
+ if (cardId) {
689
+ await sendCardKitMessage(token, chatId, cardId).catch(() => null);
690
+ displayCards.set(chatId, {
691
+ cardId,
692
+ sequence: 1,
693
+ cardBusy: false,
694
+ cardCreatedAt: Date.now(),
695
+ lastSentContent: "",
696
+ streamErrorNotified: false,
697
+ });
698
+ }
699
+ } else {
700
+ if (display.cardBusy) continue;
701
+
702
+ // 卡片轮转
703
+ if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
704
+ display.cardBusy = true;
705
+ try {
706
+ const oldSeqBase = display.sequence;
707
+ const oldDisplayContent = truncateContent(state.accumulatedContent + state.finalReply) || "处理中...";
708
+ const oldCard = buildProgressCard(oldDisplayContent, { showStop: false, headerTitle: "生成中...(上轮)" });
709
+ await updateCardKitCard(token, display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
710
+ const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
711
+ if (newCardId) {
712
+ await sendCardKitMessage(token, chatId, newCardId);
713
+ display.cardId = newCardId;
714
+ display.sequence = 1;
715
+ display.cardCreatedAt = Date.now();
716
+ display.lastSentContent = "";
717
+ display.streamErrorNotified = false;
718
+ }
719
+ } catch (err) {
720
+ console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
721
+ } finally {
722
+ display.cardBusy = false;
723
+ }
724
+ continue;
725
+ }
726
+
727
+ dotCount = (dotCount % 9) + 1;
728
+ const content = truncateContent(state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount));
729
+ if (content === display.lastSentContent) continue;
730
+
731
+ display.lastSentContent = content;
732
+ display.cardBusy = true;
733
+ const mySeq = display.sequence + 1;
734
+ try {
735
+ const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
736
+ await updateCardKitCard(token, display.cardId, card, mySeq);
737
+ display.sequence = mySeq;
738
+ } catch (err) {
739
+ console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${(err as Error).message}`);
740
+ if (!display.streamErrorNotified) {
741
+ display.streamErrorNotified = true;
742
+ sendTextReply(token, chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
743
+ }
744
+ } finally {
745
+ display.cardBusy = false;
746
+ }
747
+ }
748
+ }
749
+ } catch (err) {
750
+ console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
751
+ }
752
+ }
753
+
754
+ if (isTerminal) {
755
+ clearInterval(interval);
756
+ displayLoops.delete(sessionId);
757
+ }
758
+ })().catch((err: unknown) => {
759
+ const e = err instanceof Error ? err : new Error(String(err));
760
+ console.error(`[${ts()}] Display loop uncaught for ${sessionId}: ${e.message}`);
672
761
  });
673
- if (finalReply) {
674
- await sendTextReply(token, chatId, finalReply).catch((err) =>
675
- console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
676
- );
677
- }
678
- console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${state.chunkCount})`);
679
- if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
680
- return;
681
- }
762
+ }, 3000);
682
763
 
683
- if (streamErrorNotified) {
684
- if (state.accumulatedContent.trim()) {
685
- const shortContent = truncateContent(state.accumulatedContent, 30, 4000);
686
- await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
687
- console.error(`[${ts()}] Failed to send content fallback: ${(err as Error).message}`)
688
- );
689
- }
690
- if (finalReply) {
691
- await sendTextReply(token, chatId, finalReply).catch((err) =>
692
- console.error(`[${ts()}] Failed to send text fallback: ${(err as Error).message}`)
693
- );
694
- }
695
- } else {
696
- if (finalReply) {
697
- await sendTextReply(token, chatId, finalReply).catch((err) =>
698
- console.error(`[${ts()}] Failed to send final text: ${(err as Error).message}`)
699
- );
700
- } else if (!cardId && state.accumulatedContent.trim()) {
701
- const shortContent = truncateContent(state.accumulatedContent, 30, 4000);
702
- await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
703
- console.error(`[${ts()}] Failed to send content text: ${(err as Error).message}`)
704
- );
705
- }
706
- }
764
+ displayLoops.set(sessionId, () => clearInterval(interval));
765
+ }
707
766
 
708
- console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
709
- const finalInfo = sessionInfoMap.get(chatId);
710
- await recordSessionRegistry({
711
- chatId,
712
- sessionId,
713
- tool,
714
- turnCount: finalInfo?.turnCount ?? nextTurnCount,
715
- lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
716
- startTime: finalInfo?.startTime ?? now,
717
- running: false,
718
- });
719
- if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
767
+ // ---------------------------------------------------------------------------
768
+ // stopSession 停止指定 session 的活跃 prompt
769
+ // ---------------------------------------------------------------------------
770
+
771
+ export function stopSession(sessionId: string): boolean {
772
+ const prompt = activePrompts.get(sessionId);
773
+ if (!prompt) return false;
774
+ prompt.stopped = true;
775
+ prompt.controller.abort();
776
+ console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
777
+ return true;
720
778
  }
721
779
 
722
780
  // ---------------------------------------------------------------------------
@@ -783,22 +841,29 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
783
841
  const info = sessionInfoMap.get(chatId);
784
842
  if (!info) return null;
785
843
 
786
- const active = chatSessionMap.get(chatId);
844
+ const isActive = activePrompts.has(info.sessionId) && !(activePrompts.get(info.sessionId)?.stopped);
787
845
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
788
846
 
789
847
  const registry = await loadSessionRegistry();
790
848
  const chatName = registry[chatId]?.chatName ?? "";
791
849
 
850
+ // 从 stream-state.json 获取当前累积长度
851
+ let accumulatedLength = 0;
852
+ const streamState = await readStreamState(info.sessionId);
853
+ if (streamState) {
854
+ accumulatedLength = streamState.accumulatedContent.length + streamState.finalReply.length;
855
+ }
856
+
792
857
  return {
793
858
  sessionId: info.sessionId,
794
859
  chatName,
795
- running: active !== undefined && !active.stopped,
860
+ running: isActive,
796
861
  turnCount: info.turnCount,
797
862
  lastContextTokens: info.lastContextTokens,
798
863
  startTime: info.startTime,
799
864
  model,
800
865
  effort,
801
- accumulatedLength: active ? active.accumulatedContent.length + active.finalText.length : 0,
866
+ accumulatedLength,
802
867
  };
803
868
  }
804
869