import { ChangeDetectionStrategy, Component, OnDestroy, computed, effect, inject, input, output, signal, untracked } from '@angular/core'; import { FileCard, Message } from '../../../core/api/models'; import { AssistantTurn, TurnBlockView, elapsedSeconds, waitText } from '../../../core/assistant-turn'; import { TurnCollapseService } from '../../../core/turn-collapse.service'; import { AssistantFilesComponent } from '../assistant-files/assistant-files'; import { MessageBubbleComponent } from '../message-bubble/message-bubble'; /** * 一个 AI 回合(2026-11-06 用户需求,2026-11-07 按单聊渲染重做) * * ## 用户要求 * * 「所有输出汇聚到一个固定区域内,后续回复持续追加; * 顶部把 AI 的操作、思考融合进去,可点击折叠,输出时默认展开; * 下半部分是主要内容区,文档以宫格列表呈现; * **基于单聊的渲染逻辑**,并**模拟流式输出**。」 * * ## 与单聊的关系(2026-11-07 修正) * * 正文**直接交给 `app-message-bubble`** —— 与单聊是同一个组件, * 排版、代码块、复制按钮、时间脚注天然一致。 * 本组件只额外负责两件事:把过程行提到顶部、把文件收进宫格。 * * ## 结构 * * ``` * ┌────────────────────────────────────────┐ * │ ⌄ 运行了 3 步 · 💻 terminal · 4s 已完成 │ ← 顶部过程区 * ├────────────────────────────────────────┤ * │ 正文(app-message-bubble,与单聊同款) │ * │ ┌──────┐ ┌──────┐ ┌──────┐ │ ← 文件宫格 * │ │ a.md │ │ b.pdf│ │ c.csv│ │ * │ └──────┘ └──────┘ └──────┘ │ * └────────────────────────────────────────┘ * ``` * * ## 仿流式 * * Matrix 不支持真流式,容器是「攒一段发一段」。这里做**打字机增量**: * 当最后一段正文的文本变长时,只把**新增的那一截**以极短间隔吐出来, * 视觉上接近逐字生成;文本整体替换(重新生成)时直接切换,不做动画。 * * 关键约束: * · 不逐字重渲染 Markdown(会导致代码块闪烁、滚动跳动) * · 尊重 `prefers-reduced-motion`(直接显示完整文本) */ @Component({ selector: 'app-assistant-turn', changeDetection: ChangeDetectionStrategy.OnPush, imports: [AssistantFilesComponent, MessageBubbleComponent], template: `
@if (turn().stepCount > 0) { } @if (expanded() && turn().stepCount > 0) { }
@if (thinking()) {

{{ thinking() }}

} @for (seg of segments(); track seg.key) {
@if (multi()) {
{{ seg.name }} @if (segTime()[seg.key] > 0) { {{ segTime()[seg.key] }}s }
} @if (seg.message; as msg) { @if (seg.streaming) { } } @else if (seg.waiting) {

{{ waitLabels()[seg.key] }}

}
} @if (turn().files.length > 0) { }
`, styles: ` @use '../../../core/styles/breakpoints' as bp; :host { display: block; } .turn { display: flex; flex-direction: column; gap: var(--sp-2); } /* ---------- 顶部过程区 ---------- */ .turn-head { display: flex; align-items: center; gap: var(--sp-2); align-self: flex-start; max-width: 100%; min-height: 32px; padding: var(--sp-1) var(--sp-3); border: 1px solid var(--edge); border-radius: var(--r-tag); background: var(--surface-2); color: var(--muted); font-size: var(--fs-12); cursor: pointer; transition: background-color var(--motion-fast) var(--ease-standard); } .turn-head:hover { background: var(--surface-hover); } .chev { flex: none; font-size: var(--fs-11); transition: transform var(--motion-fast) var(--ease-standard); } .chev.open { transform: rotate(90deg); } .head-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .head-time, .head-phase { flex: none; color: var(--faint); } .head-phase.busy { color: var(--primary); } .step-list { margin: 0; padding: 0 0 0 var(--sp-3); list-style: none; display: flex; flex-direction: column; gap: 2px; } .step { display: flex; align-items: baseline; gap: var(--sp-2); min-width: 0; font-size: var(--fs-11); color: var(--muted); } .step[data-kind='status'] .step-label { color: var(--info); } .dot { flex: none; width: 4px; height: 4px; border-radius: var(--r-tag); background: var(--edge-strong); transform: translateY(-2px); } .step-label { flex: none; font-weight: 600; } .step-detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono, ui-monospace, monospace); } /* ---------- 卡片 ---------- */ .card { display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0; } .card-members { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-12); color: var(--muted); } .member-dot { width: 6px; height: 6px; border-radius: var(--r-tag); flex: none; } .card-wait { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-size: var(--fs-13); color: var(--muted); } /* ---------- 卡内分段(群聊多容器时每段一个容器) ---------- */ .seg { display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0; } /* 多段之间用留白 + 细分隔线,一眼看出「换人了」 */ .seg + .seg { margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--edge); } .seg-head { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-12); color: var(--muted); } .seg-dot { width: 6px; height: 6px; border-radius: var(--r-tag); flex: none; } .seg-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: var(--ink-2); } .seg-time { margin-left: auto; flex: none; color: var(--faint); } /* * 思考 / 输出中状态(2026-11-07 用户反馈「不明显」): * 原来只是一行灰色小字,混在正文里几乎看不见。 * 改成带底色与呼吸动画的胶囊,一眼能看出「还在跑」。 */ .seg-wait { display: inline-flex; align-items: center; gap: var(--sp-2); align-self: flex-start; margin: 0; min-height: 28px; padding: var(--sp-1) var(--sp-3); border-radius: var(--r-tag); background: var(--primary-subtle); color: var(--primary); font-size: var(--fs-12); animation: wait-breathe 1.6s var(--ease-standard) infinite; } .seg-wait i { font-size: var(--fs-11); } @keyframes wait-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } /* ---------- 输出中的闪烁光标 ---------- */ .caret { display: inline-block; width: 7px; height: 7px; margin-left: 2px; border-radius: 50%; background: var(--ink); vertical-align: middle; animation: caret-blink 1s steps(2, start) infinite; } @keyframes caret-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.15; } } @include bp.below-mobile { /* 移动端:触达区 ≥44px、正文字号 ≥16px(iOS 不缩放) */ .turn-head { min-height: var(--touch-min); padding: var(--sp-2) var(--sp-3); font-size: var(--fs-17); } .chev { font-size: var(--fs-15); } .head-time, .head-phase { font-size: var(--fs-13); } .step { min-height: 28px; font-size: var(--fs-13); } .img { max-width: 100%; } .seg-head { font-size: var(--fs-13); } .seg-wait, .card-wait { font-size: var(--fs-17); } /* 分段之间留更大间隔,移动端更容易区分「换人了」 */ .seg + .seg { margin-top: var(--sp-6); padding-top: var(--sp-4); } .caret { width: 8px; height: 8px; } } @media (prefers-reduced-motion: reduce) { .chev { transition: none; } /* 减少动效:光标不闪,用低透明度静态圆点表示「还在写」 */ .caret { animation: none; opacity: 0.5; } /* 呼吸动画同样关闭,保留静态胶囊(颜色已足够区分状态) */ .seg-wait { animation: none; } } `, }) export class AssistantTurnComponent implements OnDestroy { private readonly collapse = inject(TurnCollapseService); readonly turn = input.required(); /** 群聊为 true:多块时显示成员名 */ readonly grouped = input(false); readonly preview = output(); readonly openTask = output(); protected readonly showBlockHead = computed(() => this.grouped()); protected readonly expanded = computed(() => this.collapse.isExpanded(this.turn().id, this.turn().live), ); protected readonly allSteps = computed(() => this.turn().blocks.flatMap((b) => b.steps)); protected readonly headText = computed(() => { const n = this.turn().stepCount; const first = this.allSteps()[0]?.label ?? ''; const label = n === 1 ? '1 步' : `${n} 步`; return first ? `运行了 ${label} · ${first}` : `运行了 ${label}`; }); protected readonly phaseText = computed(() => { switch (this.turn().phase) { case 'thinking': return '思考中'; case 'replying': return '输出中'; case 'done': return '已完成'; case 'progressOnly': /* * 容器干了一轮活,但没产出可交付内容(2026-11-10 新增)。 * * 2026-11-11 改文案:「无交付」听起来像失败,实际是「做了事, * 但没有可下载的产物」——比如只跑了工具、或只回了过程日志。 * 配合 `live` 的长任务宽限(60 秒),这一档只在真的停住时才出现。 */ return '已结束 · 仅过程记录'; case 'failed': return '失败'; default: return ''; } }); /* ---------------- 仿流式 ---------------- */ /** * 实时时钟(每秒跳一次,仅在 live 时运行)。 * * 等待文案里的秒数由它驱动,避免「13s」停在原地不动。 */ protected readonly now = signal(Date.now()); /** * **每个块**已「吐出」的文本长度(2026-11-07 群聊流式修复)。 * * ## 为什么按块而不是整轮一个数字 * * 群聊里会被 @ 的多个容器**并行输出**,各自进度完全不同。 * 此前整轮只维护一个 `shown`,取的是回合级合并文本的长度, * 于是: * · A 块吐到 100 字、B 块才刚到 20 字时,B 会被按 A 的进度截断 * · 三段内容长度对不上,表现为「有的不动、有的乱跳」 * 改成 `Map` 后,每块独立推进、互不干扰。 */ private readonly shownMap = signal>({}); private raf: number | null = null; /** 上一次推进的时间戳,用于按真实帧间隔算步长 */ private lastTick = 0; private readonly reduceMotion = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; constructor() { effect(() => { const t = this.turn(); const targets = this.targetsOf(t); const settled = this.settledKeys(t); untracked(() => this.advance(targets, settled)); }); /* * 等待计时(2026-11-09)。 * * 🔴 「已送达,等待回复…(13s)」里的秒数要自己走 —— 回合数据只在 * 上一条消息变化时重算,静默等待期间没人推进它,秒数会冻住。 * 只在 live 时起表,结束即清,不常驻。 */ effect(() => { /* * 2026-11-11:静默期(`live` 已转 false 但仍在等回复)也要走表 —— * 否则「已送达,等待回复…(Ns)」的秒数会冻住,看起来像卡死。 */ const t = this.turn(); if (!t.live && !t.blocks.some((b) => b.waiting)) return; const id = setInterval(() => this.now.set(Date.now()), 1000); return () => clearInterval(id); }); } ngOnDestroy(): void { this.cancel(); } private cancel(): void { if (this.raf !== null && typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(this.raf); } this.raf = null; } /** * 各块的**目标**长度。 * * 每块以自己的正文为准(2026-11-10 拆轮后单聊也可能有多块); * 单块时等价于回合级合并文本。 */ private targetsOf(t: AssistantTurn): Record { const out: Record = {}; for (const b of t.blocks) { out[b.key] = (b.merged ?? (t.blocks.length === 1 ? t.merged : null))?.text.length ?? 0; } return out; } /** * 已结束的块 key 集合(2026-11-11 用户反馈)。 * * 用户原话:「打开其他的对话记录的时候,文字还会有动画效果,这个是不对的。 * 只有我们在输出的时候才会有动画;查看会话历史记录时应该直接显示。」 * * ## 根因 * * 组件是新建的,`shownMap` 恒为空 → `advance()` 把所有块都当「新出现的块」 * 从 0 开始推 → **历史会话打开时整篇逐字重放**。 * * ## 修法 * * 推进时,**不属于 `live` 的块直接给满格**,只有 live 块才从 0 演。 * 这样: * · 打开历史会话 → 所有块都已结束 → 一次到位,无动画 * · 发新消息 → 新块 live → 正常逐段生成 */ private settledKeys(t: AssistantTurn): Set { return new Set(t.blocks.filter((b) => !b.live).map((b) => b.key)); } /** * 推进各块的显示长度。 * * ## 为什么用 rAF 而不是 setInterval(2026-11-07 用户反馈「卡顿」) * * 每次更新显示长度都会让气泡的 `message` 输入变成新对象 → * 整个 Markdown 重新解析渲染。Markdown 解析本身很重,固定 24ms * (≈42fps)地去触发,浏览器根本追不上,表现就是一顿一顿的。 * * 改为**跟随刷新率**推进,并按「剩余量」自适应步长:剩余多就多吐, * 剩余少就收敛到逐字。帧率天然对齐 60fps,不会出现定时器与渲染 * 帧错位造成的抖动。 * * ## 何时不做动画 * * · 系统开启「减少动态效果」 * · 不是实时输出(历史消息、刷新后回放)→ 一次到位 * · 内容变短(重新生成)→ 直接跳转,不倒退 */ private advance(targets: Record, settled: Set = new Set()): void { this.cancel(); const live = this.turn().live; const cur = this.shownMap(); /* * 🔴 回合一结束 → **所有块**一次到位,不逐字重放(2026-11-11)。 * * 用户反馈:「打开别的对话记录时文字还在动画,只有输出时才该有动画。」 * 除了整回合判 live,下面还有一层按块的兜底(`settled`), * 两者叠加保证:历史会话里任何已结束的块都不会演动画。 */ if (this.reduceMotion || !live) { this.shownMap.set({ ...targets }); return; } /* * 块级兜底:即使回合整体仍 live(比如同回合里另一块还在跑), * **已经结束的块**也直接满格 —— 不能因为旁边在动就把历史内容重放一遍。 */ const preSettled: Record = {}; for (const k of settled) { if (targets[k] !== undefined) preSettled[k] = targets[k]; } /* * 🔴 实时回合的**首次**求值:从 0 开始逐字推进(2026-11-08 修复)。 * * 原来的写法是「首次出现 → 直接设为目标长度」,初衷是历史不该重放, * 但它分不清「历史回放」和「刚到达的新消息」—— * 组件是新建的,`shownMap` 恒为空,于是**每一条实时消息都被整段直出**, * 流式效果从未生效(用户实测反馈)。 * * 现在:非 live(历史)上面已经直接到位;走到这里的都是 live, * 于是从 0 开始推进,让用户看到逐段生成。 */ if (Object.keys(cur).length === 0) { const zero: Record = {}; for (const k of Object.keys(targets)) zero[k] = 0; /* 已结束的块从满格起,只有 live 块从 0 开始演 */ Object.assign(zero, preSettled); this.shownMap.set(zero); } const base = this.shownMap(); /* * 已结束的块无论之前推到哪,一律对齐到目标长度(不残留半截)。 * 放在 base 之后、reset 之前,保证下面的「变短不收缩」不会把它们截回去。 */ Object.assign(base, preSettled); /* * 目标变化:增长要动画;变短**不收缩**(2026-11-08)。 * * 「变短」通常意味着数据层暂时丢了条目(Matrix 重建 / 分页), * 而不是用户想看重新生成。收缩会让内容在眼前消失 —— * 正是「后续内容覆盖了之前的输出」的观感来源。 * 宁可暂时多显示,等下一轮数据回来再对齐。 */ const reset: Record = { ...base }; /** 是否有块「还没显示完」(不是「目标变了」) */ let needAdvance = false; for (const [k, target] of Object.entries(targets)) { const now = reset[k]; if (now === undefined) { // 新出现的块:从 0 开始(同样要流式,不直出) reset[k] = 0; if (target > 0) needAdvance = true; continue; } /* * 🔴 判「是否需要继续推进」,而不是「目标是否变化」(2026-11-09 修复)。 * * 原实现用 `target > now` 判断是否启动 rAF —— 那意味着: * · 目标没变但**还没追上**(上一帧被中断 / 组件重建)→ 直接 return, * `shown` 永远停在半截; * · 渲染时 `streaming = live && n < length` 又永远走 `slice(0, n)`, * 于是界面**只显示到 n 为止**,后面的内容看起来像「被覆盖掉了」。 * * 现在:只要还没追上目标就继续推进。变短仍保持不收缩。 */ if (target > now) needAdvance = true; // 注意:target < now 时**保持 now**,不收缩 } this.shownMap.set(reset); if (!needAdvance) return; this.lastTick = 0; const tick = (now: number): void => { const dt = this.lastTick ? Math.min(now - this.lastTick, 100) : 16; this.lastTick = now; const prev = this.shownMap(); const next: Record = { ...prev }; let remaining = false; for (const [k, target] of Object.entries(targets)) { const cur = prev[k] ?? 0; if (cur >= target) continue; remaining = true; /* * 自适应步长:约 1.2 秒追上当前剩余量,每帧至少 1 字。 * dt=16ms、remain=120 字 → 每帧约 1.6 字 */ const step = Math.max(1, Math.ceil(((target - cur) / 1200) * dt)); next[k] = Math.min(cur + step, target); } if (remaining) { this.shownMap.set(next); this.raf = requestAnimationFrame(tick); } else { this.raf = null; } }; this.raf = requestAnimationFrame(tick); } /** 群聊多容器时,每块单独一段;单聊整体一段 */ protected readonly multi = computed(() => this.grouped() && this.turn().blocks.length > 1); /** * 卡片内的分段(2026-11-07)。 * * · 单聊 → 1 段,用回合级 `merged`(整轮正文合并成一份) * · 群聊 → N 段,**每段用各自容器的 `merged`**,段首标容器名 * * 🔴 每段的消息对象必须**稳定复用**(未截断时返回原引用), * 否则流式每帧都会重建 `app-message-bubble` → 抖动。 */ protected readonly segments = computed(() => { const t = this.turn(); const shown = this.shownMap(); const out: { key: string; name: string; hue: string; message: Message | null; /** 没有可渲染消息时才可能显示占位;具体文案见 `waitLabels` */ waiting: boolean; streaming: boolean; }[] = []; for (const b of t.blocks) { /* * 🔴 每块渲染**自己的正文**(2026-11-10 改)。 * * 原来单聊(`isMulti=false`)让每个块都以回合级 `t.merged` 为源 —— * 那时单聊固定只有 1 个块,等价。 * 但拆轮后同一回合可能有多块:再用 `t.merged`,每块都会画一遍整段正文, * 界面上就是同一段话重复出现(实测)。改用 `b.merged` 后各画各的。 * * 单聊的「整轮一张卡、正文连成一份」观感不变 —— `t.merged` 仍用于 * 回合级的合并与流式推进,块只是把内容分段呈现。 */ const src = b.merged ?? (t.blocks.length === 1 ? t.merged : null); /* * 🔴 **已结束且什么都没有**的块不渲染占位(合并口径,2026-11-10): * 空占位只属于「还在等待 / 还在输出」。超时由全局提示告知, * 会话内不留一个转圈的「未响应」残留。 * * 2026-11-11 补充 `b.waiting`:`live` 在 25 秒静默后就会转 false, * 但用户仍在等(要到 180 秒才算超时)—— 这中间不能让界面一片空白。 */ if (!src && !b.live && !b.waiting) continue; let message: Message | null = null; let streaming = false; if (src) { /* * 🔴 不要再回退到 `src.text.length`(2026-11-08)。 * * 原来的 `shown[b.key] ?? src.text.length`:只要 key 对不上(例如 * targetsOf 与 segments 的 key 空间不一致、或首帧还没建立进度), * 就当成「已经显示完」直接渲染全文 —— 流式因此失效。 * 取不到进度时按 0 处理,让 rAF 一帧帧补上来。 */ /* * 🔴 已结束的块**直接全文渲染**,不参与流式截断(2026-11-09,修截断)。 * * 根因:`shown` 的键是**块 key**。容器一轮回复分多条事件到达, * `mergeConsecutiveBodies` 在合并时可能重建块 → key 变了 → * 新 key 在新 map 里是 `undefined` → 取 0 → `slice(0, 0)` **整段清空**。 * 实测正文长度从 574 掉到 565,用户看到「后面的内容被吃掉了」。 * * 流式的意义只在于「还在生成」;一旦 `!b.live`(本块已结束), * 内容已是最终态,再做截断只会丢内容,没有任何收益。 */ const n = b.live ? (shown[b.key] ?? 0) : src.text.length; /* * 🔴 差距过大时直接追赶,不做长距离「逐字补」(2026-11-09)。 * * 容器一轮会分 5+ 条事件到达,相邻两条之间可能隔十几秒; * 若此时 rAF 才从头追,用户会长时间只看到前半截, * 观感就是「后面的内容迟迟不出现」。超过一屏的量没必要逐字演。 */ const CATCH_UP_GAP = 400; const effective = src.text.length - n > CATCH_UP_GAP ? src.text.length : n; streaming = b.live && effective < src.text.length; message = streaming ? { ...src.message, text: src.text.slice(0, effective) } : src.message; } out.push({ key: b.key, name: b.agentName || b.agentId || '', hue: this.hueOf(b), message, waiting: !message, streaming, }); } return out; }); /** * 卡级占位文案。 * * 🔴 只在整个回合**一个可渲染的段都没有**时才用(例如刚开始思考)。 * 块级已有各自的「正在思考…」,两层同时出现就会显示成两条 * 「正在思考…」(2026-11-07 用户截图实测)。 */ /** * 等待 / 输出中的占位文案(按块 key 索引)。 * * 🔴 刻意**独立于 `segments`**(2026-11-09 评审修正): * 这里要读每秒变化的 `now`,若放进 `segments`,整个回合的气泡对象 * 每秒都会重建,与上面「消息对象必须稳定复用」的约束直接冲突。 * 拆开后 `segments` 只随消息变化,文案单独每秒刷新。 */ protected readonly waitLabels = computed>(() => { const now = this.now(); const out: Record = {}; for (const b of this.turn().blocks) { const hasOutput = b.body.length > 0 || b.steps.length > 0; const elapsed = b.waitingSince > 0 ? Math.max(0, now - b.waitingSince) : 0; /* 传 b.startAt:等待文案带「09:51 已送达」,用户能看出已经等了多久(2026-11-11) */ /* `live || waiting` 都算「还在进行」:静默期也不能让等待提示消失(2026-11-11) */ const text = waitText(elapsed, hasOutput, b.live || b.waiting, b.empty, b.startAt); if (text) out[b.key] = text; } return out; }); /** * 各段的「用时秒数」——**进行中逐秒递增**(2026-11-10 用户要求)。 * * 🔴 与 `waitLabels` 同一套时钟(每秒刷新的 `now`), * 但刻意与 `segments` 分开:`segments` 只在消息变化时重建, * 秒数每秒变一次,放一起会让整个气泡对象每秒重建。 */ protected readonly segTime = computed>(() => { const now = this.now(); const out: Record = {}; for (const b of this.turn().blocks) out[b.key] = elapsedSeconds(b, now); return out; }); /** 回合头部的用时:取本回合最长的那个块(群聊里各容器并行跑) */ protected readonly turnSeconds = computed(() => { const times = this.segTime(); return this.turn().blocks.reduce((max, b) => Math.max(max, times[b.key] ?? 0), 0); }); protected readonly thinking = computed(() => { const t = this.turn(); if (t.merged) return ''; // 只要还有任何块会渲染成段(有内容 / live / 仍在等),就交给段级提示 const anySeg = t.blocks.some((b) => b.merged || b.live || b.waiting || b.empty); if (anySeg) return ''; const b = t.blocks[0]; if (!b) return ''; // 与段级共用同一份文案表,避免两套判据 return this.waitLabels()[b.key] ?? ''; }); protected toggle(): void { this.collapse.toggle(this.turn().id, this.turn().live); } protected hueOf(b: TurnBlockView): string { const palette = ['info', 'success', 'warning', 'danger', 'primary']; const id = b.agentId ?? 'agent'; let sum = 0; for (let i = 0; i < id.length; i++) sum = (sum + id.charCodeAt(i)) % 9973; return palette[sum % palette.length]; } }