# 正文真实增量流式输出(F073)— 实施计划 ## 目标 按契约 [`../../reference/token-streaming.md`](../../reference/token-streaming.md)(F073), 把正文展示从「done 时整段到达 → 打字机逐字播放」改为**真实增量即时渲染**: 后端 `answer_delta` 一到就上屏,用户等待 ≈ 模型生成速度本身(不再额外加打字机时长)。 **后端已实测部署**(2026-09-20 两份真实载荷):流目标均为 `summary`;一轮 delta 235~746 片、 平均 1.7 字/片;`answer`/`summary` 完整事件与 `result.response` 是**同一份正文的兼容副本**。 ## 用户决策(2026-09-20) 1. 流式正文**立即渲染**。打字机保留:①旧后端(无流式事件)②mock 测试按钮 (不经过协调器、依赖「Sending 态追加仍打字」)③历史消息本就立即渲染。 2. **用户主动停止 → 保留已流出文字**;后端 error / 断流 / 无 done 的 EOF → **撤草稿**。 3. 思考中卡片**文字一到就退**(沿用现有机制,不改)。 ## 两个关键设计决策 **① 新增「增量 markdown 规整器」** `normalizeChatMarkdown` 会**插入**空行,而它的每处决策只依赖**相邻两行**(行结束才稳定)。 若不做增量规整,`answer_end` 的「整块替换」在多段落回答上必然与已流文本分歧 → 触发整段重建 → `` 标记被重解析 → **思考中卡片每轮闪现 + 退场动画重播**。 规整器:已结束行立即按完整决策发出;当前未结束行**扣住**,直到能判定「已是列表项前缀」 (`LIST_ITEM_RE` 无 `$` 锚,命中即稳定)或「不可能成为列表项」。 **不变式:每步 `concat(已返回增量) === normalizeChatMarkdown(concat(已输入))`** → 正常路径上 `answer_end` 退化为**纯尾缀追加**(零行重建)。 **② `contentStreaming` 标记在 `answerStreamStart` 置真、只在 `finishTask` 清除** (end/abort 都不清)——end 之后 flushContent 追加的卡片/参考资料仍即时渲染; mock 与旧后端轮次从未置位。用户停止走 `stopAiMessage(state=Stop)`,`canAnimateAiText` 已排除 Stop。 ## 步骤 1. 新建 `src/components/answer-stream.ts`(纯函数):`normalizeChatMarkdown`/`LIST_ITEM_RE` 移入、 事件解析 `parseAnswerStreamEvent`、状态机 `applyAnswerStreamEvent`、增量规整器 2. 协调器:`ApiChatEventMap` 增 3 个事件(`answerStreamStart/End/Abort`);4 个新 case (delta 过规整器后走既有 `message` 通道吃 rAF 批处理);`progress/heartbeat` 在流开始后停发 scope; `flushContent` 重构(流已完成时跳过正文、`totalResponse` 仍拼最终全文); `discardActiveStream` 挂到 error / failTurn / EOF 三条路径(**abort 必须先于 error/close 发出**) 3. hook:per-task 流状态 + 3 个监听器(start 记 base、end 尾缀追加或重建、abort 截回 base); `finishTask` 清 `contentStreaming`;`close` 的 DMS 补写判据改 `hasVisibleMessageContent`; `saveHistory` 按 500ms 节流(流式期每 rAF 全量序列化整个 chatHistory) 4. `BusinessRecord.vue`:`shouldDriveTypewriter` 加 `&& !record.contentStreaming`(唯一改动点) 5. harness:`_entry-coordinator.ts` 导出 answer-stream;新增 `verify-answer-stream.mjs`、`probe-answer-stream.mjs` ### 修正:卡片放正文**之上**(用户 2026-09-20 指出) 流式把整篇综合说明当正文先铺满,卡片照旧排在正文之后 → 用户要先滚过全文才看到卡片, 与改造前「短开场 → 卡片 → 综合说明」的观感不一致(这次是我漏掉的版式回归)。 改法:`flushContent` 把输出拆成 **leading(卡片)/ trailing(notices、补问、二维码、参考资料)** 两块; 流式轮通过新事件 **`answerStreamCompose {streamId, leading, trailing}`** 交给应用层按 `基线 + leading + 正文 + trailing` **重写一次内容**(卡片因此在正文之上); 旧路径**完全不变**(正文 → 卡片 → 参考资料,有断言守着)。 组装逻辑抽成纯函数 `composeStreamedContent`(顺带剥掉基线里的 ``,避免卡片闪现)。 ## 验证 - **纯函数**:规整器前缀不变式(固定用例 + 种子伪随机 ≥500 轮 + 逐字/整段等价 + `finish()`); reducer(缺口/重复序号/过期流/end 后 delta/abort 复位/非法负载不抛) - **协调器级假流**:happy path(正文只出现一份、流中 heartbeat 不发 scope、End.text=规整后全文、 totalResponse 以最终全文开头)、无卡片轮不丢正文、整块替换、abort 先于 error、断流撤稿+兼容回退、 **旧路径逐字节回归**、跨轮隔离、双实例并行不串台 - **真后端探针**:`probe-answer-stream.mjs` 打两类问题,统计事件序/片数/首 delta 耗时/ `concat(deltas)===answer_end.text` - **浏览器验收**:逐帧增长非逐字、首字到达 scope 即退、段间空行正确、结束卡片即时追加、 拔网撤草稿、停止保留文字、**mock 按钮仍打字机**、A 流式中切 B 独立、刷新无残影 - 回归:全部 tools 脚本 + `npm run build` + `validate-harness` ## 风险 / 未决 - **单流假设**:状态机只维护一条活动流(实测后端每轮只流一个 target);将来双目标并发需改 per-target - **每帧 marked.parse**:长文或有开销,先观察;卡顿则隔帧 flush 或限单帧字节 - **罕见分歧分支**:实现时顺手剥掉 `baseText` 里的 `` 块再拼,消除卡片闪现 - **未决(需后端确认,不阻塞)**:流式轮的 `summary.notices` 是否仍输出(本方案保留)