streamed markdown playback:完成事件不再抢跑展示动画
buffin-ai/buffin · 14dcbfe...58aca59 · 2026-07-17 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
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 走一遍,就能看到这次改动真正移动的职责边界。
use-playback-buffer.ts→ 增量解析
incremark-adapter.tsx→ 单一淡入尾部
index.css
2.1播放缓冲区接管展示时钟
以前 adapter 把权威文本立即 append 给 Incremark,再用 Incremark typewriter 控制显示。完成态一旦触发 static 或 finished,调用方会发出 skip,尚未播放的尾部直接跳到最终文本。
static / finished 触发 skip缓冲区的参数共同定义感知速度:首帧先放 2 个字符,此后每 72ms 前进一步。积压越长,每步越大,但被限制在 2 到 12 个字符之间。
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)
}
finished 与 playback 不再被压成一个布尔开关。完成事件只在“此前确实正在 revealing”时开启内部 draining;因此 live part 会继续前进,静态历史则从一开始就完整显示。
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。
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。
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 下直接关闭。
.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:检查 beginsDrain、drainingRef 与 interval 是否仍在运行 |
| 文字停住或重复 append | incremark-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心智模型补丁
finished=true 等于“现在可以显示最终文本”。
finished=true 只表示输入封口;live part 还可能处于 draining。
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 驱动定时器。
覆盖声明:本报告全量精读 commit 58aca59 的 5 个文件与完整 diff;before 侧的 adapter 与旧测试直接读取自父提交 14dcbfe。没有抽样,没有使用 subagent,也没有把工作区其他历史 commit 纳入本报告。