assistant-turn.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873
  1. import { ChangeDetectionStrategy, Component, OnDestroy, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
  2. import { FileCard, Message } from '../../../core/api/models';
  3. import { AssistantTurn, TurnBlockView, elapsedSeconds, waitText } from '../../../core/assistant-turn';
  4. import { TurnCollapseService } from '../../../core/turn-collapse.service';
  5. import { AssistantFilesComponent } from '../assistant-files/assistant-files';
  6. import { MessageBubbleComponent } from '../message-bubble/message-bubble';
  7. /**
  8. * 一个 AI 回合(2026-11-06 用户需求,2026-11-07 按单聊渲染重做)
  9. *
  10. * ## 用户要求
  11. *
  12. * 「所有输出汇聚到一个固定区域内,后续回复持续追加;
  13. * 顶部把 AI 的操作、思考融合进去,可点击折叠,输出时默认展开;
  14. * 下半部分是主要内容区,文档以宫格列表呈现;
  15. * **基于单聊的渲染逻辑**,并**模拟流式输出**。」
  16. *
  17. * ## 与单聊的关系(2026-11-07 修正)
  18. *
  19. * 正文**直接交给 `app-message-bubble`** —— 与单聊是同一个组件,
  20. * 排版、代码块、复制按钮、时间脚注天然一致。
  21. * 本组件只额外负责两件事:把过程行提到顶部、把文件收进宫格。
  22. *
  23. * ## 结构
  24. *
  25. * ```
  26. * ┌────────────────────────────────────────┐
  27. * │ ⌄ 运行了 3 步 · 💻 terminal · 4s 已完成 │ ← 顶部过程区
  28. * ├────────────────────────────────────────┤
  29. * │ 正文(app-message-bubble,与单聊同款) │
  30. * │ ┌──────┐ ┌──────┐ ┌──────┐ │ ← 文件宫格
  31. * │ │ a.md │ │ b.pdf│ │ c.csv│ │
  32. * │ └──────┘ └──────┘ └──────┘ │
  33. * └────────────────────────────────────────┘
  34. * ```
  35. *
  36. * ## 仿流式
  37. *
  38. * Matrix 不支持真流式,容器是「攒一段发一段」。这里做**打字机增量**:
  39. * 当最后一段正文的文本变长时,只把**新增的那一截**以极短间隔吐出来,
  40. * 视觉上接近逐字生成;文本整体替换(重新生成)时直接切换,不做动画。
  41. *
  42. * 关键约束:
  43. * · 不逐字重渲染 Markdown(会导致代码块闪烁、滚动跳动)
  44. * · 尊重 `prefers-reduced-motion`(直接显示完整文本)
  45. */
  46. @Component({
  47. selector: 'app-assistant-turn',
  48. changeDetection: ChangeDetectionStrategy.OnPush,
  49. imports: [AssistantFilesComponent, MessageBubbleComponent],
  50. template: `
  51. <section class="turn" [class.live]="turn().live" [attr.data-phase]="turn().phase">
  52. <!-- 顶部过程区:只有存在过程步骤时才出现,避免空头占位 -->
  53. @if (turn().stepCount > 0) {
  54. <button
  55. type="button"
  56. class="turn-head"
  57. [attr.aria-expanded]="expanded()"
  58. [attr.aria-controls]="'steps-' + turn().id"
  59. (click)="toggle()"
  60. >
  61. <i class="fa-solid fa-chevron-right chev" [class.open]="expanded()" aria-hidden="true"></i>
  62. <span class="head-text">{{ headText() }}</span>
  63. @if (turnSeconds() > 0) {
  64. <span class="head-time">{{ turnSeconds() }}s</span>
  65. }
  66. <span class="head-phase" [class.busy]="turn().live">{{ phaseText() }}</span>
  67. </button>
  68. }
  69. @if (expanded() && turn().stepCount > 0) {
  70. <ul class="step-list" [id]="'steps-' + turn().id">
  71. @for (s of allSteps(); track $index) {
  72. <li class="step" [attr.data-kind]="s.kind">
  73. <span class="dot" aria-hidden="true"></span>
  74. <span class="step-label">{{ s.label }}</span>
  75. @if (s.detail) {
  76. <span class="step-detail">{{ s.detail }}</span>
  77. }
  78. </li>
  79. }
  80. </ul>
  81. }
  82. <!--
  83. 一整轮 = 一张卡(2026-11-07 用户需求)。
  84. 过程在顶部统一折叠,文件在底部宫格。
  85. 🔴 群聊多容器**必须分块**(2026-11-07 用户反馈「@三个人都融合在一起了」):
  86. @ 了 A、B、C 就要看得出各自说了什么。此前把所有容器的正文拼成一份,
  87. 归属信息彻底丢失。现在每个容器一段,段首标名字。
  88. 单聊只有一块,等价于「一整轮一张卡」,不受影响。
  89. -->
  90. <article class="card">
  91. @if (thinking()) {
  92. <p class="card-wait">
  93. <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
  94. {{ thinking() }}
  95. </p>
  96. }
  97. @for (seg of segments(); track seg.key) {
  98. <section class="seg" [class.seg--multi]="multi()">
  99. @if (multi()) {
  100. <header class="seg-head">
  101. <span class="seg-dot" [style.background]="'var(--' + seg.hue + ')'" aria-hidden="true"></span>
  102. <span class="seg-name">{{ seg.name }}</span>
  103. @if (segTime()[seg.key] > 0) {
  104. <span class="seg-time">{{ segTime()[seg.key] }}s</span>
  105. }
  106. </header>
  107. }
  108. @if (seg.message; as msg) {
  109. <app-message-bubble
  110. [message]="msg"
  111. [hideProcess]="true"
  112. [showFile]="false"
  113. (openTask)="openTask.emit($event)"
  114. (preview)="preview.emit($event)"
  115. />
  116. <!-- 本段还在吐字:末尾跟一个闪烁圆点,表示没写完 -->
  117. @if (seg.streaming) {
  118. <span class="caret" aria-hidden="true"></span>
  119. }
  120. } @else if (seg.waiting) {
  121. <p class="seg-wait">
  122. <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
  123. {{ waitLabels()[seg.key] }}
  124. </p>
  125. }
  126. </section>
  127. }
  128. @if (turn().files.length > 0) {
  129. <app-assistant-files [items]="turn().files" (preview)="preview.emit($event)" />
  130. }
  131. </article>
  132. </section>
  133. `,
  134. styles: `
  135. @use '../../../core/styles/breakpoints' as bp;
  136. :host {
  137. display: block;
  138. }
  139. .turn {
  140. display: flex;
  141. flex-direction: column;
  142. gap: var(--sp-2);
  143. }
  144. /* ---------- 顶部过程区 ---------- */
  145. .turn-head {
  146. display: flex;
  147. align-items: center;
  148. gap: var(--sp-2);
  149. align-self: flex-start;
  150. max-width: 100%;
  151. min-height: 32px;
  152. padding: var(--sp-1) var(--sp-3);
  153. border: 1px solid var(--edge);
  154. border-radius: var(--r-tag);
  155. background: var(--surface-2);
  156. color: var(--muted);
  157. font-size: var(--fs-12);
  158. cursor: pointer;
  159. transition: background-color var(--motion-fast) var(--ease-standard);
  160. }
  161. .turn-head:hover {
  162. background: var(--surface-hover);
  163. }
  164. .chev {
  165. flex: none;
  166. font-size: var(--fs-11);
  167. transition: transform var(--motion-fast) var(--ease-standard);
  168. }
  169. .chev.open {
  170. transform: rotate(90deg);
  171. }
  172. .head-text {
  173. overflow: hidden;
  174. text-overflow: ellipsis;
  175. white-space: nowrap;
  176. min-width: 0;
  177. }
  178. .head-time,
  179. .head-phase {
  180. flex: none;
  181. color: var(--faint);
  182. }
  183. .head-phase.busy {
  184. color: var(--primary);
  185. }
  186. .step-list {
  187. margin: 0;
  188. padding: 0 0 0 var(--sp-3);
  189. list-style: none;
  190. display: flex;
  191. flex-direction: column;
  192. gap: 2px;
  193. }
  194. .step {
  195. display: flex;
  196. align-items: baseline;
  197. gap: var(--sp-2);
  198. min-width: 0;
  199. font-size: var(--fs-11);
  200. color: var(--muted);
  201. }
  202. .step[data-kind='status'] .step-label {
  203. color: var(--info);
  204. }
  205. .dot {
  206. flex: none;
  207. width: 4px;
  208. height: 4px;
  209. border-radius: var(--r-tag);
  210. background: var(--edge-strong);
  211. transform: translateY(-2px);
  212. }
  213. .step-label {
  214. flex: none;
  215. font-weight: 600;
  216. }
  217. .step-detail {
  218. overflow: hidden;
  219. text-overflow: ellipsis;
  220. white-space: nowrap;
  221. font-family: var(--font-mono, ui-monospace, monospace);
  222. }
  223. /* ---------- 卡片 ---------- */
  224. .card {
  225. display: flex;
  226. flex-direction: column;
  227. gap: var(--sp-2);
  228. min-width: 0;
  229. }
  230. .card-members {
  231. display: flex;
  232. align-items: center;
  233. gap: var(--sp-2);
  234. font-size: var(--fs-12);
  235. color: var(--muted);
  236. }
  237. .member-dot {
  238. width: 6px;
  239. height: 6px;
  240. border-radius: var(--r-tag);
  241. flex: none;
  242. }
  243. .card-wait {
  244. display: flex;
  245. align-items: center;
  246. gap: var(--sp-2);
  247. margin: 0;
  248. font-size: var(--fs-13);
  249. color: var(--muted);
  250. }
  251. /* ---------- 卡内分段(群聊多容器时每段一个容器) ---------- */
  252. .seg {
  253. display: flex;
  254. flex-direction: column;
  255. gap: var(--sp-2);
  256. min-width: 0;
  257. }
  258. /* 多段之间用留白 + 细分隔线,一眼看出「换人了」 */
  259. .seg + .seg {
  260. margin-top: var(--sp-4);
  261. padding-top: var(--sp-3);
  262. border-top: 1px solid var(--edge);
  263. }
  264. .seg-head {
  265. display: flex;
  266. align-items: center;
  267. gap: var(--sp-2);
  268. font-size: var(--fs-12);
  269. color: var(--muted);
  270. }
  271. .seg-dot {
  272. width: 6px;
  273. height: 6px;
  274. border-radius: var(--r-tag);
  275. flex: none;
  276. }
  277. .seg-name {
  278. overflow: hidden;
  279. text-overflow: ellipsis;
  280. white-space: nowrap;
  281. font-weight: 600;
  282. color: var(--ink-2);
  283. }
  284. .seg-time {
  285. margin-left: auto;
  286. flex: none;
  287. color: var(--faint);
  288. }
  289. /*
  290. * 思考 / 输出中状态(2026-11-07 用户反馈「不明显」):
  291. * 原来只是一行灰色小字,混在正文里几乎看不见。
  292. * 改成带底色与呼吸动画的胶囊,一眼能看出「还在跑」。
  293. */
  294. .seg-wait {
  295. display: inline-flex;
  296. align-items: center;
  297. gap: var(--sp-2);
  298. align-self: flex-start;
  299. margin: 0;
  300. min-height: 28px;
  301. padding: var(--sp-1) var(--sp-3);
  302. border-radius: var(--r-tag);
  303. background: var(--primary-subtle);
  304. color: var(--primary);
  305. font-size: var(--fs-12);
  306. animation: wait-breathe 1.6s var(--ease-standard) infinite;
  307. }
  308. .seg-wait i {
  309. font-size: var(--fs-11);
  310. }
  311. @keyframes wait-breathe {
  312. 0%,
  313. 100% {
  314. opacity: 1;
  315. }
  316. 50% {
  317. opacity: 0.6;
  318. }
  319. }
  320. /* ---------- 输出中的闪烁光标 ---------- */
  321. .caret {
  322. display: inline-block;
  323. width: 7px;
  324. height: 7px;
  325. margin-left: 2px;
  326. border-radius: 50%;
  327. background: var(--ink);
  328. vertical-align: middle;
  329. animation: caret-blink 1s steps(2, start) infinite;
  330. }
  331. @keyframes caret-blink {
  332. 0%,
  333. 100% {
  334. opacity: 1;
  335. }
  336. 50% {
  337. opacity: 0.15;
  338. }
  339. }
  340. @include bp.below-mobile {
  341. /* 移动端:触达区 ≥44px、正文字号 ≥16px(iOS 不缩放) */
  342. .turn-head {
  343. min-height: var(--touch-min);
  344. padding: var(--sp-2) var(--sp-3);
  345. font-size: var(--fs-17);
  346. }
  347. .chev {
  348. font-size: var(--fs-15);
  349. }
  350. .head-time,
  351. .head-phase {
  352. font-size: var(--fs-13);
  353. }
  354. .step {
  355. min-height: 28px;
  356. font-size: var(--fs-13);
  357. }
  358. .img {
  359. max-width: 100%;
  360. }
  361. .seg-head {
  362. font-size: var(--fs-13);
  363. }
  364. .seg-wait,
  365. .card-wait {
  366. font-size: var(--fs-17);
  367. }
  368. /* 分段之间留更大间隔,移动端更容易区分「换人了」 */
  369. .seg + .seg {
  370. margin-top: var(--sp-6);
  371. padding-top: var(--sp-4);
  372. }
  373. .caret {
  374. width: 8px;
  375. height: 8px;
  376. }
  377. }
  378. @media (prefers-reduced-motion: reduce) {
  379. .chev {
  380. transition: none;
  381. }
  382. /* 减少动效:光标不闪,用低透明度静态圆点表示「还在写」 */
  383. .caret {
  384. animation: none;
  385. opacity: 0.5;
  386. }
  387. /* 呼吸动画同样关闭,保留静态胶囊(颜色已足够区分状态) */
  388. .seg-wait {
  389. animation: none;
  390. }
  391. }
  392. `,
  393. })
  394. export class AssistantTurnComponent implements OnDestroy {
  395. private readonly collapse = inject(TurnCollapseService);
  396. readonly turn = input.required<AssistantTurn>();
  397. /** 群聊为 true:多块时显示成员名 */
  398. readonly grouped = input(false);
  399. readonly preview = output<FileCard>();
  400. readonly openTask = output<string>();
  401. protected readonly showBlockHead = computed(() => this.grouped());
  402. protected readonly expanded = computed(() =>
  403. this.collapse.isExpanded(this.turn().id, this.turn().live),
  404. );
  405. protected readonly allSteps = computed(() => this.turn().blocks.flatMap((b) => b.steps));
  406. protected readonly headText = computed(() => {
  407. const n = this.turn().stepCount;
  408. const first = this.allSteps()[0]?.label ?? '';
  409. const label = n === 1 ? '1 步' : `${n} 步`;
  410. return first ? `运行了 ${label} · ${first}` : `运行了 ${label}`;
  411. });
  412. protected readonly phaseText = computed(() => {
  413. switch (this.turn().phase) {
  414. case 'thinking':
  415. return '思考中';
  416. case 'replying':
  417. return '输出中';
  418. case 'done':
  419. return '已完成';
  420. case 'progressOnly':
  421. /*
  422. * 容器干了一轮活,但没产出可交付内容(2026-11-10 新增)。
  423. *
  424. * 2026-11-11 改文案:「无交付」听起来像失败,实际是「做了事,
  425. * 但没有可下载的产物」——比如只跑了工具、或只回了过程日志。
  426. * 配合 `live` 的长任务宽限(60 秒),这一档只在真的停住时才出现。
  427. */
  428. return '已结束 · 仅过程记录';
  429. case 'failed':
  430. return '失败';
  431. default:
  432. return '';
  433. }
  434. });
  435. /* ---------------- 仿流式 ---------------- */
  436. /**
  437. * 实时时钟(每秒跳一次,仅在 live 时运行)。
  438. *
  439. * 等待文案里的秒数由它驱动,避免「13s」停在原地不动。
  440. */
  441. protected readonly now = signal(Date.now());
  442. /**
  443. * **每个块**已「吐出」的文本长度(2026-11-07 群聊流式修复)。
  444. *
  445. * ## 为什么按块而不是整轮一个数字
  446. *
  447. * 群聊里会被 @ 的多个容器**并行输出**,各自进度完全不同。
  448. * 此前整轮只维护一个 `shown`,取的是回合级合并文本的长度,
  449. * 于是:
  450. * · A 块吐到 100 字、B 块才刚到 20 字时,B 会被按 A 的进度截断
  451. * · 三段内容长度对不上,表现为「有的不动、有的乱跳」
  452. * 改成 `Map<blockKey, shownLen>` 后,每块独立推进、互不干扰。
  453. */
  454. private readonly shownMap = signal<Record<string, number>>({});
  455. private raf: number | null = null;
  456. /** 上一次推进的时间戳,用于按真实帧间隔算步长 */
  457. private lastTick = 0;
  458. private readonly reduceMotion =
  459. typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
  460. constructor() {
  461. effect(() => {
  462. const t = this.turn();
  463. const targets = this.targetsOf(t);
  464. const settled = this.settledKeys(t);
  465. untracked(() => this.advance(targets, settled));
  466. });
  467. /*
  468. * 等待计时(2026-11-09)。
  469. *
  470. * 🔴 「已送达,等待回复…(13s)」里的秒数要自己走 —— 回合数据只在
  471. * 上一条消息变化时重算,静默等待期间没人推进它,秒数会冻住。
  472. * 只在 live 时起表,结束即清,不常驻。
  473. */
  474. effect(() => {
  475. /*
  476. * 2026-11-11:静默期(`live` 已转 false 但仍在等回复)也要走表 ——
  477. * 否则「已送达,等待回复…(Ns)」的秒数会冻住,看起来像卡死。
  478. */
  479. const t = this.turn();
  480. if (!t.live && !t.blocks.some((b) => b.waiting)) return;
  481. const id = setInterval(() => this.now.set(Date.now()), 1000);
  482. return () => clearInterval(id);
  483. });
  484. }
  485. ngOnDestroy(): void {
  486. this.cancel();
  487. }
  488. private cancel(): void {
  489. if (this.raf !== null && typeof cancelAnimationFrame !== 'undefined') {
  490. cancelAnimationFrame(this.raf);
  491. }
  492. this.raf = null;
  493. }
  494. /**
  495. * 各块的**目标**长度。
  496. *
  497. * 每块以自己的正文为准(2026-11-10 拆轮后单聊也可能有多块);
  498. * 单块时等价于回合级合并文本。
  499. */
  500. private targetsOf(t: AssistantTurn): Record<string, number> {
  501. const out: Record<string, number> = {};
  502. for (const b of t.blocks) {
  503. out[b.key] = (b.merged ?? (t.blocks.length === 1 ? t.merged : null))?.text.length ?? 0;
  504. }
  505. return out;
  506. }
  507. /**
  508. * 已结束的块 key 集合(2026-11-11 用户反馈)。
  509. *
  510. * 用户原话:「打开其他的对话记录的时候,文字还会有动画效果,这个是不对的。
  511. * 只有我们在输出的时候才会有动画;查看会话历史记录时应该直接显示。」
  512. *
  513. * ## 根因
  514. *
  515. * 组件是新建的,`shownMap` 恒为空 → `advance()` 把所有块都当「新出现的块」
  516. * 从 0 开始推 → **历史会话打开时整篇逐字重放**。
  517. *
  518. * ## 修法
  519. *
  520. * 推进时,**不属于 `live` 的块直接给满格**,只有 live 块才从 0 演。
  521. * 这样:
  522. * · 打开历史会话 → 所有块都已结束 → 一次到位,无动画
  523. * · 发新消息 → 新块 live → 正常逐段生成
  524. */
  525. private settledKeys(t: AssistantTurn): Set<string> {
  526. return new Set(t.blocks.filter((b) => !b.live).map((b) => b.key));
  527. }
  528. /**
  529. * 推进各块的显示长度。
  530. *
  531. * ## 为什么用 rAF 而不是 setInterval(2026-11-07 用户反馈「卡顿」)
  532. *
  533. * 每次更新显示长度都会让气泡的 `message` 输入变成新对象 →
  534. * 整个 Markdown 重新解析渲染。Markdown 解析本身很重,固定 24ms
  535. * (≈42fps)地去触发,浏览器根本追不上,表现就是一顿一顿的。
  536. *
  537. * 改为**跟随刷新率**推进,并按「剩余量」自适应步长:剩余多就多吐,
  538. * 剩余少就收敛到逐字。帧率天然对齐 60fps,不会出现定时器与渲染
  539. * 帧错位造成的抖动。
  540. *
  541. * ## 何时不做动画
  542. *
  543. * · 系统开启「减少动态效果」
  544. * · 不是实时输出(历史消息、刷新后回放)→ 一次到位
  545. * · 内容变短(重新生成)→ 直接跳转,不倒退
  546. */
  547. private advance(targets: Record<string, number>, settled: Set<string> = new Set()): void {
  548. this.cancel();
  549. const live = this.turn().live;
  550. const cur = this.shownMap();
  551. /*
  552. * 🔴 回合一结束 → **所有块**一次到位,不逐字重放(2026-11-11)。
  553. *
  554. * 用户反馈:「打开别的对话记录时文字还在动画,只有输出时才该有动画。」
  555. * 除了整回合判 live,下面还有一层按块的兜底(`settled`),
  556. * 两者叠加保证:历史会话里任何已结束的块都不会演动画。
  557. */
  558. if (this.reduceMotion || !live) {
  559. this.shownMap.set({ ...targets });
  560. return;
  561. }
  562. /*
  563. * 块级兜底:即使回合整体仍 live(比如同回合里另一块还在跑),
  564. * **已经结束的块**也直接满格 —— 不能因为旁边在动就把历史内容重放一遍。
  565. */
  566. const preSettled: Record<string, number> = {};
  567. for (const k of settled) {
  568. if (targets[k] !== undefined) preSettled[k] = targets[k];
  569. }
  570. /*
  571. * 🔴 实时回合的**首次**求值:从 0 开始逐字推进(2026-11-08 修复)。
  572. *
  573. * 原来的写法是「首次出现 → 直接设为目标长度」,初衷是历史不该重放,
  574. * 但它分不清「历史回放」和「刚到达的新消息」——
  575. * 组件是新建的,`shownMap` 恒为空,于是**每一条实时消息都被整段直出**,
  576. * 流式效果从未生效(用户实测反馈)。
  577. *
  578. * 现在:非 live(历史)上面已经直接到位;走到这里的都是 live,
  579. * 于是从 0 开始推进,让用户看到逐段生成。
  580. */
  581. if (Object.keys(cur).length === 0) {
  582. const zero: Record<string, number> = {};
  583. for (const k of Object.keys(targets)) zero[k] = 0;
  584. /* 已结束的块从满格起,只有 live 块从 0 开始演 */
  585. Object.assign(zero, preSettled);
  586. this.shownMap.set(zero);
  587. }
  588. const base = this.shownMap();
  589. /*
  590. * 已结束的块无论之前推到哪,一律对齐到目标长度(不残留半截)。
  591. * 放在 base 之后、reset 之前,保证下面的「变短不收缩」不会把它们截回去。
  592. */
  593. Object.assign(base, preSettled);
  594. /*
  595. * 目标变化:增长要动画;变短**不收缩**(2026-11-08)。
  596. *
  597. * 「变短」通常意味着数据层暂时丢了条目(Matrix 重建 / 分页),
  598. * 而不是用户想看重新生成。收缩会让内容在眼前消失 ——
  599. * 正是「后续内容覆盖了之前的输出」的观感来源。
  600. * 宁可暂时多显示,等下一轮数据回来再对齐。
  601. */
  602. const reset: Record<string, number> = { ...base };
  603. /** 是否有块「还没显示完」(不是「目标变了」) */
  604. let needAdvance = false;
  605. for (const [k, target] of Object.entries(targets)) {
  606. const now = reset[k];
  607. if (now === undefined) {
  608. // 新出现的块:从 0 开始(同样要流式,不直出)
  609. reset[k] = 0;
  610. if (target > 0) needAdvance = true;
  611. continue;
  612. }
  613. /*
  614. * 🔴 判「是否需要继续推进」,而不是「目标是否变化」(2026-11-09 修复)。
  615. *
  616. * 原实现用 `target > now` 判断是否启动 rAF —— 那意味着:
  617. * · 目标没变但**还没追上**(上一帧被中断 / 组件重建)→ 直接 return,
  618. * `shown` 永远停在半截;
  619. * · 渲染时 `streaming = live && n < length` 又永远走 `slice(0, n)`,
  620. * 于是界面**只显示到 n 为止**,后面的内容看起来像「被覆盖掉了」。
  621. *
  622. * 现在:只要还没追上目标就继续推进。变短仍保持不收缩。
  623. */
  624. if (target > now) needAdvance = true;
  625. // 注意:target < now 时**保持 now**,不收缩
  626. }
  627. this.shownMap.set(reset);
  628. if (!needAdvance) return;
  629. this.lastTick = 0;
  630. const tick = (now: number): void => {
  631. const dt = this.lastTick ? Math.min(now - this.lastTick, 100) : 16;
  632. this.lastTick = now;
  633. const prev = this.shownMap();
  634. const next: Record<string, number> = { ...prev };
  635. let remaining = false;
  636. for (const [k, target] of Object.entries(targets)) {
  637. const cur = prev[k] ?? 0;
  638. if (cur >= target) continue;
  639. remaining = true;
  640. /*
  641. * 自适应步长:约 1.2 秒追上当前剩余量,每帧至少 1 字。
  642. * dt=16ms、remain=120 字 → 每帧约 1.6 字
  643. */
  644. const step = Math.max(1, Math.ceil(((target - cur) / 1200) * dt));
  645. next[k] = Math.min(cur + step, target);
  646. }
  647. if (remaining) {
  648. this.shownMap.set(next);
  649. this.raf = requestAnimationFrame(tick);
  650. } else {
  651. this.raf = null;
  652. }
  653. };
  654. this.raf = requestAnimationFrame(tick);
  655. }
  656. /** 群聊多容器时,每块单独一段;单聊整体一段 */
  657. protected readonly multi = computed(() => this.grouped() && this.turn().blocks.length > 1);
  658. /**
  659. * 卡片内的分段(2026-11-07)。
  660. *
  661. * · 单聊 → 1 段,用回合级 `merged`(整轮正文合并成一份)
  662. * · 群聊 → N 段,**每段用各自容器的 `merged`**,段首标容器名
  663. *
  664. * 🔴 每段的消息对象必须**稳定复用**(未截断时返回原引用),
  665. * 否则流式每帧都会重建 `app-message-bubble` → 抖动。
  666. */
  667. protected readonly segments = computed(() => {
  668. const t = this.turn();
  669. const shown = this.shownMap();
  670. const out: {
  671. key: string;
  672. name: string;
  673. hue: string;
  674. message: Message | null;
  675. /** 没有可渲染消息时才可能显示占位;具体文案见 `waitLabels` */
  676. waiting: boolean;
  677. streaming: boolean;
  678. }[] = [];
  679. for (const b of t.blocks) {
  680. /*
  681. * 🔴 每块渲染**自己的正文**(2026-11-10 改)。
  682. *
  683. * 原来单聊(`isMulti=false`)让每个块都以回合级 `t.merged` 为源 ——
  684. * 那时单聊固定只有 1 个块,等价。
  685. * 但拆轮后同一回合可能有多块:再用 `t.merged`,每块都会画一遍整段正文,
  686. * 界面上就是同一段话重复出现(实测)。改用 `b.merged` 后各画各的。
  687. *
  688. * 单聊的「整轮一张卡、正文连成一份」观感不变 —— `t.merged` 仍用于
  689. * 回合级的合并与流式推进,块只是把内容分段呈现。
  690. */
  691. const src = b.merged ?? (t.blocks.length === 1 ? t.merged : null);
  692. /*
  693. * 🔴 **已结束且什么都没有**的块不渲染占位(合并口径,2026-11-10):
  694. * 空占位只属于「还在等待 / 还在输出」。超时由全局提示告知,
  695. * 会话内不留一个转圈的「未响应」残留。
  696. *
  697. * 2026-11-11 补充 `b.waiting`:`live` 在 25 秒静默后就会转 false,
  698. * 但用户仍在等(要到 180 秒才算超时)—— 这中间不能让界面一片空白。
  699. */
  700. if (!src && !b.live && !b.waiting) continue;
  701. let message: Message | null = null;
  702. let streaming = false;
  703. if (src) {
  704. /*
  705. * 🔴 不要再回退到 `src.text.length`(2026-11-08)。
  706. *
  707. * 原来的 `shown[b.key] ?? src.text.length`:只要 key 对不上(例如
  708. * targetsOf 与 segments 的 key 空间不一致、或首帧还没建立进度),
  709. * 就当成「已经显示完」直接渲染全文 —— 流式因此失效。
  710. * 取不到进度时按 0 处理,让 rAF 一帧帧补上来。
  711. */
  712. /*
  713. * 🔴 已结束的块**直接全文渲染**,不参与流式截断(2026-11-09,修截断)。
  714. *
  715. * 根因:`shown` 的键是**块 key**。容器一轮回复分多条事件到达,
  716. * `mergeConsecutiveBodies` 在合并时可能重建块 → key 变了 →
  717. * 新 key 在新 map 里是 `undefined` → 取 0 → `slice(0, 0)` **整段清空**。
  718. * 实测正文长度从 574 掉到 565,用户看到「后面的内容被吃掉了」。
  719. *
  720. * 流式的意义只在于「还在生成」;一旦 `!b.live`(本块已结束),
  721. * 内容已是最终态,再做截断只会丢内容,没有任何收益。
  722. */
  723. const n = b.live ? (shown[b.key] ?? 0) : src.text.length;
  724. /*
  725. * 🔴 差距过大时直接追赶,不做长距离「逐字补」(2026-11-09)。
  726. *
  727. * 容器一轮会分 5+ 条事件到达,相邻两条之间可能隔十几秒;
  728. * 若此时 rAF 才从头追,用户会长时间只看到前半截,
  729. * 观感就是「后面的内容迟迟不出现」。超过一屏的量没必要逐字演。
  730. */
  731. const CATCH_UP_GAP = 400;
  732. const effective = src.text.length - n > CATCH_UP_GAP ? src.text.length : n;
  733. streaming = b.live && effective < src.text.length;
  734. message = streaming
  735. ? { ...src.message, text: src.text.slice(0, effective) }
  736. : src.message;
  737. }
  738. out.push({
  739. key: b.key,
  740. name: b.agentName || b.agentId || '',
  741. hue: this.hueOf(b),
  742. message,
  743. waiting: !message,
  744. streaming,
  745. });
  746. }
  747. return out;
  748. });
  749. /**
  750. * 卡级占位文案。
  751. *
  752. * 🔴 只在整个回合**一个可渲染的段都没有**时才用(例如刚开始思考)。
  753. * 块级已有各自的「正在思考…」,两层同时出现就会显示成两条
  754. * 「正在思考…」(2026-11-07 用户截图实测)。
  755. */
  756. /**
  757. * 等待 / 输出中的占位文案(按块 key 索引)。
  758. *
  759. * 🔴 刻意**独立于 `segments`**(2026-11-09 评审修正):
  760. * 这里要读每秒变化的 `now`,若放进 `segments`,整个回合的气泡对象
  761. * 每秒都会重建,与上面「消息对象必须稳定复用」的约束直接冲突。
  762. * 拆开后 `segments` 只随消息变化,文案单独每秒刷新。
  763. */
  764. protected readonly waitLabels = computed<Record<string, string>>(() => {
  765. const now = this.now();
  766. const out: Record<string, string> = {};
  767. for (const b of this.turn().blocks) {
  768. const hasOutput = b.body.length > 0 || b.steps.length > 0;
  769. const elapsed = b.waitingSince > 0 ? Math.max(0, now - b.waitingSince) : 0;
  770. /* 传 b.startAt:等待文案带「09:51 已送达」,用户能看出已经等了多久(2026-11-11) */
  771. /* `live || waiting` 都算「还在进行」:静默期也不能让等待提示消失(2026-11-11) */
  772. const text = waitText(elapsed, hasOutput, b.live || b.waiting, b.empty, b.startAt);
  773. if (text) out[b.key] = text;
  774. }
  775. return out;
  776. });
  777. /**
  778. * 各段的「用时秒数」——**进行中逐秒递增**(2026-11-10 用户要求)。
  779. *
  780. * 🔴 与 `waitLabels` 同一套时钟(每秒刷新的 `now`),
  781. * 但刻意与 `segments` 分开:`segments` 只在消息变化时重建,
  782. * 秒数每秒变一次,放一起会让整个气泡对象每秒重建。
  783. */
  784. protected readonly segTime = computed<Record<string, number>>(() => {
  785. const now = this.now();
  786. const out: Record<string, number> = {};
  787. for (const b of this.turn().blocks) out[b.key] = elapsedSeconds(b, now);
  788. return out;
  789. });
  790. /** 回合头部的用时:取本回合最长的那个块(群聊里各容器并行跑) */
  791. protected readonly turnSeconds = computed<number>(() => {
  792. const times = this.segTime();
  793. return this.turn().blocks.reduce((max, b) => Math.max(max, times[b.key] ?? 0), 0);
  794. });
  795. protected readonly thinking = computed(() => {
  796. const t = this.turn();
  797. if (t.merged) return '';
  798. // 只要还有任何块会渲染成段(有内容 / live / 仍在等),就交给段级提示
  799. const anySeg = t.blocks.some((b) => b.merged || b.live || b.waiting || b.empty);
  800. if (anySeg) return '';
  801. const b = t.blocks[0];
  802. if (!b) return '';
  803. // 与段级共用同一份文案表,避免两套判据
  804. return this.waitLabels()[b.key] ?? '';
  805. });
  806. protected toggle(): void {
  807. this.collapse.toggle(this.turn().id, this.turn().live);
  808. }
  809. protected hueOf(b: TurnBlockView): string {
  810. const palette = ['info', 'success', 'warning', 'danger', 'primary'];
  811. const id = b.agentId ?? 'agent';
  812. let sum = 0;
  813. for (let i = 0; i < id.length; i++) sum = (sum + id.charCodeAt(i)) % 9973;
  814. return palette[sum % palette.length];
  815. }
  816. }