name: chat-element-streaming description: 当 ai-shifu 聊天流从 block 粒度向 element 粒度演进,或历史记录与 SSE 渲染一致性出现问题时使用本技能。统一 element_bid 渲染键、兼容旧字段并收敛 AskBlock 归并逻辑。
Element 粒度聊天流
核心规则
- 使用
element_bid作为聊天项稳定渲染 key。 - 在数据归一化入口保留
generated_block_bid、parent_block_bid兼容字段。 - 历史 records 与实时 SSE 共用同一条转换路径,产出一致的
contentList。
工作流
- 接收 SSE
type=element时按element_bid覆盖更新,不做重复拼接。 - 对追问流
element_type=answer,让AskBlock按答案流增量更新。 - 学习记录返回
element_type=ask/answer时,归并到anchor_element_bid对应的AskBlock.ask_list。 - AskBlock 落位以接口返回顺序(
sequence)为准,不强制锚定在 anchor 内容后。 - 在统一归一化层回填旧字段,避免兼容逻辑散落到渲染层。
- 调试预览链路若切换到
type=element+type=done,需按element_bid覆盖更新元素项,并将done作为唯一收尾信号。 - SSE 包体字段需同时兼容
type/event_type与content/data两套命名,避免后端灰度期间前端丢流。 - 当调试流未返回
done但出现多个element_bid时,以“新 element 到达”作为前一个 element 的结束信号,并立即补一个追问块(like-status/ask入口)。 - 学习页、预览页、调试页处理
type=done时,优先读取done.is_terminal判断它是“当前分段结束”还是“真正终态”;字段缺失时才回退到旧的前端推断。 done收尾补追问块时,锚点应取“最近的可操作 element(content 或 interaction)”,否则最后一项为 interaction 时会漏补追问块。- 若后端出现
done与后续element乱序,边界判定不能只依赖 streaming ref,需增加“从当前列表尾部推断上一个可操作 element”的兜底逻辑,避免中间 element 漏补追问块。 - 若后端直接关闭预览 SSE,前端应把“最后一个可操作 element 不是 interaction”视为可续拉信号,自动发起下一次预览请求;但若最近一次
done.is_terminal=true,则必须停止续拉。 - 预览链路收到
type=done时,不要默认立即stopPreview;仅当done.is_terminal=true才关闭当前预览流。关闭后若最后一个可操作块不是interaction,应继续拉取下一段流。 - 当需要在
ai-shifu本地稳定复现runSSE 问题时,优先在getRunMessage层增加显式调试开关;mock_run_sse_fixture=stuck回放由data.json截断得到的卡住 fixture,mock_run_sse_fixture=test回放mock-fixtures/test/data.json的完整测试流,并保持message/readystatechange/close契约不变;不要把页面组件改成只兼容 mock 数据。 - 学习页
run流若在 3 秒内没有任何新事件,前端应在消费层主动判定为超时:关闭当前流与 loading 状态、向items/elementList追加type=error的错误项,并复用同一份国际化文案弹 destructive toast。 - 听课模式
Slide遇到超时错误时,只把它当作“关闭内部 loading overlay”的信号,不要把错误项渲染成单独一页;用户侧错误提示统一交给 toast。 - 阅读模式同样不要把超时错误项渲染进正文列表;错误项可以保留在内部状态里供控制逻辑使用,但用户可见反馈只保留 toast。
- 阅读模式的流式点状 loading 应作为列表尾部状态渲染在最后一个 element 后面,不要绑定到当前流式 element 内部,否则
html视觉 marker 更新时容易把 loading 插到两个 element 的视觉边界之间;但当loading思考占位仍在列表中时,不要同时显示点状 loading。 - 阅读模式若要给
markdown-flow-ui的ContentRender打开打字机效果,应只对“非历史记录、且element_type === text”的实时正文块开启;html、interaction和历史正文继续即时渲染,优先在ContentBlock这类渲染包装层集中判断,不要把条件散落到列表组装逻辑里。 - 当阅读模式在消费层已经明确知道某个
element被done或“下一个 element 到达”收尾时,要同步把本地ChatContentItem.is_final置为true;不要让列表状态长期停留在早先 SSE 快照里的false。 - 阅读模式如果要阻止“前一个 text 还在打字机播放、后一个 element 已经出现”,优先在
NewChatComp上层维护readModeItems的可见列表缓存和按element_bid存储的打字机完成态;只有前一个 text 同时满足“is_final=true且当前 content 的打字机回调已完成”时,才放行下一个 element。 - 移动端阅读模式若会在
element收尾时给正文content追加追问按钮或把块改成isHistory=true,不要让这个“收尾态 content”继续复用同一条 typewriter gate 缓存;否则缓存会因content变化被重置成未完成,而ContentRender又因为不再启用打字机而不会补发onTypeFinished,最终把后续 element 永久卡住。 isHistory只表示“来自学习记录/历史回放的数据”,不要把它当作运行时 UI 控制位复用;runSSE 覆盖已有历史项时,也不要从previousItem继承isHistory,否则实时流会被误判成历史数据。- 阅读模式打字机和
elementList可见性 gate 不要直接依赖isHistory;应使用独立的 typewriter 候选标记,仅让“真正参与实时打字机的 text element”进入缓存与阻塞链路,避免历史首块被 SSE 覆盖后误拦住后续 element。 - 一旦前端已经根据
done或“下一个 element 到达”把某个元素标记为is_final=true,后续同element_bid的 SSEelement快照不能再用旧的is_final=false/undefined覆盖回去;本地 finalize 语义优先级应高于早先快照字段。 - 移动端
custom-button-after-content只允许作为“打字结束后的静态尾部 UI”出现;打字机阶段传给渲染器的 content 必须先剥掉这段 markup,避免把追问按钮当正文逐字输出。 - 非
text的富内容块(如html、svg、sandbox 相关内容)若需要尾部追问按钮,不要依赖内容字符串内部的custom-button-after-content首屏渲染;应把按钮从 content 中剥离后在外层独立渲染,避免正文未稳定时按钮先于正文或以异常结构闪现。 - 只要
StudyRecordItem这类共享记录类型被 SSE、历史记录和渲染层共同消费,就必须把is_final等流式收尾字段声明在共享接口里;不要仅在局部 UI 类型上兜底,否则next build很容易在跨层调用处报类型缺失。 - 阅读模式不能只用
cache.isFinished决定同一个element_bid是否继续打字;若当前归一化 content 已经比缓存快照更长,即使旧 cache 标记过完成,也必须继续给ContentRender打开 typewriter,否则后续增量首帧会直接整段落地。 - 阅读模式判断“同一个 text element 是否需要再次进入打字机”时,不能把任意 content 差异都视为增量;只有“当前归一化 content 以缓存快照为前缀且长度更长”的真正追加场景才允许续打,避免收尾阶段的等价改写触发整段重打。
- 同一个
text element在is_final !== true的等待阶段,即使当前 chunk 已经打完,也不要先把enableTypewriter关掉;否则后续增量到达时会经历false -> true切换,ContentRender会把已显示文本清空并从头重打。 - 若流式链路在
text_end/done后会自动续拉下一段,不能只依赖内存里的currentContentRef作为正文基线;下一轮content到达时应优先回填同一element_bid已渲染正文,并同时兼容“纯 delta”与“累计快照”两种 payload 语义,否则续流会把前一段正文覆盖掉并触发整段重打。 AskBlock里的追问 answer 要把ContentRender.enableTypewriter当成“整条 answer 的会话状态”来保活,而不是把每个done/break都当成终态。非终态done(text_end)不能立刻finalize + close,终态收尾时也不要顺手把shouldUseTypewriter改成false,否则同一条 answer 后续再到 chunk 时就会从某一句开始整段直出。AskBlock本地 store 里若已经追加出比父层ask_list更长的追问历史,后续hydrateAskList不能再用更短的旧列表覆盖它;展开/收起、列表重排或重新渲染时都要优先保留较新的本地追问记录,否则收起后再展开会丢失刚刚流出来的追问问答。- 阅读模式若在
text_end/done与下一段同element_bid文本续流之间存在等待期,尾部可见的那个 text element 不能先把enableTypewriter关掉;至少在本轮输出仍未结束时要保活这次打字机会话,避免后续追加文本到达时触发false -> true切换并把已显示正文清空重打。 - 富内容块把
custom-button-after-content从正文字符串里剥离后,如果改成在宿主层单独渲染追问按钮,按钮的横向布局与img/span对齐样式也要一起在宿主层显式声明;不要只依赖原先 custom element 场景下的 descendant selector,否则按钮容易在外层 DOM 变化后出现图标与文案错位或换行。 - 阅读模式尾部 text 的打字机保活不能只依赖
isOutputInProgress;还要记录“当前这轮输出已经实际流到过哪个 element”。新一轮交互onSend刚启动、首个 element 尚未到达时,必须先清空上一轮的 keep-alive bid,避免旧正文被误判为当前流的一部分而再次整段打字。 - 阅读模式打字机 keep-alive 的锚点不要回退到“上一轮残留的 text element”,但也不要被后续
interaction/html抢走;它应绑定到“当前输出会话里最近一次参与打字机的 text element”。新一轮输出开始前先清空锚点,后续只有新的 text element 才能切换锚点,这样既能避免旧会话串场,也能避免 interaction 到达后把前一个 text 关掉再重开,触发整段重打。 - 阅读模式判断某个 text 是否还能继续保活打字机时,不能只看“它是不是最后一个 text”;还要看“它是不是最后一个可见 item”。一旦尾部已经挂上
like-status / interaction / ask之类的非 text 交互块,前一个 text 就不再是尾项,必须立即停止 keep-alive,避免交互块出现后前文再次整段打字。 - 听课模式里已经明确被
text_end/done或“下一个 element 到达”收尾的实时 element,应该立刻在数据层打上“切回阅读模式按历史态展示”的独立标记;阅读模式组装readModeItems时再把它映射成isHistory=true和shouldUseTypewriter=false,不要等用户切模式时再临时回填,也不要直接污染实时流本身的isHistory语义。 - 学习模式切换期间如果旧的 SSE stream 还在继续消费,凡是
finalize/build item这类依赖当前模式的逻辑,都不能直接读取创建 stream 时 closure 里的isListenMode;应改读实时 ref,否则“听课模式发起、阅读模式收尾”的交互链路会把新正文误标成 history-like,触发 typewriter 中途关闭后再重开,造成闪烁或整段重打。 - 追问
AskBlock的 streaming answer 若在打字途中被收起/关闭,不能把这条 answer 的shouldUseTypewriter立即改成false,也不要卸载承载它的ContentRender面板节点;应让流式会话在隐藏状态下继续保活,否则再次打开时同一条 answer 会因重挂载从错误进度重新打字,表现为速度异常、跳字或像被“叠速”一样。 - 上一条只适用于“流仍未结束”的窗口期;如果追问 answer 在面板隐藏期间已经收到终态
done/text_end(is_terminal=true)并完成收尾,就应立即把该 answer 的shouldUseTypewriter置为false。这样再次打开时直接展示静态最终文本,不要再重放一次打字机。
备注
- 当同一答案分多次快照回传且
element_bid相同,必须覆盖同一条消息。 - 字段重构从
*BlockBid*到*ElementBid*后,消费方解构与依赖数组必须同步改名。