streamed markdown playback:完成事件不再抢跑展示动画

buffin-ai/buffin · 14dcbfe...58aca59 · 2026-07-17 · 自包含,读完即弃

1 commit
5 文件
+344 / −88
36% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

这次修复把流式 Markdown 的事实到达速度屏幕播放速度拆开。Incremark 不再同时负责解析与打字节拍;新的 usePlaybackBuffer 持有展示游标,以固定 cadence 释放权威文本。

message.completed 只封口输入。只要当前文本曾经处于 revealing,缓冲区就继续 draining,直到展示游标追上完整文本,adapter 才调用 finalize()

视觉层只给本帧最新释放的 AST 尾部添加一个 fade chunk,避免多个半透明 suffix 同时留在 DOM 里形成拖影;历史回放与 reduced-motion 路径仍然静态显示。

2一条文本的播放旅程

从 provider 文本抵达 renderer 开始,顺着展示游标、Incremark parser 和 fade span 走一遍,就能看到这次改动真正移动的职责边界。

全景 · 涉及 3 个运行时文件
权威 text / finished 展示游标
use-playback-buffer.ts
增量解析
incremark-adapter.tsx
单一淡入尾部
index.css

2.1播放缓冲区接管展示时钟

以前 adapter 把权威文本立即 append 给 Incremark,再用 Incremark typewriter 控制显示。完成态一旦触发 static 或 finished,调用方会发出 skip,尚未播放的尾部直接跳到最终文本。

以前
权威文本直接 append 到 Incremark
Incremark typewriter 持有播放进度
static / finished 触发 skip
现在
权威文本只更新 target
PlaybackBuffer 持有展示游标
adapter 只 append 本帧新释放的 suffix

缓冲区的参数共同定义感知速度:首帧先放 2 个字符,此后每 72ms 前进一步。积压越长,每步越大,但被限制在 2 到 12 个字符之间。

apps/desktop/src/renderer/features/session/hooks/use-playback-buffer.ts节拍与自适应步长
const TICK_MS = 72
const INITIAL_CHARS = 2
const MIN_CHARS_PER_TICK = 2
const MAX_CHARS_PER_TICK = 12
const TARGET_DRAIN_TICKS = 16

function nextRevealLength(current: number, target: number): number {
  const remaining = target - current
  const step = Math.min(
    MAX_CHARS_PER_TICK,
    Math.max(MIN_CHARS_PER_TICK, Math.ceil(remaining / TARGET_DRAIN_TICKS)),
  )
  return Math.min(target, current + step)
}

finishedplayback 不再被压成一个布尔开关。完成事件只在“此前确实正在 revealing”时开启内部 draining;因此 live part 会继续前进,静态历史则从一开始就完整显示。

apps/desktop/src/renderer/features/session/hooks/use-playback-buffer.ts完成事件只开启 drain
function beginsDrain(
  finished: boolean,
  previousFinished: boolean,
  previousPlayback: ThreadPlaybackMode,
): boolean {
  return finished && !previousFinished && previousPlayback === 'revealing'
}

if (beganDrain) drainingRef.current = true
const reveal = shouldReveal(playback, drainingRef.current)

if (!reveal) {
  displayedLengthRef.current = text.length
  setFrame((current) => settledFrame(current, text))
}

非前缀 replacement 走另一条路径:它代表权威内容改写,而不是 suffix 到达。此时缓冲区立即停表并落到完整文本,避免把旧文本的游标套到新内容上。

2.2Adapter 只消费展示帧

adapter 仍然拥有 Incremark parser 的生命周期,但输入已经从 text 换成 displayedText。每次游标前进只 append 新释放的 suffix;只有展示文本等于权威文本时,完成态才真正 finalize parser。

apps/desktop/src/renderer/features/session/components/incremark-adapter.tsx解析进度跟随展示进度
const frame = usePlaybackBuffer(text, playback, finished)
const displayedText = frame.text
const displayFinished = finished && displayedText === text

if (displayedText.length === 0) {
  im.reset()
} else if (initialContent || displayedText.startsWith(previous)) {
  im.append(displayedText.slice(previous?.length ?? 0))
} else {
  im.reset()
  im.append(displayedText)
}

if (displayFinished && !finalizedRef.current) {
  im.finalize()
  finalizedRef.current = true
}

这使 parser 与用户看到的文本保持同一步幅。完成事件可以早到,但 parser 的 finalize 不会越过展示游标。

2.3最新尾部单独淡入

每帧返回 fadeChars 与单调递增的 revision。adapter 从最后一个 AST block 向前切,只给本帧新增的尾部创建 chunk;旧字符直接成为 stable text。

apps/desktop/src/renderer/features/session/components/incremark-adapter.tsx只标记最新 AST 尾部
const animatedChars = Math.min(blockChars, remaining)
const node = sliceAst(block.node, blockChars, {
  stableChars: blockChars - animatedChars,
  chunks: [{ text: ' '.repeat(animatedChars), createdAt: revision }],
})

if (node) result[index] = { ...block, node }
remaining -= animatedChars

CSS 动画只改变 opacity,从 0.2 到 1。64ms 动画短于 72ms 释放节拍,因此新 suffix 出现前,上一段已经完成淡入;reduced-motion 下直接关闭。

apps/desktop/src/renderer/styles/index.css单 suffix fade
.markdown-part .incremark-fade-in {
  animation: buffin-stream-text-enter 64ms cubic-bezier(0.2, 0.6, 0.3, 1) both;
}

@keyframes buffin-stream-text-enter {
  from { opacity: 0.2; }
  to { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .markdown-part .incremark-fade-in { animation: none; }
}
排查路标 · streamed markdown playback
症状从哪下手
completion 后剩余文字突然全部出现use-playback-buffer.ts:检查 beginsDraindrainingRef 与 interval 是否仍在运行
文字停住或重复 appendincremark-adapter.tsx:检查 previousTextRef、前缀判断与 displayedText
出现多层半透明尾巴incremark-adapter.tsx:检查 addFadeInTail 是否仍只生成一个 revision chunk
历史消息重新播放动画use-playback-buffer.ts:检查 initialDisplay 对 static playback 的分支
淡入完全不可见index.css:比较 64ms animation 与 72ms release cadence,并检查 reduced-motion

3心智模型补丁

Incremark 同时拥有权威文本、解析进度和播放进度。 Buffin 拥有展示游标;Incremark 只解析已经释放到屏幕的文本。
finished=true 等于“现在可以显示最终文本”。 finished=true 只表示输入封口;live part 还可能处于 draining。
static playback 总能立即 settle。 从 revealing 进入完成态时,即使外部 playback 已 static,内部 drain 仍会继续。
每个已经释放的 chunk 都可以保留自己的 fade span。 只有最新 release frame 拥有 fade span;上一帧立即成为稳定文本。
解析完成取决于 provider 是否发出 completion。 adapter 只有在 finished && displayedText === text 时才 finalize。

4新词表

播放状态
authoritative text事件流已经确认的完整事实文本,可能领先屏幕很多字符。
displayedText展示游标已经释放、当前允许交给 parser 和 DOM 的前缀。
draining输入已经完成,但展示游标还没追上权威文本的阶段。
settled展示文本已经追上权威文本,可以停止 timer 并静态化。
动画标记
fadeChars当前 release frame 新增加、允许获得淡入 class 的字符数量。
revision每次游标前进递增的 key,让最新 fade span 作为新节点开始动画。

5测试与风险地图

有兜底的

  • 静态历史不生成 fade chunk。
  • completion 到达后仍逐步排空,不硬切全文。
  • 最终 suffix 与 completion 同帧到达时仍会 drain。
  • 任意时刻只有一个淡入 suffix,不形成透明拖影。
  • frozen 模式暂停解析,恢复后从同一游标继续。
  • 非前缀 replacement 会 reset parser。
  • StrictMode effect cleanup 后 parser 仍可继续 append。

薄冰 · 事实缺口

  • 🟠没有覆盖“上一轮仍 draining 时马上开始下一轮”的跨 turn 集成测试。
  • 🟡没有浏览器截图或像素测试验证 64ms opacity 的视觉曲线;现有测试验证 DOM chunk 契约。
  • 🟡buffer 没有向 thread presentation 回报 settled,composer 仍只跟 execution state 联动。
  • 视觉 cadence 参数是模块常量,测试直接以 72ms 驱动定时器。
验证结果:提交钩子执行了全仓 check、所有 workspace typecheck 与 Vitest;296 个测试文件通过、2 个跳过,共 2972 tests passed、3 skipped。

覆盖声明:本报告全量精读 commit 58aca59 的 5 个文件与完整 diff;before 侧的 adapter 与旧测试直接读取自父提交 14dcbfe。没有抽样,没有使用 subagent,也没有把工作区其他历史 commit 纳入本报告。