| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149 |
- import { Message } from './api/models';
- import { isToolActivityMessage } from './message-segments';
- /**
- * 同一条回答被拆成多条消息时的合并(2026-11-06)
- *
- * ## 为什么要合并
- *
- * 容器回答一条问题时常把内容拆成多条 `m.room.message`:
- * · 超长内容分段发送
- * · 先发一段结论、再发一段细节
- * · 流式输出逐段落库
- *
- * 每段都变成独立气泡,用户读到的是「被切断的碎片」,
- * 而不是「一次回答」。用户要求:**同一回合同一发送者的连续消息合成一条**。
- *
- * ## 合并边界
- *
- * | 情况 | 是否合并 |
- * |---|---|
- * | 连续两条 agent 正文消息 | ✅ 合并 |
- * | 中间夹着工具执行过程(💻 / 🔎 …) | ❌ 断开(过程是另一类内容,已有折叠组) |
- * | 中间夹着用户消息 | ❌ 断开(那是另一个回合) |
- * | 带附件 / 任务的 agent 消息 | ❌ 不并入正文(附件要单独成卡) |
- * | 失败消息 | ❌ 不合并(要保留重试按钮与错误原因) |
- *
- * ## 为什么用纯函数
- *
- * 桌面端与移动端都要用同一套规则,写在组件里必然漂移。
- * 这里只做数据加工,便于直接单测。
- */
- /** 合并后的一条消息:除 `text` 外沿用首条的其余字段 */
- export interface MergedMessage extends Message {
- /** 被合并的原始消息 id(首条在前);用于 key 稳定 */
- mergedIds: string[];
- }
- /**
- * 这条消息是否可以作为正文参与合并。
- *
- * 🔴 这里不直接用 `isToolActivityMessage` 做唯一判据:
- * 它对**短文本**很宽松(单字母/单词会被当成命令),
- * 用它当合并闸门会让「好」「稍等」这类正常短回复被踢出正文。
- * 改为「明确的工具标记 → 不合并;否则长度判断」。
- */
- export function isMergeableBody(m: Message): boolean {
- if (m.sender !== 'agent') return false;
- if (m.state === 'failed') return false;
- if (m.file) return false; // 附件卡单独展示
- if (m.taskId || m.artifactId) return false; // 任务/成果卡单独展示
- const text = m.text?.trim();
- if (!text) return false;
- /*
- * 多行且每行都像命令 → 是执行过程,交给折叠组。
- * 单行只有**带明确终端标记**(💻 / 🔎 / 🖥 等 emoji 前缀)才排除,
- * 否则「收到」这种短回复会被误杀。
- */
- const lines = text.split(/\r?\n/).filter((l) => l.trim());
- if (lines.length >= 2 && isToolActivityMessage(text)) return false;
- if (lines.length === 1 && hasTerminalMarker(text)) return false;
- return true;
- }
- /**
- * 单行正文是否带终端活动标记(💻 terminal / 🔎 Searching …)
- *
- * 🔴 结果 emoji(✅❌⚠️)**不算**终端标记(2026-11-10 移动端实测)。
- *
- * 容器常在一条回复里用多行 emoji 列表输出结论:
- * ```
- * ✅ 成果表有记录
- * ❌ 但回复正文没有任何 https
- * ❌ 正文还有残留
- * ```
- * 旧规则把它们当成终端活动 → 每条各自成块 → 移动端一屏出现**一堆小卡片**
- * (用户截图实测)。
- *
- * 结果 emoji 表示「结论」,本来就该作为正文参与合并、读成一段。
- */
- function hasTerminalMarker(text: string): boolean {
- // 以结果 emoji 开头 → 是结论,不是执行过程
- if (/^[*·•\-]?\s*[✅❌⚠️✔✔️❎🚫🛑⛔🎉🏁]/u.test(text)) return false;
- // 以 emoji 或动词开头 → 视为执行过程
- if (/^[*·•\-]?\s*[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(text)) return true;
- return /^[*·•\-]?\s*(?:terminal|Searching|Reading|Writing|Running|Executing)\b/i.test(text);
- }
- /**
- * 把连续的、可合并的 agent 正文消息合成一条。
- *
- * @param messages 已按时间升序的消息列表
- * @returns 合并后的列表;不可合并的消息原样保留、顺序不变
- */
- export function mergeConsecutiveBodies(messages: Message[]): MergedMessage[] {
- const out: MergedMessage[] = [];
- /** 当前正在累积的正文组 */
- let buf: Message[] = [];
- const flush = (): void => {
- if (buf.length === 0) return;
- const first = buf[0];
- if (buf.length === 1) {
- out.push({ ...first, mergedIds: [first.id] });
- buf = [];
- return;
- }
- /*
- * 用两个换行拼接:Markdown 里这是分段,正文不会糊成一句。
- * 各段可能是「一段话」,也可能是「一个列表」,空行能同时保住两者的结构。
- */
- const text = buf
- .map((m) => (m.text ?? '').trim())
- .filter(Boolean)
- .join('\n\n');
- out.push({
- ...first,
- id: first.id,
- text,
- ts: buf[buf.length - 1].ts, // 展示时间取最后一段到达的时刻
- mergedIds: buf.map((m) => m.id),
- mentions: [...new Set(buf.flatMap((m) => m.mentions ?? []))],
- });
- buf = [];
- };
- for (const m of messages) {
- if (isMergeableBody(m)) {
- /*
- * 同一发送者才合并 —— 群聊里不同容器的消息即使相邻也不能拼在一起。
- * (群聊页已按 agentId 分块,这里再兜一层,防止误用。)
- */
- if (buf.length > 0 && buf[0].senderId !== m.senderId) flush();
- buf.push(m);
- continue;
- }
- flush();
- /*
- * 完全没有可展示内容的消息(撤回 / 表情回应 / 空 body)直接丢弃,
- * 不占用一个空气泡。带附件/任务/失败态的仍要保留 —— 它们有内容可展示。
- */
- const blank = !m.text?.trim() && !m.file && !m.taskId && !m.artifactId;
- if (blank) continue;
- out.push({ ...m, mergedIds: [m.id] });
- }
- flush();
- return out;
- }
|