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; }