import { FileCard, Message } from './api/models'; import { GroupTurn } from './group-turns'; import { mergeConsecutiveBodies, MergedMessage } from './message-merge'; import { isToolActivityMessage, ProcessStep, splitMessage } from './message-segments'; import { Turn } from './turn-aggregator'; /** * AI 输出的回合视图模型(2026-11-06 用户需求) * * ## 为什么要有这一层 * * 用户要求「所有输出在一个固定区域内持续追加,顶部过程区可折叠, * 下半部分是正文与文档宫格」,并且**双端、单聊与群聊效果一致**。 * * 但四个页面原本各写一套:桌面单聊是扁平流、桌面群聊是 turn → 容器块、 * 移动两页又是一套。规则一旦分叉就会漂移(右栏预览就是前车之鉴)。 * * 所以把「回合长什么样」收敛到这里:**纯函数,不依赖 Angular**, * 两端共用同一份数据,只在渲染层保留与端相关的样式差异。 * * ## 单聊与群聊的统一 * * - 群聊:一个回合有 N 个块(每个被 @ 的容器一块) * - 单聊:同样用块表达,但**固定只有 1 块**(`agentId: null`) * * 这样上层模板只认 `blocks[]`,不必区分单聊群聊。 * * ## 顶部过程区从哪来 * * 容器的回复把「过程行」和「正文」混在**同一条消息文本**里: * * ``` * 我先看一下环境。 * · bash * 找到了,正在改。 * ``` * * 两步取过程: * ① 纯终端消息(整条都是 `· bash` 这类)→ 整条进过程区 * ② 混在正文里的过程行 → 由 `splitMessage` 拆出来 * * 判定全部复用 `message-segments.ts` 的既有口径,**不在本文件新造正则**。 */ /** 下半区的内容块(顶部过程区已单独抽出,不在此列) */ export type TurnBodyBlock = | { kind: 'text'; key: string; message: MergedMessage } | { kind: 'file'; key: string; file: FileCard; message: Message } | { kind: 'task'; key: string; taskId: string; message: Message }; /** 一个「说话方」在回合内的呈现数据 */ export interface TurnBlockView { key: string; /** 单聊为 null;群聊为容器 id */ agentId: string | null; /** 群聊成员显示名(由宿主传入 `nameOf` 时才有) */ agentName?: string; /** 块内消息(单位:毫秒);0 表示无数据,不显示 */ seconds: number; /** 顶部过程区 */ steps: ProcessStep[]; /** 下半区正文 / 文件 / 任务 */ body: TurnBodyBlock[]; /** 文件宫格的数据(从 body 的 file 项抽出,便于模板直接栅格渲染) */ files: { key: string; file: FileCard }[]; /** 本块是否仍在输出 */ live: boolean; /** 本块是否无内容且未在输出(群聊未响应) */ empty: boolean; /** * 本块自身合并后的正文(2026-11-07)。 * * 群聊多容器时,**每个容器单独渲染自己的正文**,不能跨容器拼接 —— * 用户要求「@ 了三个人就要看出谁说了什么」,拼在一起等于丢失归属。 * 单聊只有一块,用回合级的 `merged` 即可。 */ merged: TurnMergedBody | null; } export type TurnPhase = 'thinking' | 'replying' | 'done' | 'failed' | 'idle'; /** * 整轮合并后的正文(2026-11-07 用户需求)。 * * 用户要求「这一轮对话的所有输出融合到**一张卡片**里,不再是每段单独复制, * 顶部折叠思考和操作」。所以正文本不该按容器/消息分段,而应拼成一份。 */ export interface TurnMergedBody { /** 稳定 key(首条正文消息 id),用于模板 track */ key: string; /** 合并后的正文(Markdown) */ text: string; /** 参与合并的消息(末尾那条用于取时间戳、任务引用等元信息) */ message: Message; } /** 一个完整回合:一条用户消息 + 它的全部回复 */ export interface AssistantTurn { id: string; userMessage?: Message; /** 用户在同回合内额外发的文件(群聊「文字 + 多个文件」时) */ userFiles: Message[]; blocks: TurnBlockView[]; phase: TurnPhase; seconds: number; live: boolean; closed: boolean; /** 过程步骤总数,用于折叠头「运行了 N 步」 */ stepCount: number; /** 文件总数,用于宫格标题 */ fileCount: number; /** * 整轮合并后的正文与文件(2026-11-07)。 * * 「一整轮 = 一张卡」:不管中间隔了多少次工具调用,正文全部拼成一份 * 给卡片渲染,过程进顶部折叠区,文件进底部宫格。 * * 🔴 **单聊用这个**。群聊多容器时各自独立,见 `blocks[].merged`。 */ merged: TurnMergedBody | null; /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */ files: { key: string; file: FileCard }[]; } /** 构建一块所需的原料(由单聊/群聊两种输入各转一份) */ interface BlockSource { agentId: string | null; messages: Message[]; startedAt: number; updatedAt: number; typing: boolean; closed: boolean; } /* ------------------------------------------------------------------ */ /* 从原始消息派生「过程」与「正文」 */ /* ------------------------------------------------------------------ */ /** * 把一组消息拆成「顶部过程步骤」与「下半区内容块」。 * * 处理顺序很关键: * 1. 先按纯终端消息把整条消息**提走**进过程区(它不该出现在正文里) * 2. 剩下的正文消息交给 `mergeConsecutiveBodies` 合并连续片段 * 3. 合并后再用 `splitMessage` 把夹在正文里的过程行拆出来 * * 🔴 这里不能只认 `block.kind === 'process'`。 * `toBlocks()` 有个既有设计:**孤立单步(前后无空行)会被内联成 text** * (`message-segments.ts:281-285`),为的是「只有一个步骤时不必多点一次」。 * 但在本视图模型里,顶部过程区与下半区必须**严格互斥**,否则 * 「过程区折叠了、正文里却还留着那一行」。 * 所以这里按**行**再判一次:`· 工具名` 形态的行一律归过程区。 */ function splitIntoParts(messages: Message[]): { steps: ProcessStep[]; body: TurnBodyBlock[]; } { const steps: ProcessStep[] = []; const bodies: Message[] = []; for (const m of messages) { const text = m.text ?? ''; // ① 整条都是终端活动 → 过程区,不进正文 if (text && isToolActivityMessage(text) && !proseOf(text)) { steps.push(...stepsOf(text)); continue; } if (text.trim()) bodies.push(m); } /* * ② 合并连续正文。 * 带附件的消息由 `mergeConsecutiveBodies` 内部规则挡住(见 message-merge.ts 注释), * 它们不进 `bodies`,单独成块。 */ const merged = mergeConsecutiveBodies(bodies); const body: TurnBodyBlock[] = []; for (const mm of merged) { const { steps: s, text } = divideText(mm.text ?? ''); steps.push(...s); if (text.trim()) { body.push({ kind: 'text', key: mm.mergedIds[0], message: { ...mm, text: text.trim() } }); } if (mm.file) { body.push({ kind: 'file', key: `${mm.mergedIds[0]}-f`, file: mm.file, message: mm }); } if (mm.taskId) { body.push({ kind: 'task', key: `${mm.mergedIds[0]}-t`, taskId: mm.taskId, message: mm }); } } /* * ③ 只有附件、没有正文的消息不会被 `mergeConsecutiveBodies` 输出(它只处理 text), * 这里补回来,否则「容器只回了一个文件」会整个消失。 */ for (const m of messages) { if (!m.file && !m.taskId) continue; const covered = body.some((b) => b.message.id === m.id); if (covered) continue; if (m.file) body.push({ kind: 'file', key: `${m.id}-f`, file: m.file, message: m }); if (m.taskId) body.push({ kind: 'task', key: `${m.id}-t`, taskId: m.taskId, message: m }); } return { steps, body }; } /** * 从一段文本里取过程行(返回 steps)。 * 纯终端文本直接用 `splitMessage` 的 process 块。 */ function stepsOf(text: string): ProcessStep[] { const out: ProcessStep[] = []; for (const b of splitMessage(text)) { if (b.kind === 'process') out.push(...b.steps); } return out; } /** * 把一段**混合**文本拆成「过程步骤」与「纯正文」。 * * 两步: * ① `splitMessage` 能识别的 process 块 → 步骤 * ② 剩下的 text 块里,仍可能有被 `toBlocks` 内联的单步行(`· bash`),按行再剥一次 * * 返回的 text 已不含过程行,顶部折叠后正文不会再出现一遍。 */function divideText(text: string): { steps: ProcessStep[]; text: string } { const steps: ProcessStep[] = []; const keep: string[] = []; for (const b of splitMessage(text)) { if (b.kind === 'process') { /* * 🔴 `splitMessage` 也会把「行首 emoji + 成句中文」解析成 process 块 * (`💾 自我升级小结:…了。` 实测如此)。这类是**结论**不是过程, * 交还正文,否则折叠区里会出现半句话。 */ const prose = b.steps.filter((s) => proseOf(s.label)); const tools = b.steps.filter((s) => !proseOf(s.label)); steps.push(...tools); for (const p of prose) keep.push(p.label); continue; } if (b.kind !== 'text') continue; for (const line of b.text.split('\n')) { const inline = inlineStepOf(line); if (inline && !proseOf(line)) steps.push(inline); else keep.push(line); } } return { steps, text: keep.join('\n').replace(/\n{3,}/g, '\n\n') }; } /** 行首 `· 工具名` 形态 → 一步;否则 null */ const INLINE_STEP = /^\s*[·•]\s*([a-zA-Z][\w.\-]{0,40})(?:\s*[::]?\s*(.+))?$/; function inlineStepOf(line: string): ProcessStep | null { const m = INLINE_STEP.exec(line); if (!m) return null; return { kind: 'tool', label: m[1], detail: m[2]?.trim() || undefined }; } /** * 这条「看起来像终端活动」的文本,其实是一句**结论性叙述**吗? * * ## 为什么需要(2026-11-07 实测) * * 容器会这样汇报一次自我升级: * * ``` * 💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。 * ``` * * 行首 `💾` 命中了 `TOOL_EMOJI_LEAD`,于是整条被当成工具活动, * 折叠头上显示成「运行了 1 步 · 自我升级小结:…」—— * 用户要读的**结论**被折进了过程区。 * * ## 判定口径(宁可放过、不可误折) * * 同时满足才算叙述: * ① 以中文句末标点收尾(。!?) * ② 去掉行首 emoji 后,剩下的是一段**中文句子**(至少 6 个汉字) * * 真正的工具行(`💻 bash`、`✍️ Writing /path`)主体是英文工具名与路径, * 不含成句中文,不会被误判。 */ function proseOf(text: string): boolean { const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); if (lines.length === 0) return false; // 只要有一条是「成句中文」,整条就当正文 —— 终端活动不会夹杂叙述 return lines.some((line) => { if (!/[。!?]$/.test(line)) return false; // 剥掉行首的项目符号与 emoji(含变体选择符 / 零宽连接符) const body = line .replace(/^[\s*·•-]+/, '') .replace(/^[\p{Extended_Pictographic}️‍\u{1F3FB}-\u{1F3FF}]+\s*/u, ''); return (body.match(/[一-龥]/g)?.length ?? 0) >= 6; }); } /* ------------------------------------------------------------------ */ /* 块 → 视图 */ /* ------------------------------------------------------------------ */ function toBlockView(src: BlockSource, now: number, quietMs: number): TurnBlockView { const { steps, body } = splitIntoParts(src.messages); const files = body .filter((b): b is Extract => b.kind === 'file') .map((b) => ({ key: b.key, file: b.file })); /* * live 判定与 `isTurnOpen` 同口径:容器正在 typing,或末条消息至今未超过静默窗口。 * 群聊里某个成员可能先回完,所以按**块**判断而不是按回合。 */ const live = !src.closed && (src.typing || now - src.updatedAt < quietMs); const seconds = src.startedAt > 0 && src.updatedAt >= src.startedAt ? Math.max(0, Math.round((src.updatedAt - src.startedAt) / 1000)) : 0; return { key: src.agentId ?? 'agent', agentId: src.agentId, seconds, steps, body, files, live, empty: !live && body.length === 0 && steps.length === 0, merged: mergeBodies([{ body }]), }; } /** 由各块合成回合阶段 */ function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase { if (blocks.some((b) => b.live && (b.body.length > 0 || b.steps.length > 0))) return 'replying'; if (blocks.some((b) => b.live)) return 'thinking'; if (blocks.length === 0) return closed ? 'idle' : 'thinking'; // 全部结束:有内容就算完成,全空算未响应 return blocks.some((b) => !b.empty) ? 'done' : 'idle'; } /* ------------------------------------------------------------------ */ /* 两种输入 → 统一的回合视图 */ /* ------------------------------------------------------------------ */ /** * 群聊:`buildGroupTurns` 的结果 → 回合视图。 * * 每个 `GroupBlock` 对应一个块;块顺序保持 `targets` 的声明顺序。 */ export function turnsFromGroup( turns: GroupTurn[], now: number, quietMs = 3000, nameOf?: (agentId: string) => string, ): AssistantTurn[] { return turns.map((t) => { const blocks = t.blocks.map((b) => { const view = toBlockView( { agentId: b.agentId, messages: b.messages, startedAt: b.startedAt, updatedAt: b.updatedAt, typing: b.typing, closed: b.closed, }, now, quietMs, ); const name = nameOf?.(b.agentId); return name ? { ...view, agentName: name } : view; }); const live = blocks.some((b) => b.live); return { id: t.id, userMessage: t.userMessage, userFiles: t.userFiles ?? [], blocks, phase: phaseOf(blocks, t.closed), seconds: roundSeconds(t.blocks), live, closed: t.closed, stepCount: blocks.reduce((n, b) => n + b.steps.length, 0), fileCount: blocks.reduce((n, b) => n + b.files.length, 0), merged: mergeBodies(blocks), files: collectFiles(blocks), }; }); } /** * 单聊:`buildTurns` 的结果 → 回合视图(固定 1 块)。 */ export function turnsFromDm(turns: Turn[], now: number, typing: boolean, quietMs = 3000): AssistantTurn[] { return turns.map((t) => { const block = toBlockView( { agentId: null, messages: t.blocks, startedAt: t.startedAt, updatedAt: t.updatedAt, typing, closed: t.closed, }, now, quietMs, ); const live = block.live; return { id: t.id, userMessage: t.userMessage, userFiles: [], blocks: [block], phase: phaseOf([block], t.closed), seconds: block.seconds, live, closed: t.closed, stepCount: block.steps.length, fileCount: block.files.length, merged: mergeBodies([block]), files: collectFiles([block]), }; }); } /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */ function collectFiles(blocks: TurnBlockView[]): { key: string; file: FileCard }[] { const seen = new Set(); const out: { key: string; file: FileCard }[] = []; for (const b of blocks) { for (const f of b.files) { // 同名同大小的重复文件只出一张卡(多容器转发同一份时) const sig = `${f.file.name}|${f.file.size ?? 0}|${f.file.url}`; if (seen.has(sig)) continue; seen.add(sig); out.push(f); } } return out; } /** * 把整轮所有块的正文拼成一份。 * * 块之间用空行分隔(Markdown 段落边界),保持可读; * key 取首条正文消息 id,**不随内容增长而变** —— 否则流式时模板会重建 DOM。 */ function mergeBodies(blocks: { body: TurnBodyBlock[] }[]): TurnMergedBody | null { const parts: string[] = []; let first: Message | null = null; let last: Message | null = null; for (const b of blocks) { for (const part of b.body) { if (part.kind !== 'text') continue; const t = (part.message.text ?? '').trim(); if (!t) continue; parts.push(t); if (!first) first = part.message; last = part.message; } } if (!first || !last) return null; return { key: first.id, text: parts.join('\n\n'), message: last }; } /** 回合耗时:首条 agent 消息到末条(各块取并集,避免多块重复计算) */ function roundSeconds(blocks: { startedAt: number; updatedAt: number }[]): number { const starts = blocks.map((b) => b.startedAt).filter((n) => n > 0); const ends = blocks.map((b) => b.updatedAt).filter((n) => n > 0); if (starts.length === 0 || ends.length === 0) return 0; return Math.max(0, Math.round((Math.max(...ends) - Math.min(...starts)) / 1000)); } /** 便捷入口:判断回合里是否有该文件(避免模板里重复过滤) */ export function hasFiles(t: AssistantTurn): boolean { return t.fileCount > 0; }