| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873 |
- 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: `
- <section class="turn" [class.live]="turn().live" [attr.data-phase]="turn().phase">
- <!-- 顶部过程区:只有存在过程步骤时才出现,避免空头占位 -->
- @if (turn().stepCount > 0) {
- <button
- type="button"
- class="turn-head"
- [attr.aria-expanded]="expanded()"
- [attr.aria-controls]="'steps-' + turn().id"
- (click)="toggle()"
- >
- <i class="fa-solid fa-chevron-right chev" [class.open]="expanded()" aria-hidden="true"></i>
- <span class="head-text">{{ headText() }}</span>
- @if (turnSeconds() > 0) {
- <span class="head-time">{{ turnSeconds() }}s</span>
- }
- <span class="head-phase" [class.busy]="turn().live">{{ phaseText() }}</span>
- </button>
- }
- @if (expanded() && turn().stepCount > 0) {
- <ul class="step-list" [id]="'steps-' + turn().id">
- @for (s of allSteps(); track $index) {
- <li class="step" [attr.data-kind]="s.kind">
- <span class="dot" aria-hidden="true"></span>
- <span class="step-label">{{ s.label }}</span>
- @if (s.detail) {
- <span class="step-detail">{{ s.detail }}</span>
- }
- </li>
- }
- </ul>
- }
- <!--
- 一整轮 = 一张卡(2026-11-07 用户需求)。
- 过程在顶部统一折叠,文件在底部宫格。
- 🔴 群聊多容器**必须分块**(2026-11-07 用户反馈「@三个人都融合在一起了」):
- @ 了 A、B、C 就要看得出各自说了什么。此前把所有容器的正文拼成一份,
- 归属信息彻底丢失。现在每个容器一段,段首标名字。
- 单聊只有一块,等价于「一整轮一张卡」,不受影响。
- -->
- <article class="card">
- @if (thinking()) {
- <p class="card-wait">
- <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
- {{ thinking() }}
- </p>
- }
- @for (seg of segments(); track seg.key) {
- <section class="seg" [class.seg--multi]="multi()">
- @if (multi()) {
- <header class="seg-head">
- <span class="seg-dot" [style.background]="'var(--' + seg.hue + ')'" aria-hidden="true"></span>
- <span class="seg-name">{{ seg.name }}</span>
- @if (segTime()[seg.key] > 0) {
- <span class="seg-time">{{ segTime()[seg.key] }}s</span>
- }
- </header>
- }
- @if (seg.message; as msg) {
- <app-message-bubble
- [message]="msg"
- [hideProcess]="true"
- [showFile]="false"
- (openTask)="openTask.emit($event)"
- (preview)="preview.emit($event)"
- />
- <!-- 本段还在吐字:末尾跟一个闪烁圆点,表示没写完 -->
- @if (seg.streaming) {
- <span class="caret" aria-hidden="true"></span>
- }
- } @else if (seg.waiting) {
- <p class="seg-wait">
- <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
- {{ waitLabels()[seg.key] }}
- </p>
- }
- </section>
- }
- @if (turn().files.length > 0) {
- <app-assistant-files [items]="turn().files" (preview)="preview.emit($event)" />
- }
- </article>
- </section>
- `,
- 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<AssistantTurn>();
- /** 群聊为 true:多块时显示成员名 */
- readonly grouped = input(false);
- readonly preview = output<FileCard>();
- readonly openTask = output<string>();
- 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<blockKey, shownLen>` 后,每块独立推进、互不干扰。
- */
- private readonly shownMap = signal<Record<string, number>>({});
- 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<string, number> {
- const out: Record<string, number> = {};
- 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<string> {
- 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<string, number>, settled: Set<string> = 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<string, number> = {};
- 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<string, number> = {};
- 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<string, number> = { ...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<string, number> = { ...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<Record<string, string>>(() => {
- const now = this.now();
- const out: Record<string, string> = {};
- 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<Record<string, number>>(() => {
- const now = this.now();
- const out: Record<string, number> = {};
- for (const b of this.turn().blocks) out[b.key] = elapsedSeconds(b, now);
- return out;
- });
- /** 回合头部的用时:取本回合最长的那个块(群聊里各容器并行跑) */
- protected readonly turnSeconds = computed<number>(() => {
- 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];
- }
- }
|