message-merge.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import { Message } from './api/models';
  2. import { isToolActivityMessage } from './message-segments';
  3. /**
  4. * 同一条回答被拆成多条消息时的合并(2026-11-06)
  5. *
  6. * ## 为什么要合并
  7. *
  8. * 容器回答一条问题时常把内容拆成多条 `m.room.message`:
  9. * · 超长内容分段发送
  10. * · 先发一段结论、再发一段细节
  11. * · 流式输出逐段落库
  12. *
  13. * 每段都变成独立气泡,用户读到的是「被切断的碎片」,
  14. * 而不是「一次回答」。用户要求:**同一回合同一发送者的连续消息合成一条**。
  15. *
  16. * ## 合并边界
  17. *
  18. * | 情况 | 是否合并 |
  19. * |---|---|
  20. * | 连续两条 agent 正文消息 | ✅ 合并 |
  21. * | 中间夹着工具执行过程(💻 / 🔎 …) | ❌ 断开(过程是另一类内容,已有折叠组) |
  22. * | 中间夹着用户消息 | ❌ 断开(那是另一个回合) |
  23. * | 带附件 / 任务的 agent 消息 | ❌ 不并入正文(附件要单独成卡) |
  24. * | 失败消息 | ❌ 不合并(要保留重试按钮与错误原因) |
  25. *
  26. * ## 为什么用纯函数
  27. *
  28. * 桌面端与移动端都要用同一套规则,写在组件里必然漂移。
  29. * 这里只做数据加工,便于直接单测。
  30. */
  31. /** 合并后的一条消息:除 `text` 外沿用首条的其余字段 */
  32. export interface MergedMessage extends Message {
  33. /** 被合并的原始消息 id(首条在前);用于 key 稳定 */
  34. mergedIds: string[];
  35. }
  36. /**
  37. * 这条消息是否可以作为正文参与合并。
  38. *
  39. * 🔴 这里不直接用 `isToolActivityMessage` 做唯一判据:
  40. * 它对**短文本**很宽松(单字母/单词会被当成命令),
  41. * 用它当合并闸门会让「好」「稍等」这类正常短回复被踢出正文。
  42. * 改为「明确的工具标记 → 不合并;否则长度判断」。
  43. */
  44. export function isMergeableBody(m: Message): boolean {
  45. if (m.sender !== 'agent') return false;
  46. if (m.state === 'failed') return false;
  47. if (m.file) return false; // 附件卡单独展示
  48. if (m.taskId || m.artifactId) return false; // 任务/成果卡单独展示
  49. const text = m.text?.trim();
  50. if (!text) return false;
  51. /*
  52. * 多行且每行都像命令 → 是执行过程,交给折叠组。
  53. * 单行只有**带明确终端标记**(💻 / 🔎 / 🖥 等 emoji 前缀)才排除,
  54. * 否则「收到」这种短回复会被误杀。
  55. */
  56. const lines = text.split(/\r?\n/).filter((l) => l.trim());
  57. if (lines.length >= 2 && isToolActivityMessage(text)) return false;
  58. if (lines.length === 1 && hasTerminalMarker(text)) return false;
  59. return true;
  60. }
  61. /**
  62. * 单行正文是否带终端活动标记(💻 terminal / 🔎 Searching …)
  63. *
  64. * 🔴 结果 emoji(✅❌⚠️)**不算**终端标记(2026-11-10 移动端实测)。
  65. *
  66. * 容器常在一条回复里用多行 emoji 列表输出结论:
  67. * ```
  68. * ✅ 成果表有记录
  69. * ❌ 但回复正文没有任何 https
  70. * ❌ 正文还有残留
  71. * ```
  72. * 旧规则把它们当成终端活动 → 每条各自成块 → 移动端一屏出现**一堆小卡片**
  73. * (用户截图实测)。
  74. *
  75. * 结果 emoji 表示「结论」,本来就该作为正文参与合并、读成一段。
  76. */
  77. function hasTerminalMarker(text: string): boolean {
  78. // 以结果 emoji 开头 → 是结论,不是执行过程
  79. if (/^[*·•\-]?\s*[✅❌⚠️✔✔️❎🚫🛑⛔🎉🏁]/u.test(text)) return false;
  80. // 以 emoji 或动词开头 → 视为执行过程
  81. if (/^[*·•\-]?\s*[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(text)) return true;
  82. return /^[*·•\-]?\s*(?:terminal|Searching|Reading|Writing|Running|Executing)\b/i.test(text);
  83. }
  84. /**
  85. * 把连续的、可合并的 agent 正文消息合成一条。
  86. *
  87. * @param messages 已按时间升序的消息列表
  88. * @returns 合并后的列表;不可合并的消息原样保留、顺序不变
  89. */
  90. export function mergeConsecutiveBodies(messages: Message[]): MergedMessage[] {
  91. const out: MergedMessage[] = [];
  92. /** 当前正在累积的正文组 */
  93. let buf: Message[] = [];
  94. const flush = (): void => {
  95. if (buf.length === 0) return;
  96. const first = buf[0];
  97. if (buf.length === 1) {
  98. out.push({ ...first, mergedIds: [first.id] });
  99. buf = [];
  100. return;
  101. }
  102. /*
  103. * 用两个换行拼接:Markdown 里这是分段,正文不会糊成一句。
  104. * 各段可能是「一段话」,也可能是「一个列表」,空行能同时保住两者的结构。
  105. */
  106. const text = buf
  107. .map((m) => (m.text ?? '').trim())
  108. .filter(Boolean)
  109. .join('\n\n');
  110. out.push({
  111. ...first,
  112. id: first.id,
  113. text,
  114. ts: buf[buf.length - 1].ts, // 展示时间取最后一段到达的时刻
  115. mergedIds: buf.map((m) => m.id),
  116. mentions: [...new Set(buf.flatMap((m) => m.mentions ?? []))],
  117. });
  118. buf = [];
  119. };
  120. for (const m of messages) {
  121. if (isMergeableBody(m)) {
  122. /*
  123. * 同一发送者才合并 —— 群聊里不同容器的消息即使相邻也不能拼在一起。
  124. * (群聊页已按 agentId 分块,这里再兜一层,防止误用。)
  125. */
  126. if (buf.length > 0 && buf[0].senderId !== m.senderId) flush();
  127. buf.push(m);
  128. continue;
  129. }
  130. flush();
  131. /*
  132. * 完全没有可展示内容的消息(撤回 / 表情回应 / 空 body)直接丢弃,
  133. * 不占用一个空气泡。带附件/任务/失败态的仍要保留 —— 它们有内容可展示。
  134. */
  135. const blank = !m.text?.trim() && !m.file && !m.taskId && !m.artifactId;
  136. if (blank) continue;
  137. out.push({ ...m, mergedIds: [m.id] });
  138. }
  139. flush();
  140. return out;
  141. }