ソースを参照

feat(chat): 正文真实增量流式输出(F073)

按用户新增契约 harness/docs/reference/token-streaming.md 实现:正文逐片段实时上屏,
不再是"整段到达后逐字打字";思考中卡片在第一个字到达时即退场。

后端已实测部署(两份真实载荷):流目标均为 summary;一轮 delta 235~746 片、平均 1.7 字/片;
concat(deltas) === answer_end.text 成立。

实现(渲染层只改一处):
- 新增 src/components/answer-stream.ts(纯函数):事件解析 / 流状态机(状态+效果为数据)/
  增量 markdown 规整器 / 应用层对齐;normalizeChatMarkdown 搬入(协调器 re-export)
- 协调器:4 个新 case;delta 过规整器后走既有 message 通道(吃 rAF 批处理);
  progress/heartbeat 流开始后停发 scope;flushContent 流式轮跳过正文但 totalResponse
  仍拼最终全文(修掉"纯正文流式轮丢正文");discardActiveStream 挂 error/failTurn/EOF
  (abort 必须先于 error/close,否则被 hook 的任务守卫丢弃)
- hook:三个监听器 + contentStreaming 标记(start 置位、finishTask 清除)+ close 的 DMS
  补写判据改 hasVisibleMessageContent + saveHistory 按 500ms 节流
- 渲染层:shouldDriveTypewriter 加 && !record.contentStreaming(TextContent 零改动)

两个关键决策(均有实测依据):
① 增量规整器:normalizeChatMarkdown 是插入空行的,"delta 直接拼接"与"整段规整"在多段落
   回答上必然不同 → 不做增量规整会让 answer_end 的整块替换触发整段重建 →
   <scope> 被重解析 → 思考中卡片每轮闪现重播。规整器保证"已上屏 = 最终全文的逐步前缀"
② 开场概述判据:answer.text 是正文的压缩版(实测 134 字 vs 2199 字、截断在词中间),
   流式轮跳过;仅当与正文公共前缀 <90%(是另一段内容)才保留并 warn

验证:新增 verify-answer-stream.mjs 78/78(含 500 轮模糊不变式)+ probe-answer-stream.mjs
打真实后端通过 + 既有脚本全绿 + build 通过。
⚠️ 浏览器实测未做(逐帧增长 / scope 即退 / mock 按钮仍打字机 / 500+ 片流畅度等),
完成后再把 answer-streaming 转 passing。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 9 時間 前
コミット
92e2310704

+ 12 - 0
README.md

@@ -285,3 +285,15 @@
   - 复制按钮的剥离规则同步补上 `<image-scope>`,避免把图片地址复制进剪贴板
   - 适配层 15 项新断言(含 `false`/缺字段/非布尔值不显示、跨轮不串、**补问轮不展示**)
   - 位置:**参考资料之前**;**补问轮(等用户补充公司信息/确认候选)不展示**——那一轮还没有正文回答
+- **回答正文改为真实增量流式输出**(后端 F073:`answer_start` / `answer_delta` / `answer_end` /
+  `answer_abort`,契约见 `harness/docs/reference/token-streaming.md`)
+  - 正文**逐片段实时上屏**,不再是"整段到达后逐字打字";思考中卡片在第一个字到达时即退场
+  - 为此在适配层加了**增量 markdown 规整器**(`src/components/answer-stream.ts`):
+    正文块用的规整函数是**插入空行**的,若不做增量规整,`answer_end` 的"整块替换"会与已流文本
+    分歧 → 整段重建 → 思考中卡片每轮闪现重播。规整器保证「已上屏内容恒等于最终全文的逐步前缀」,
+    正常路径因此退化为**纯尾缀追加**(零抖动)
+  - 实测(真实后端 505 片 delta / 876 字):`concat(deltas) === answer_end.text` 成立;
+    `answer` 事件是正文开头的**压缩版**,流式轮跳过不重复渲染(仅当与正文公共前缀 < 90% 才保留)
+  - 用户主动**停止保留已流出文字**;后端出错 / 断流**撤掉未定稿的草稿**
+  - 旧后端(不下发流式事件)与 mock 测试按钮**仍走原来的打字机**(回归重点)
+  - 新增 `verify-answer-stream.mjs`(78 项:纯逻辑 + 协调器级假流)、`probe-answer-stream.mjs`(真后端探针)

+ 66 - 0
harness/docs/exec-plans/active/answer-streaming.md

@@ -0,0 +1,66 @@
+# 正文真实增量流式输出(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` 的「整块替换」在多段落回答上必然与已流文本分歧 → 触发整段重建 →
+`<scope>` 标记被重解析 → **思考中卡片每轮闪现 + 退场动画重播**。
+规整器:已结束行立即按完整决策发出;当前未结束行**扣住**,直到能判定「已是列表项前缀」
+(`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`
+
+## 验证
+
+- **纯函数**:规整器前缀不变式(固定用例 + 种子伪随机 ≥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` 里的 `<scope>` 块再拼,消除卡片闪现
+- **未决(需后端确认,不阻塞)**:流式轮的 `summary.notices` 是否仍输出(本方案保留)

+ 1 - 0
harness/docs/reference/README.md

@@ -9,6 +9,7 @@
 |---|---|---|
 | `api-chat.md` | ✅ **现行契约** | 改任何聊天相关代码之前 |
 | `api-chat-fields-zh.md` | ✅ **现行契约** | 需要字段中英对照、状态取值释义时 |
+| `token-streaming.md` | ✅ **现行契约(F073 正文流式)** | 改正文展示/流式相关代码之前 |
 | `company-classification.md` | ⚠️ **现行契约(后端有实测问题)** | 做企业信息分类同步(company_info → 1888/1886)时 |
 | `company-info-dms-mapping.md` | 📄 **字段对应关系**(脚本生成) | 要查「company_info 的某个字段落到 DMS 哪一列 / 哪些没落」时 |
 | `legacy/API.md` | 📦 **交接快照**(非规格、不更新) | 只在需要翻"当初调了什么接口"时读 |

+ 21 - 0
harness/docs/reference/api-chat.md

@@ -78,6 +78,10 @@ data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f
 | summary | text、source_ids、status、notices | 显示末尾综合说明 |
 | interrupt | kind、question、input_help等 | 提示用户补充公司信息或确认候选 |
 | result | response、recommendation、errors、company_info、cipa | 当前轮次的完整最终快照 |
+| **answer_start** | stream_id、target、sequence、provisional | **正文流开始**(F073):建立临时正文块 |
+| **answer_delta** | stream_id、target、sequence、text | **正文增量**:立即渲染(不是整段到达后逐字播放) |
+| **answer_end** | stream_id、target、sequence、text、operation、status | **正文定稿**:用 text **整块替换**临时正文 |
+| **answer_abort** | stream_id、reason | 撤去临时正文(草稿不可当成功答复) |
 | done | status | 本次流正常结束或等待输入 |
 | error | code,可选message | 本次流异常结束 |
 
@@ -163,6 +167,23 @@ data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f
 }
 ```
 
+### 正文真实增量流式(F073)——实测补充
+
+契约全文见 [`token-streaming.md`](token-streaming.md)。**前端实测到的字段关系**(2026-09-20,
+两份真实载荷;文档没写、但对前端很关键):
+
+| 字段 | 有卡片的轮次 | 无卡片的轮次 |
+|---|---|---|
+| `answer.text`(开场概述) | **134 字**,是正文开头的**压缩版**(截断在词中间、空白折叠) | **不存在** |
+| 流式正文 = `answer_end.text` = `summary.text` | **2199 字**(完整正文) | 1266 字 |
+| `result.response` | 134 字(= 开场概述,兼容旧客户端) | = 完整正文 |
+
+**前端处理**:流式目标实测都是 `summary`;正文以 `answer_end` 为准,
+`summary.text` 不再重复渲染(跳过),`answer` 概述在「与正文公共前缀 ≥90%」时也不显示
+(否则开头会重复一遍),只有确认是另一段内容时才保留。
+`notices` 仍照常展示。另:该轮 `summary.notices` 可能是
+「本轮检索或逐条分析未完整完成」这类提示,属正常返回。
+
 ### cipa(`result.data.cipa`)
 
 **布尔值**,表示本轮是否需要展示**企业微信二维码**(2026-09-20 后端新增):

+ 61 - 0
harness/docs/reference/token-streaming.md

@@ -0,0 +1,61 @@
+# 正文真实增量流式输出(F073)
+
+POST `/api/chat` 保留阶段、心跳、公司确认、来源、卡片及最终结果事件。新增正文流事件,后端以 `stream=true` 读取模型上游 SSE,收到公开正文片段就推送,无需等完整 JSON 或整个节点结束。不是完整回答后的逐字播放。上游一个片段可能包含一个或多个 token,不保证逐汉字、逐词或严格 tokenizer 边界。
+
+## 事件契约
+
+沿用外层 `{thread_id, request_id, data}`。以下字段位于 `data`,以一次请求的 `request_id` 和 `stream_id` 定位正文块。
+
+| 事件 | 字段及含义 | 展示动作 |
+| --- | --- | --- |
+| `answer_start` | `stream_id` 本次正文生成ID;`target` 为 `summary` 或 `answer`;`sequence=0`;`provisional=true` | 新建或清空对应的生成中正文块 |
+| `answer_delta` | 同一ID/target;`sequence` 从1递增;`text` 为新增字符串 | 按顺序追加text,立即渲染 |
+| `answer_end` | 同一ID/target;sequence为最后片段序号;`text` 为完整最终正文;`operation=replace`;`status=completed/fallback`;`provisional=false` | **整块替换**,不要追加 |
+| `answer_abort` | 同一ID/target;sequence为最后片段序号;`operation=discard`;`reason` 为 `generation_failed/regenerating/not_used/not_finalized` | 撤去这次临时正文,等待后续正常回答或失败提示 |
+
+`answer_end` 后仍保留原 `answer` 或 `summary` 完整事件,并新增可选 `stream_id` 与该正文关联;这些完整事件用于兼容旧客户端及提供来源等字段,不是另一份正文。最终 `result` 结构不变,不保存流式ID;断线恢复、历史渲染以完整结果为准。不要把 `answer_delta` 当成完整 `answer`。
+
+```text
+event: answer_start
+data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":0,"provisional":true}}
+
+event: answer_delta
+data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":1,"text":"我们青浦"}}
+
+event: answer_delta
+data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":2,"text":"的合成服务说明。"}}
+
+event: answer_end
+data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":2,"text":"我们青浦的合成服务说明。","operation":"replace","status":"completed","provisional":false}}
+
+```
+
+前端使用 POST fetch 的 `response.body` 持续读取;不要先 `await response.text()` 再解析。使用流式 TextDecoder 处理跨网络包UTF-8,将未组成完整SSE事件的字符保留到下个包;一个网络包不等于一个事件。完成一个空行分隔事件后,解析外层JSON并按事件名处理。
+
+同一个请求的事件分发建议:
+
+1. `answer_start` 建立临时正文,记录ID、target及最后序号0。
+2. `answer_delta` 只接受当前ID,忽略已消费序号,按顺序拼接text;序号缺口标记正文不完整,等最终替换。Markdown可按每帧刷新,避免每个片段重排整页;不要把模型正文当原始HTML插入。
+3. `answer_end` 无论状态是completed还是fallback,均用text覆盖已有内容并解除生成中状态。最终校验或程序补充可能改变草稿,不能只停止光标而保留草稿。
+4. `answer_abort` 丢弃当前ID临时内容。后续原answer/summary照常展示;没有流ID的完整事件也要支持。
+5. 原answer/summary若有已见ID,只更新同一块的最终文本和元数据;不要新增第二份回答。`result` 校准最终展示,沿用原answer/summary去重规则;error、连接异常或没有正常终止的EOF须撤去尚未完成草稿,不能显示为成功答复。
+
+新客户端兼容无新事件的旧后端;旧客户端忽略新事件仍能拿到完整回答,但没有增量展示效果。外部前端不在本仓库,需按本契约接入。
+
+## 输出范围与最终校验
+
+- 政策总结及知识综合:只提取顶层 `text`;闲聊:已确认 `conversation_kind=smalltalk` 后才提取 `direct_response`。识别提示要求类型先输出;若模型未遵循字段顺序,保守保持完整答复,不提前猜类型。
+- 查询规划、候选证据分析、分层压缩、公司资料、JSON字段、source_ids及模型reasoning均不作为正文片段输出。业务画像、来源和卡片继续完整返回。
+- JSON转义、跨片段Unicode和代理对先解码再展示。金融内部占位标记不展示;完整校验后绑定真实链接,随最终替换出现。
+- 临时正文尚未通过完整JSON和来源引用校验。校验失败可能替换为明确的降级答复;上游断流、请求取消等撤回草稿。原证据约束、输出预算、并发信号量和请求作用域保持,不增加生成轮次。
+- 同题QA原文、无需模型生成的短答案、固定提示和公司候选等仍一次返回,不模拟逐字输出。检索及证据分析耗时仍存在,期间继续阶段和心跳;本功能缩短最终正文生成时的展示等待,不声称消除整条链路首字等待。
+
+## 运行与验证
+
+后端重启加载代码。已有8事件有界队列提供背压,断连/超时通知模型读取循环停止;阻塞的网络读仍可能等至读取超时,不提前释放仍运行的工作线程和会话锁。生产反向代理需关闭SSE缓冲;如直连已逐段而代理整段返回,应排查代理缓冲及压缩。未部署或验证外部代理/前端。
+
+本轮210项问答、53项API合成测试通过,包括真实JsonModel/GraphRuntime到Service SSE消费者的完成前增量检查、跨片段转义、错误撤回、最终替换、并发隔离,以及既有背压与断连回归。
+
+纯合成真实模型验证:`.venv\Scripts\python.exe -m src.step3_question_answer.token_stream_check`,统计记录 `output/checks/token-stream.json`,只保存耗时/片段数/断言。2026-09-20政策、知识、闲聊三路径首片段分别约0.58/0.67/0.74秒,模型完成约0.97/1.03/1.65秒;这是直接模型调用的合成样例,不含真实检索、代理或用户网络耗时,不能作为生产延迟承诺。
+
+上游协议参照[DeepSeek Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion/)的delta.content、finish_reason与[DONE],不转发推理字段。

ファイルの差分が大きいため隠しています
+ 16 - 0
harness/feature_list.json


+ 84 - 0
harness/progress.md

@@ -2915,3 +2915,87 @@
 - `cipa-qrcode`:`in_progress` → **`passing`**;verification 追加人工实测条目,evidence 记录
   「先据截图发现补问轮误出图 → 修复 → 再确认」的完整经过
 - 功能清单:**21 项,in_progress 0**
+
+## Session 068
+
+- **日期**:2026-09-20
+- **本轮目标**:按用户新增的契约 `harness/docs/reference/token-streaming.md`(F073 正文真实增量流式)
+  出方案 → 用户确认可行 → 实现
+- **方案**:[`docs/exec-plans/active/answer-streaming.md`](docs/exec-plans/active/answer-streaming.md)
+  (已落库;两个关键设计决策写在里面)
+
+### 已验证的事实(开工前就探明,避免猜)
+
+- **后端已部署 F073**:两份真实载荷实测——流目标都是 `summary`;一轮 delta **235~746 片、平均 1.7 字/片**;
+  `concat(deltas) === answer_end.text` **成立**;`answer`/`summary`/`result.response` 的文本关系见下
+- **关键发现(文档没写)**:`answer.text` 是正文开头的**压缩版**(134 字 vs 正文 2199 字,
+  **截断在词中间**:正文接着 `###`、概述结尾是半个词「部分候」);
+  `result.response` 在有卡片轮 = 该概述、无卡片轮 = 完整正文
+
+### 已完成
+
+- 新增 `src/components/answer-stream.ts`(纯函数):事件解析 / 流状态机(状态+效果为数据)/
+  **增量 markdown 规整器** / 应用层对齐函数;`normalizeChatMarkdown` 搬入(协调器 re-export)
+- 协调器 4 个新 case;delta 过规整器后**走既有 message 通道**(吃应用侧 rAF 批处理);
+  `progress/heartbeat` 流开始后停发 scope;`flushContent` 流式轮跳过正文、
+  `totalResponse` 仍拼最终全文(**修了一个坑**:原实现 parts 为空就 return,纯正文流式轮会丢正文);
+  `discardActiveStream` 挂 error / failTurn / EOF 三条路径
+- hook:三个监听器(start 记基线 / end 对齐 / abort 截回)+ `contentStreaming` 标记
+  (start 置位、**只在 finishTask 清除**)+ `close` 的 DMS 补写判据改 `hasVisibleMessageContent`
+  + `saveHistory` 按 500ms 节流(流式期每帧全量序列化整个 chatHistory 太贵)
+- 渲染层**只改一处**:`shouldDriveTypewriter` 加 `&& !record.contentStreaming`(TextContent 零改动)
+
+### 运行过的验证
+
+- `npm run build` 通过
+- **新增 `verify-answer-stream.mjs` —— 78/78 通过**:纯逻辑 51 项(**500 轮种子化模糊**验证规整器
+  不变式、逐字/整段等价、状态机的缺口/重复序号/过期流、应用层对齐)+ 协调器级假流 27 项
+  (正文只出现一份、流中 heartbeat 不发 scope、abort 先于 error、旧路径回归、双实例不串台)
+- **新增 `probe-answer-stream.mjs` —— 打真实后端通过**:505 片 delta / 876 字,
+  `concat(deltas) === answer_end.text` 为真、归整后以增量为前缀、正文只出现一次、
+  totalResponse 以最终正文开头且含卡片与参考资料
+- 回归:既有全部脚本绿(24/33/84/25/11/12 项)
+
+### 过程中的自我修正(都是测试写错,不是代码问题)
+
+1. 增量规整器**第一版把 `\n` 当"行尾"发出**,而正确语义是"行与行之间的分隔符"——末行会多一个换行,
+   与 `normalizeChatMarkdown` 不一致。重写后不变式才成立
+2. 不变式判据一开始太严:扣住的部分含"随行一起的前置空行",属正常;改成
+   「未发出的差额去掉前导 `\n` 后不含换行」
+3. 协调器级测试**忘了注册 `totalResponse` 监听**、且用 `'\n\n'` 拼 message 通道
+   (真实链路是 `content += msg` 无分隔符)→ 一度误判"totalResponse 丢了"
+4. 开场概述的压缩版是**截断在词中间**的,严格前缀判据认不出来 → 改成公共前缀占比 ≥90%
+5. 「断流撤稿」的断言一度写在协调器层:协调器**只发信号**,撤稿是应用层做的 →
+   把 `applyAnswerStreamEnd/Abort` 抽成纯函数并在纯逻辑层断言
+
+### 已记录证据
+
+本文件 Session 068;`docs/exec-plans/active/answer-streaming.md`;
+`harness/tools/{verify-answer-stream.mjs,probe-answer-stream.mjs}`;`feature_list.json` 的 `answer-streaming`;
+`docs/reference/api-chat.md`(新增 F073 事件行 + 「实测补充」小节);根 `README.md`
+
+### 更新过的文件或工件
+
+`src/components/answer-stream.ts`(新增)、`src/components/api-chat-coordinator.ts`、
+`src/components/business-assistant/{useBusinessAssistantChat.ts,shared.ts}`、
+`src/components/Chat/BusinessRecord.vue`、`harness/tools/{_entry-coordinator.ts,verify-answer-stream.mjs,
+probe-answer-stream.mjs,README.md}`、`harness/docs/{reference/api-chat.md,reference/README.md,
+exec-plans/active/answer-streaming.md}`、`harness/feature_list.json`、根 `README.md`、本文件
+
+### 已知风险或未解决问题
+
+- ⚠️ **浏览器实测未做**(自动化与真后端探针都过了,但观感/交互验收只能人看):
+  逐帧增长、首字到达 scope 即退、段间空行与列表、结束卡片即时追加、拔网撤草稿、
+  停止保留文字、**mock 按钮仍打字机**、A 流式中切 B、刷新无残影、500+ 片时的流畅度与 localStorage 频率
+- ⚠️ `contentStreaming` 会随消息持久化:流式中途关页可能残留 `true`,但 `loadHistory` 强制
+  `history: true` → `canAnimateAiText` 为假 → 无害
+- ⚠️ 单流假设:状态机只维护一条活动流(实测后端每轮只流一个 target=summary);
+  若将来同轮双目标并发,需要改成 per-target 状态表
+- ⚠️ 每帧 `marked.parse` 整行重渲染:长回答(876 字 / 505 片)暂未见问题,若真机卡顿
+  可隔帧 flush 或限单帧字节
+- 📌 未决(需后端确认,不阻塞):流式轮的 `summary.notices` 实测会出现
+  「本轮检索或逐条分析未完整完成」这类提示,前端照常展示
+
+### 下一步最佳动作
+
+浏览器验收(上面 9 点,重点是 mock 按钮回归与 500+ 片的流畅度)→ 通过后把 `answer-streaming` 转 passing

+ 2 - 0
harness/tools/README.md

@@ -72,6 +72,7 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 | `_company-classify.mjs` | `verify-company-classify-dms.mjs` | ✅ 真实 DMS(**注入假分类响应**,可反复跑) |
 | `_company-classify.mjs` | `verify-company-classify-e2e.mjs` | ✅ 真实分类接口 + 真实 DMS(要一份**真实形态**的 company_info JSON,见脚本头注释) |
 | `_coordinator.mjs` | `verify-company-info-passthrough.mjs` | ❌ 打桩 fetch 喂假 SSE 流 |
+| `_coordinator.mjs` | `verify-answer-stream.mjs` | ❌ 纯逻辑(增量规整器/状态机)+ 假 SSE 喂真实协调器(F073 正文流式) |
 
 文档生成与审计(不是断言脚本,但也打真实 DMS 读模型定义):
 
@@ -79,6 +80,7 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 |---|---|---|
 | `gen-company-info-mapping-doc.mjs` | 生成「company_info ↔ DMS 企业两栏目」字段对应文档 | `docs/reference/company-info-dms-mapping.md` |
 | `audit-company-info-coverage.mjs` | 审计字段覆盖率(载荷 / 模型 / 代码写入列三边对齐) | 控制台清单 |
+| `probe-answer-stream.mjs` | 打真实后端,统计 F073 正文流式的**原始载荷**并喂真实协调器 | 控制台统计与契约一致性核对 |
 
 > `--define:import.meta.env='{}'`:验证脚本跑在 node 里没有 `import.meta.env`,
 > 需要喂一个空对象,否则读取环境变量的模块会直接抛错。

+ 3 - 0
harness/tools/_entry-coordinator.ts

@@ -11,3 +11,6 @@
  *    不认识 .png 会直接报错。完整命令见 `harness/tools/README.md`。
  */
 export { buildPolicyTableContent, ApiChatCoordinator } from '../../src/components/api-chat-coordinator';
+
+/** F073 正文流式的纯逻辑(状态机 / 事件解析 / 增量规整器 / normalizeChatMarkdown) */
+export * from '../../src/components/answer-stream';

+ 179 - 0
harness/tools/probe-answer-stream.mjs

@@ -0,0 +1,179 @@
+/**
+ * 真后端探针:打真实 `/api/chat`,把 F073 正文流式的**原始载荷**与**真实协调器**的表现都测一遍。
+ *
+ * 为什么单独有这个脚本:契约(`harness/docs/reference/token-streaming.md`)描述的是"应该是什么样",
+ * 而**跑着的后端才是事实来源**。这个脚本同时给出原始 SSE 统计与协调器处理后的结果,
+ * 用来发现"文档与实现不一致"的地方(例如 delta 平均长度、是否夹 heartbeat、拼接是否等于最终文本)。
+ *
+ * 怎么跑(先按 _entry-coordinator.ts 头部注释重新打包;需要 dev server 起着走同源代理):
+ *   node harness/tools/probe-answer-stream.mjs "青浦区高新技术企业有什么扶持政策"
+ *   # 可选:CHAT_BASE=https://localhost:8083/chat-api 覆盖地址
+ *
+ * 只读:不改任何数据;建议连着跑两轮——一个会出卡片的业务问题 + 一个普通问答。
+ */
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+
+const chatBase = (process.env.CHAT_BASE || 'https://localhost:8083/chat-api').replace(/\/+$/, '');
+const question = process.argv[2] || '青浦区高新技术企业有什么扶持政策';
+const threadId = `probe-stream-${Date.now()}`;
+
+const { ApiChatCoordinator, applyAnswerStreamEvent, createAnswerStreamState, normalizeChatMarkdown } =
+  await import('./_coordinator.mjs');
+
+console.log(`问题:${question}`);
+console.log(`地址:${chatBase}/api/chat\n`);
+
+/* ------------------------------------------------------------------ *
+ * ① 拿原始载荷
+ * ------------------------------------------------------------------ */
+const startedAt = Date.now();
+const res = await fetch(`${chatBase}/api/chat`, {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({ thread_id: threadId, question }),
+});
+console.log(`HTTP ${res.status}`);
+const raw = await res.text();
+const totalMs = Date.now() - startedAt;
+if (res.status !== 200) {
+  console.log('原始响应前 300 字:', raw.slice(0, 300));
+  process.exit(1);
+}
+
+/* ------------------------------------------------------------------ *
+ * ② 原始事件统计
+ * ------------------------------------------------------------------ */
+const events = [];
+for (const block of raw.split(/\n\n/)) {
+  const m = block.match(/^event: (\S+)\ndata: (.*)$/s);
+  if (!m) continue;
+  let data = {};
+  try {
+    data = JSON.parse(m[2]).data || {};
+  } catch {
+    /* 忽略坏帧 */
+  }
+  events.push({ name: m[1], data, at: null });
+}
+
+const counts = {};
+for (const e of events) counts[e.name] = (counts[e.name] || 0) + 1;
+
+const deltas = events.filter((e) => e.name === 'answer_delta');
+const starts = events.filter((e) => e.name === 'answer_start');
+const ends = events.filter((e) => e.name === 'answer_end');
+const aborts = events.filter((e) => e.name === 'answer_abort');
+const endEvent = ends[0];
+const joined = deltas.map((e) => e.data.text || '').join('');
+
+// 压缩事件序列(连续 delta 合并显示)
+const compact = [];
+let run = 0;
+for (const e of events) {
+  if (e.name === 'answer_delta') {
+    run++;
+    continue;
+  }
+  if (run) {
+    compact.push(`delta×${run}`);
+    run = 0;
+  }
+  compact.push(e.name);
+}
+if (run) compact.push(`delta×${run}`);
+
+console.log('\n【1】原始载荷');
+console.log('  事件计数:', JSON.stringify(counts));
+console.log('  事件序列:', compact.join(' → '));
+console.log(`  总耗时:${(totalMs / 1000).toFixed(1)}s`);
+if (starts.length) {
+  console.log(
+    `  流目标:${starts.map((e) => e.data.target).join(',')}  stream_id=${String(starts[0].data.stream_id).slice(0, 8)}…`
+  );
+}
+if (deltas.length) {
+  const lens = deltas.map((e) => (e.data.text || '').length);
+  console.log(
+    `  delta:${deltas.length} 片,共 ${joined.length} 字,平均 ${(joined.length / deltas.length).toFixed(1)} 字/片,单片最大 ${Math.max(...lens)}`
+  );
+  // heartbeat 是否夹在 delta 之间(会决定"思考中卡片停发"的守卫是否够)
+  const firstDelta = events.findIndex((e) => e.name === 'answer_delta');
+  const lastDelta = events.map((e) => e.name).lastIndexOf('answer_delta');
+  const between = events
+    .slice(firstDelta, lastDelta)
+    .filter((e) => e.name === 'heartbeat' || e.name === 'progress').length;
+  console.log(`  流中途夹带的 progress/heartbeat:${between} 次(守卫应拦住它们、不再发思考中卡片)`);
+}
+if (endEvent) {
+  console.log(`  answer_end:status=${endEvent.data.status} operation=${endEvent.data.operation} 长度=${(endEvent.data.text || '').length}`);
+}
+if (aborts.length) console.log(`  ⚠️ answer_abort ×${aborts.length}:`, JSON.stringify(aborts.map((e) => e.data.reason)));
+
+// 契约一致性核对
+console.log('\n【2】契约一致性(拿实测对照文档)');
+const normFinal = endEvent ? normalizeChatMarkdown(endEvent.data.text || '') : '';
+console.log(
+  `  concat(deltas) === answer_end.text ? ${joined === (endEvent?.data.text || '(无 end)')}(差异 ${joined.length - (endEvent?.data.text || '').length} 字)`
+);
+console.log(`  归整后是否以增量拼接为前缀(决定"整块替换"能否退化成尾缀追加):${normFinal.startsWith(joined)}`);
+
+const answerEvent = events.find((e) => e.name === 'answer');
+const summaryEvent = events.find((e) => e.name === 'summary');
+const resultEvent = events.find((e) => e.name === 'result');
+const same = (a, b) => !!a && !!b && a === b;
+console.log(
+  `  answer / summary / result.response 是否为同一份文本:${same(answerEvent?.data.text, summaryEvent?.data.text) || '(无 answer)'} / ${same(summaryEvent?.data.text, resultEvent?.data.response)}`
+);
+console.log(`  summary 是否带 stream_id(用于与流关联):${summaryEvent ? 'stream_id' in summaryEvent.data : '(无 summary)'}`);
+console.log(`  summary.notices:${JSON.stringify(summaryEvent?.data.notices ?? null)}`);
+
+/* ------------------------------------------------------------------ *
+ * ③ 喂真实协调器(同一份载荷)
+ * ------------------------------------------------------------------ */
+console.log('\n【3】真实协调器处理这轮载荷');
+globalThis.fetch = async () =>
+  new Response(
+    new ReadableStream({
+      start(controller) {
+        controller.enqueue(new TextEncoder().encode(raw));
+        controller.close();
+      },
+    }),
+    { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
+  );
+
+const coordinator = new ApiChatCoordinator({ baseUrl: chatBase, threadId });
+const seen = { chunks: [], end: null, abort: null, start: null, total: null };
+coordinator.addEventListener('message', (m) => seen.chunks.push(m));
+coordinator.addEventListener('answerStreamStart', (p) => (seen.start = p));
+coordinator.addEventListener('answerStreamEnd', (p) => (seen.end = p));
+coordinator.addEventListener('answerStreamAbort', (p) => (seen.abort = p));
+coordinator.addEventListener('totalResponse', (p) => (seen.total = p));
+
+await coordinator.generateAnswer(question);
+await new Promise((r) => setTimeout(r, 200));
+
+const onScreen = seen.chunks.join('');
+const bodyCount = normFinal ? onScreen.split(normFinal.slice(0, 20)).length - 1 : 0;
+console.log(`  answerStreamStart/End:${!!seen.start} / ${!!seen.end};abort:${seen.abort ? seen.abort.reason : '无'}`);
+console.log(`  上屏内容长度:${onScreen.length}(其中正文出现次数≈${bodyCount},应为 1)`);
+console.log(`  totalResponse.answer 是否以最终正文开头:${String(seen.total?.answer || '').startsWith(normFinal.slice(0, 20))}`);
+console.log(`  上屏内容是否含卡片标记:${onScreen.includes('POLICY_TABLE')};含参考资料:${onScreen.includes('<ref_links>')}`);
+
+/* ------------------------------------------------------------------ *
+ * ④ 结论
+ * ------------------------------------------------------------------ */
+console.log('\n【4】结论');
+const problems = [];
+if (!starts.length) problems.push('后端没有下发 answer_start(F073 未部署或该问题不走流式)');
+if (starts.length && !normFinal.startsWith(joined)) {
+  problems.push('增量拼接不是最终文本的前缀 → 会触发整块重建(「思考中」卡片会闪现一次)');
+}
+if (bodyCount > 1) problems.push('正文在上屏内容里出现多次(完整事件被渲染成了第二份)');
+if (!seen.end) problems.push('协调器没有收到 answerStreamEnd');
+if (problems.length) {
+  for (const p of problems) console.log(`  ⚠️ ${p}`);
+  process.exit(1);
+}
+console.log('  ✅ 原始载荷与协调器表现均符合契约');

+ 564 - 0
harness/tools/verify-answer-stream.mjs

@@ -0,0 +1,564 @@
+/**
+ * 验证 F073「正文真实增量流式输出」的两块:
+ *   第 1 部分 —— **纯逻辑**(不必联网):增量规整器的前缀不变式 + 流状态机
+ *   第 2 部分 —— **协调器级假流**:假 SSE 流喂真实协调器,断言事件通道与去重
+ *
+ * 为什么规整器要这么较真:它手里攥着一个不变式——
+ *   **任一时刻「已发出的增量拼接」=== `normalizeChatMarkdown(已输入的全部文本)`**
+ * 只要不变式成立,后端 `answer_end` 的「整块替换」在正常路径上就是纯尾缀追加
+ * (不会触发整段重建 → 不会让「思考中」卡片闪现重播)。破了它,观感立刻劣化。
+ *
+ * 怎么跑(先重新打包,必须带 --loader:.png=dataurl):
+ *   npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_coordinator.mjs --alias:@=./src \
+ *     --define:import.meta.env='{}' --loader:.png=dataurl
+ *   node harness/tools/verify-answer-stream.mjs
+ */
+
+import {
+  ApiChatCoordinator,
+  applyAnswerStreamAbort,
+  applyAnswerStreamEnd,
+  applyAnswerStreamEvent,
+  createAnswerStreamState,
+  createIncrementalMarkdownNormalizer,
+  normalizeChatMarkdown,
+  parseAnswerStreamEvent,
+  shouldKeepAnswerOverview,
+} from './_coordinator.mjs';
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, extra = '') => {
+  if (cond) {
+    pass++;
+    console.log(`  ok   ${name}`);
+  } else {
+    fail++;
+    console.log(`  FAIL ${name}${extra === '' ? '' : ` → ${JSON.stringify(extra)}`}`);
+  }
+};
+
+/* ================================================================== *
+ * 第 1 部分:纯逻辑
+ * ================================================================== */
+console.log('【1】增量规整器:前缀不变式');
+
+/** 把一串输入分片喂给规整器,返回逐片输出 */
+const feed = (chunks) => {
+  const normalizer = createIncrementalMarkdownNormalizer();
+  const out = [];
+  for (const chunk of chunks) out.push(normalizer.push(chunk));
+  return { out, normalizer };
+};
+
+/**
+ * 不变式(流中):**已发出的文本必须是「规整后全文」的前缀**,
+ * 且未发出的差额只能是「**给当前行准备的空行分隔符(可能 1~2 个 \n)+ 当前行的尾巴**」——
+ * 也就是「为了判断该行是不是列表项而短暂扣住的一小段」,行尾一到就释放。
+ *
+ * 这个性质正好保证 `answer_end` 的「整块替换」= 纯尾缀追加(不会整段重建)。
+ */
+const isPrefixWithHeldLineTail = (got, want) => {
+  if (!want.startsWith(got)) return false;
+  const held = want.slice(got.length).replace(/^\n+/, ''); // 去掉随行一起扣住的前置空行
+  return !held.includes('\n');
+};
+
+/** 不变式:每喂一片就核对一次 */
+const checkInvariant = (label, chunks, { finish = false } = {}) => {
+  const normalizer = createIncrementalMarkdownNormalizer();
+  let want = '';
+  let got = '';
+  let ok = true;
+  let at = 0;
+  for (let i = 0; i < chunks.length; i++) {
+    want += chunks[i];
+    got += normalizer.push(chunks[i]);
+    if (!isPrefixWithHeldLineTail(got, normalizeChatMarkdown(want))) {
+      ok = false;
+      at = i;
+      break;
+    }
+  }
+  if (ok && finish) {
+    got += normalizer.finish();
+    if (got !== normalizeChatMarkdown(want)) ok = false;
+  }
+  check(
+    `不变式:${label}`,
+    ok,
+    ok ? '' : { 第几片: at, 期望: normalizeChatMarkdown(want).slice(-40), 实际: got.slice(-40) }
+  );
+  return ok;
+};
+
+checkInvariant('整段一次 push', ['第一行\n第二行\n第三行']);
+checkInvariant('逐字 push(含换行)', [...'第一行\n第二行\n1. a\n2. b']);
+checkInvariant('双换行(空行)', ['段落一\n\n段落二']);
+checkInvariant('连续有序列表不插空行', ['1. 甲\n2. 乙\n3. 丙']);
+checkInvariant('连续无序列表不插空行', ['- 甲\n- 乙']);
+checkInvariant('列表后接说明行', ['- 甲\n- 乙\n以上就是全部']);
+checkInvariant('伪列表符(2倍)不误判', ['2倍\n3. 真列表']);
+checkInvariant('行首恰为列表前缀(- )', ['前一行\n- ']);
+checkInvariant('行首数字未定型(12)', ['前一行\n12']);
+checkInvariant('行首数字带点(12.)', ['前一行\n12.']);
+checkInvariant('末尾无换行', ['段一\n段二(未完']);
+checkInvariant('以换行结尾', ['段一\n']);
+checkInvariant('空串与纯空白', ['', ' ', '\n', '  \n']);
+checkInvariant('逐字 push 且收尾 finish', [...'甲\n1. 乙\n丙'], { finish: true });
+
+// 逐字 push 与整段 push 的「拼接结果」必须一致(都 finish 掉扣住的尾巴后比)
+{
+  const text = '第一段\n第二段\n1. 甲\n2. 乙\n收尾说明';
+  const wholeFeed = feed([text]);
+  wholeFeed.out.push(wholeFeed.normalizer.finish());
+  const perCharFeed = feed([...text]);
+  perCharFeed.out.push(perCharFeed.normalizer.finish());
+  const whole = wholeFeed.out.join('');
+  const perChar = perCharFeed.out.join('');
+  check('逐字 push 与整段 push 输出一致', whole === perChar, { whole, perChar });
+  check('两者的拼接都等于整段规整结果', whole === normalizeChatMarkdown(text));
+}
+
+// 种子化伪随机模糊(≥500 轮)
+{
+  let seed = 20260920;
+  const rand = () => {
+    seed = (seed * 1103515245 + 12345) % 2147483648;
+    return seed / 2147483648;
+  };
+  const alphabet = ['甲', '乙', '丙', '\n', '\n\n', '- ', '* ', '1', '2', '.', ')', ' ', '。', '12', '-'];
+  let bad = null;
+  for (let round = 0; round < 500 && !bad; round++) {
+    const chunks = [];
+    const count = 1 + Math.floor(rand() * 20);
+    for (let i = 0; i < count; i++) chunks.push(alphabet[Math.floor(rand() * alphabet.length)]);
+    const normalizer = createIncrementalMarkdownNormalizer();
+    let want = '';
+    let got = '';
+    for (const chunk of chunks) {
+      want += chunk;
+      got += normalizer.push(chunk);
+      if (!isPrefixWithHeldLineTail(got, normalizeChatMarkdown(want))) {
+        bad = { round, chunks, want, got };
+        break;
+      }
+    }
+    if (!bad) {
+      got += normalizer.finish();
+      if (got !== normalizeChatMarkdown(want)) bad = { round, chunks, want, got, finish: true };
+    }
+  }
+  check('种子化模糊 500 轮(含 finish)不变式恒成立', !bad, bad);
+}
+
+// 扣住的部分必须「有界」:只是当前行的一小段,不能无限积压
+{
+  const normalizer = createIncrementalMarkdownNormalizer();
+  let shown = '';
+  let want = '';
+  for (const chunk of ['第一段\n', '第二段\n', '1', '.', ' ', '甲', '\n', '2', '.', ' ', '乙']) {
+    want += chunk;
+    shown += normalizer.push(chunk);
+    const held = normalizeChatMarkdown(want).slice(shown.length).replace(/^\n+/, '');
+    if (held.includes('\n') || held.length > 8) {
+      check(`扣住有界(本条 ${JSON.stringify(chunk)})`, false, { held, shown });
+      break;
+    }
+  }
+  check('扣住的只是「可能成为列表项」的短前缀(本例 ≤8 字符且不含换行)', true);
+}
+
+console.log('\n【2】流式事件解析(形状不符 → null 走旧路径)');
+{
+  const start = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', sequence: 0 }, 'answer_start');
+  check('合法的 answer_start', start?.kind === 'start' && start.streamId === 's1' && start.target === 'summary', start);
+
+  check('缺 stream_id → null', parseAnswerStreamEvent({ target: 'summary' }, 'answer_start') === null);
+  check('空 stream_id → null', parseAnswerStreamEvent({ stream_id: '  ', target: 'summary' }, 'answer_delta') === null);
+  check('target 非法 → null', parseAnswerStreamEvent({ stream_id: 's1', target: 'other' }, 'answer_delta') === null);
+  check('非对象 → null', parseAnswerStreamEvent(null, 'answer_delta') === null && parseAnswerStreamEvent([1], 'answer_delta') === null);
+
+  const delta = parseAnswerStreamEvent({ stream_id: 's1', target: 'answer', sequence: '3', text: 123 }, 'answer_delta');
+  check('sequence 宽松(字符串也认)', delta?.sequence === 3, delta);
+  check('text 非字符串按空串', delta?.text === '', delta);
+
+  const end = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', text: 'x', status: '怪值' }, 'answer_end');
+  check('status 非法 → fallback', end?.kind === 'end' && end.status === 'fallback', end);
+  const endOk = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', text: 'x', status: 'completed' }, 'answer_end');
+  check('status=completed 保留', endOk?.status === 'completed');
+
+  const abort = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', reason: '怪值' }, 'answer_abort');
+  check('reason 非法 → generation_failed', abort?.kind === 'abort' && abort.reason === 'generation_failed', abort);
+  const abortOk = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', reason: 'not_finalized' }, 'answer_abort');
+  check('reason=not_finalized 保留', abortOk?.reason === 'not_finalized');
+
+  check('未知事件名 → null', parseAnswerStreamEvent({ stream_id: 's1', target: 'summary' }, 'answer_what') === null);
+}
+
+console.log('\n【3】流状态机');
+{
+  const run = (events) => {
+    let state = createAnswerStreamState();
+    const effects = [];
+    for (const ev of events) {
+      const r = applyAnswerStreamEvent(state, ev);
+      state = r.state;
+      effects.push(r.effect.kind);
+    }
+    return { state, effects };
+  };
+  const ev = (kind, extra = {}) => ({ kind, streamId: 's1', target: 'summary', sequence: 0, text: '', ...extra });
+
+  // 正常流
+  {
+    const { state, effects } = run([
+      ev('start', { sequence: 0 }),
+      ev('delta', { sequence: 1, text: '你好' }),
+      ev('delta', { sequence: 2, text: '世界' }),
+      ev('end', { sequence: 2, text: '你好,世界' }),
+    ]);
+    check('正常流:效果序列 start→append→append→replace', JSON.stringify(effects) === JSON.stringify(['start', 'append', 'append', 'replace']), effects);
+    check('正常流:end 后 text = 最终全文(整块替换,不拼接)', state.text === '你好,世界' && state.completed === true, state);
+  }
+
+  // 序号缺口
+  {
+    const { state, effects } = run([
+      ev('start'),
+      ev('delta', { sequence: 1, text: 'a' }),
+      ev('delta', { sequence: 5, text: 'b' }),
+    ]);
+    check('序号缺口:仍拼接、hasGap 置位', state.text === 'ab' && state.hasGap === true, state);
+    check('缺口不阻断渲染(仍是 append)', effects[2] === 'append');
+  }
+
+  // 重复序号
+  {
+    const { state } = run([ev('start'), ev('delta', { sequence: 1, text: 'a' }), ev('delta', { sequence: 1, text: 'XX' })]);
+    check('重复序号忽略(不重复拼接)', state.text === 'a', state);
+  }
+
+  // 过期流
+  {
+    const { state, effects } = run([
+      ev('start'),
+      ev('delta', { streamId: 'other', sequence: 1, text: 'X' }),
+      ev('end', { streamId: 'other', text: 'X' }),
+      ev('abort', { streamId: 'other' }),
+    ]);
+    check('过期 streamId 的 delta/end/abort 全部 none', effects.slice(1).every((k) => k === 'none'), effects);
+    check('过期事件不改状态', state.text === '' && state.completed === false, state);
+  }
+
+  // end 之后的 delta
+  {
+    const { state, effects } = run([ev('start'), ev('end', { text: '定稿' }), ev('delta', { sequence: 9, text: '迟到' })]);
+    check('end 之后的 delta 忽略', effects[2] === 'none' && state.text === '定稿', state);
+  }
+
+  // abort 复位 + 重新 start
+  {
+    const { state, effects } = run([
+      ev('start'),
+      ev('delta', { sequence: 1, text: '草稿' }),
+      ev('abort', { reason: 'generation_failed' }),
+      ev('start', { streamId: 's2' }),
+      ev('delta', { streamId: 's2', sequence: 1, text: '新正文' }),
+    ]);
+    check('abort 复位(discard 效果 + 状态清空后可再 start)', effects.includes('discard') && state.text === '新正文' && state.streamId === 's2', { effects, state });
+  }
+
+  // end 覆盖草稿(fallback 也替换)
+  {
+    const { state } = run([
+      ev('start'),
+      ev('delta', { sequence: 1, text: '草稿' }),
+      ev('end', { sequence: 1, text: '最终', status: 'fallback' }),
+    ]);
+    check('fallback 的 end 同样整块替换', state.text === '最终' && state.completed === true, state);
+  }
+
+  check('null 事件 → none 且状态不变', (() => {
+    const s0 = createAnswerStreamState();
+    const r = applyAnswerStreamEvent(s0, null);
+    return r.effect.kind === 'none' && r.state === s0;
+  })());
+}
+
+console.log('\n【3b】应用层的内容对齐(end 替换 / abort 撤稿)');
+{
+  const BASE = '<scope title="思考中">\n</scope>\n';
+  check(
+    '正常路径:最终文本是已上屏内容的前缀延伸 → 纯尾缀追加(不加空行、不重建)',
+    applyAnswerStreamEnd(BASE + '一、说明', BASE, '一、说明。二、补充') === BASE + '一、说明。二、补充'
+  );
+  check(
+    '已上屏一长段、最终只多了尾巴 → 不重复整段',
+    applyAnswerStreamEnd(BASE + '甲', BASE, '甲乙丙') === BASE + '甲乙丙'
+  );
+  check(
+    '罕见分歧(最终文本与增量拼接不一致)→ 整块重建,且基线里的 <scope> 被剥掉(避免卡片闪现)',
+    applyAnswerStreamEnd(BASE + '草稿', BASE, '完全不同的最终文本') === '完全不同的最终文本'
+  );
+  check(
+    '重建时**基线(流开始前就有的内容)保留**,最终文本接在其后 —— 只有 scope 标记会被剥掉',
+    applyAnswerStreamEnd('前言', '前言', '最终') === '前言最终'
+  );
+  check('abort 撤稿:回到流开始前的内容', applyAnswerStreamAbort(BASE) === BASE);
+  check('abort 撤稿:空基线返回空串', applyAnswerStreamAbort('') === '');
+
+  // answer 开场概述该不该显示(实测:后端把正文作为 summary 流式下发,
+  // 而 answer 常是同一段开头的**压缩版**——显示会重复开头;但万一是另一段内容就不能丢)
+  const body =
+    '我们青浦目前能对上的小微企业扶持,主要分两类:一类是面向特定对象的创业扶持(如带动就业补贴、贷款贴息、留创企业开办资助),另一类是市级中小企业公共资助项目;此外还有几款“小微贷”金融产品。\n\n### 具体政策\n- 创业扶持:带动就业补贴';
+  // 压缩版的形态与实测一致:**截断在词中间**(正文接着 '###',概述结尾是半个词 '部分候')
+  const abridged = body.slice(0, 70).replace(/\n+/g, ' ') + ' 部分候…';
+  check('压缩版开场(截断在词中间,公共前缀 ≥90%)→ 不显示(避免开头重复)', shouldKeepAnswerOverview(abridged, body) === false, abridged);
+  check('真正的另一段内容(不是前缀)→ 必须保留', shouldKeepAnswerOverview('这是另一段完全不同的开场说明。', body) === true);
+  check('概述为空 → 不显示', shouldKeepAnswerOverview('', body) === false && shouldKeepAnswerOverview(null, body) === false);
+  check('流式正文为空 → 保留概述(不能丢字)', shouldKeepAnswerOverview('开场', '') === true);
+}
+
+console.log(`\n===== 纯逻辑部分:通过 ${pass} 项,失败 ${fail} 项 =====`);
+
+/* ================================================================== *
+ * 第 2 部分:协调器级假流(假 SSE 喂真实协调器)
+ * ================================================================== */
+
+const sse = (events) =>
+  events
+    .map(
+      ([event, data]) =>
+        `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`
+    )
+    .join('');
+
+const stubFetchWith = (sseText) => {
+  globalThis.fetch = async () => {
+    const stream = new ReadableStream({
+      start(controller) {
+        controller.enqueue(new TextEncoder().encode(sseText));
+        controller.close();
+      },
+    });
+    return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
+  };
+};
+
+/** 跑一轮,收集各类事件 */
+const runTurn = async (events) => {
+  stubFetchWith(sse(events));
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  const seen = { messages: [], total: null, streamStart: [], streamEnd: [], streamAbort: [], errors: [], order: [] };
+  coordinator.addEventListener('message', (m) => {
+    seen.messages.push(m);
+    seen.order.push(`message:${m.slice(0, 24)}`);
+  });
+  coordinator.addEventListener('totalResponse', (p) => {
+    seen.total = p;
+  });
+  coordinator.addEventListener('answerStreamStart', (p) => {
+    seen.streamStart.push(p);
+    seen.order.push('streamStart');
+  });
+  coordinator.addEventListener('answerStreamEnd', (p) => {
+    seen.streamEnd.push(p);
+    seen.order.push('streamEnd');
+  });
+  coordinator.addEventListener('answerStreamAbort', (p) => {
+    seen.streamAbort.push(p);
+    seen.order.push('streamAbort');
+  });
+  coordinator.addEventListener('error', (p) => {
+    seen.errors.push(p);
+    seen.order.push('error');
+  });
+  await coordinator.generateAnswer('测试问题');
+  await new Promise((resolve) => setTimeout(resolve, 80)); // flushContent 会等两帧
+  return seen;
+};
+
+// 应用侧就是 `content += msg`(见 appendAiMessageChunk),**没有分隔符** ——
+// 用 '\n\n' 拼会把流式增量切成一堆段落,断言就失真了
+const messageText = (seen) => seen.messages.join('');
+const countOf = (text, needle) => text.split(needle).length - 1;
+
+const DELTA_TEXTS = ['一、', '第一段说明', '。\n\n', '二、', '第二段说明', '。'];
+const FINAL_TEXT = '一、第一段说明。\n\n二、第二段说明。';
+const MARKER = '第一段说明';
+
+console.log('\n【4】协调器假流:happy path(带卡片轮形态,含流中 heartbeat)');
+{
+  const seen = await runTurn([
+    ['accepted', { message: '收到' }],
+    ['progress', { stage: 'retrieve', message: '正在检索……' }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0, provisional: true }],
+    ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['heartbeat', { status: 'processing', message: '仍在处理……', elapsed_seconds: 20 }],
+    ['answer', { text: FINAL_TEXT }],
+    ['source', { id: 's1', title: '测试政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    // 有 item 才有 POLICY_TABLE 卡片块(buildPolicyTableContent 是按 items 建的)
+    ['item', { source_id: 's1', title: '测试政策', card: { name: { text: '测试政策' } } }],
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, operation: 'replace', status: 'completed', provisional: false }],
+    ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
+    ['result', { response: FINAL_TEXT, recommendation: {}, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+
+  const text = messageText(seen);
+  if (process.env.DBG) console.log('[dbg] text=' + JSON.stringify(text));
+  check('answerStreamStart 发出一次', seen.streamStart.length === 1, seen.streamStart);
+  check('answerStreamEnd 发出一次', seen.streamEnd.length === 1, seen.streamEnd);
+  check('无 abort', seen.streamAbort.length === 0, seen.streamAbort);
+  check('scope 卡片只发了一次(流中 heartbeat 不再发)', countOf(text, '<scope') === 1, countOf(text, '<scope'));
+  check('answer_end 的文本是规整后的全文', seen.streamEnd[0]?.text === normalizeChatMarkdown(FINAL_TEXT), seen.streamEnd[0]?.text);
+  check(
+    '正文在 message 通道**只出现一次**(完整 answer/summary 事件没有渲染成第二份)',
+    countOf(text, MARKER) === 1,
+    countOf(text, MARKER)
+  );
+  check(
+    '流式增量拼接 === 规整后全文(前缀不变式在真链路成立)',
+    text.includes(normalizeChatMarkdown(FINAL_TEXT).split('\n')[0]),
+    normalizeChatMarkdown(FINAL_TEXT).slice(0, 30)
+  );
+  check('totalResponse.answer 以最终全文开头', String(seen.total?.answer || '').startsWith('一、第一段说明。'), String(seen.total?.answer || '').slice(0, 40));
+  check('totalResponse 里含政策卡片与参考资料', String(seen.total?.answer || '').includes('POLICY_TABLE') && String(seen.total?.answer || '').includes('<ref_links>'));
+  check('卡片/参考资料是在流结束之后补发的', text.indexOf('POLICY_TABLE') > text.indexOf(MARKER));
+}
+
+console.log('\n【5】协调器假流:纯正文轮(无卡片、无 answer 事件)不丢正文');
+{
+  const seen = await runTurn([
+    ['accepted', { message: '收到' }],
+    ['answer_start', { stream_id: 's2', target: 'summary', sequence: 0 }],
+    ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's2', target: 'summary', sequence: i + 1, text }]),
+    ['answer_end', { stream_id: 's2', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
+    ['summary', { text: FINAL_TEXT }],
+    ['result', { response: FINAL_TEXT }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('totalResponse.answer === 最终全文(没有因为"没有卡片"而丢正文)', String(seen.total?.answer || '').trim() === normalizeChatMarkdown(FINAL_TEXT).trim(), String(seen.total?.answer || ''));
+}
+
+console.log('\n【6】协调器假流:answer_end 与增量拼接不同 → 整块替换生效');
+{
+  const seen = await runTurn([
+    ['answer_start', { stream_id: 's3', target: 'summary', sequence: 0 }],
+    ['answer_delta', { stream_id: 's3', target: 'summary', sequence: 1, text: '草稿内容' }],
+    ['answer_end', { stream_id: 's3', target: 'summary', sequence: 1, text: '定稿内容(后端最终校验改过)', status: 'completed' }],
+    ['summary', { text: '定稿内容(后端最终校验改过)' }],
+    ['result', { response: '定稿内容(后端最终校验改过)' }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('End.text = 规整后的最终文本(不是增量拼接)', seen.streamEnd[0]?.text === '定稿内容(后端最终校验改过)', seen.streamEnd[0]?.text);
+  check('totalResponse.answer 用的是最终文本', String(seen.total?.answer || '').includes('定稿内容'), String(seen.total?.answer || ''));
+}
+
+console.log('\n【7】协调器假流:后端 error → 撤草稿(abort 先于 error)');
+{
+  const seen = await runTurn([
+    ['answer_start', { stream_id: 's4', target: 'summary', sequence: 0 }],
+    ['answer_delta', { stream_id: 's4', target: 'summary', sequence: 1, text: '半截草稿' }],
+    ['error', { code: 'processing_failed' }],
+  ]);
+  const abortAt = seen.order.indexOf('streamAbort');
+  const errorAt = seen.order.indexOf('error');
+  check('abort 在 error **之前**发出(否则 hook 已摘任务、abort 会被守卫丢弃)', abortAt > -1 && abortAt < errorAt, seen.order);
+  check('abort 的 reason = generation_failed', seen.streamAbort[0]?.reason === 'generation_failed', seen.streamAbort[0]);
+  check('草稿文本未进入 totalResponse', !String(seen.total?.answer || '').includes('半截草稿'), String(seen.total?.answer || ''));
+}
+
+console.log('\n【8】协调器假流:断流(EOF 无 done)→ 撤草稿 + 兼容回退');
+{
+  const seen = await runTurn([
+    ['answer_start', { stream_id: 's5', target: 'summary', sequence: 0 }],
+    ['answer_delta', { stream_id: 's5', target: 'summary', sequence: 1, text: '半截草稿' }],
+    ['answer', { text: '完整事件里的正文(断流前的兼容副本)' }],
+  ]);
+  check('断流时发出 abort(reason=not_finalized)', seen.streamAbort[0]?.reason === 'not_finalized', seen.streamAbort);
+  // ⚠️ 分层:协调器**只发信号**,真正把草稿从界面上撤掉的是应用层(hook 调 applyAnswerStreamAbort)。
+  // 断流路径也不发 totalResponse(既有行为:内容走 message 通道,hook 的 close 监听负责补写 DMS)。
+  check('兼容路径的完整 answer 事件仍进了 message 通道', messageText(seen).includes('完整事件里的正文'), messageText(seen).slice(0, 80));
+  check('报的是连接断开', seen.errors.some((e) => e?.code === 'disconnected'), seen.errors);
+}
+
+console.log('\n【9】协调器假流:旧后端(无任何流式事件)行为不变');
+{
+  const seen = await runTurn([
+    ['accepted', { message: '收到' }],
+    ['progress', { stage: 'retrieve', message: '正在检索……' }],
+    ['answer', { text: FINAL_TEXT }],
+    ['source', { id: 's1', title: '测试政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['summary', { text: FINAL_TEXT }],
+    ['result', { response: FINAL_TEXT }],
+    ['done', { status: 'completed' }],
+  ]);
+  const text = messageText(seen);
+  check('没有流事件、没有 abort', seen.streamStart.length === 0 && seen.streamAbort.length === 0);
+  check('scope 卡片正常', countOf(text, '<scope') === 1);
+  check('正文按原路径整段输出(规整后)', text.includes(normalizeChatMarkdown(FINAL_TEXT)));
+  check('顺序仍是 正文 → 卡片 → 参考资料', text.indexOf('正文') === -1 || text.indexOf('第一段说明') < text.indexOf('POLICY_TABLE') || text.indexOf('POLICY_TABLE') === -1);
+}
+
+console.log('\n【10】协调器:流式轮之后跑旧轮,状态不残留');
+{
+  stubFetchWith(
+    sse([
+      ['answer_start', { stream_id: 's6', target: 'summary', sequence: 0 }],
+      ['answer_delta', { stream_id: 's6', target: 'summary', sequence: 1, text: '流式正文' }],
+      ['answer_end', { stream_id: 's6', target: 'summary', sequence: 1, text: '流式正文', status: 'completed' }],
+      ['done', { status: 'completed' }],
+    ])
+  );
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  const rounds = [];
+  coordinator.addEventListener('message', (m) => rounds.push(m));
+  await coordinator.generateAnswer('第一轮');
+  await new Promise((r) => setTimeout(r, 80));
+  const cutoff = rounds.length; // 第一轮结束时的分界
+
+  stubFetchWith(
+    sse([
+      ['progress', { stage: 'retrieve', message: '正在检索……' }],
+      ['answer', { text: '第二轮正文' }],
+      ['done', { status: 'completed' }],
+    ])
+  );
+  await coordinator.generateAnswer('第二轮');
+  await new Promise((r) => setTimeout(r, 80));
+
+  const first = rounds.slice(0, cutoff).join('');
+  const second = rounds.slice(cutoff).join('');
+  check('第一轮是流式正文', first.includes('流式正文'), first.slice(0, 60));
+  check('第二轮回到旧路径:有 scope、正文是整段输出', countOf(second, '<scope') === 1 && second.includes('第二轮正文'), second.slice(0, 80));
+}
+
+console.log('\n【11】两个协调器并行流式:互不串台');
+{
+  stubFetchWith(
+    sse([
+      ['answer_start', { stream_id: 'A', target: 'summary', sequence: 0 }],
+      ['answer_delta', { stream_id: 'A', target: 'summary', sequence: 1, text: '甲会话正文' }],
+      ['answer_end', { stream_id: 'A', target: 'summary', sequence: 1, text: '甲会话正文', status: 'completed' }],
+      ['done', { status: 'completed' }],
+    ])
+  );
+  const a = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 'A' });
+  const b = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 'B' });
+  const gotA = [];
+  const gotB = [];
+  a.addEventListener('message', (m) => gotA.push(m));
+  b.addEventListener('message', (m) => gotB.push(m));
+  b.addEventListener('answerStreamAbort', () => gotB.push('ABORT'));
+
+  await a.generateAnswer('问 A');
+  await new Promise((r) => setTimeout(r, 80));
+  check('B 未收到任何事件(含 abort)', gotB.length === 0, gotB);
+  check('A 正常收到正文', gotA.join('').includes('甲会话正文'), gotA);
+}
+
+console.log(`\n===== 总计:通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 3 - 0
src/components/Chat/BusinessRecord.vue

@@ -935,6 +935,9 @@ const hasUnfinishedRevealRows = computed(() =>
 const shouldDriveTypewriter = computed(
   () =>
     canAnimateAiText.value &&
+    // F073:正文本身是真实增量流式到达的,再逐字重放等于凭空多等一遍 —— 关掉打字机
+    // (TextContent 的 `instant || !activeTypewriter` 分支会转成即时渲染)
+    !record.value.contentStreaming &&
     (activeRecordState.value !== MessageState.Finish || hasUnfinishedRevealRows.value)
 );
 const isSessionReadableLogButtonEnabled =

+ 437 - 0
src/components/answer-stream.ts

@@ -0,0 +1,437 @@
+/**
+ * 正文真实增量流式输出(F073)的**纯逻辑层**。
+ *
+ * 契约见 `harness/docs/reference/token-streaming.md`:后端对 `/api/chat` 增加
+ * `answer_start / answer_delta / answer_end / answer_abort` 四个事件,正文逐片段到达。
+ *
+ * 本文件放三样东西(**不 import vue、不发网络请求**,可被 harness 的 esbuild 直接打包断言):
+ * 1. `parseAnswerStreamEvent` —— 事件形状解析(容忍缺字段,形状不符返回 null 走旧路径)
+ * 2. `applyAnswerStreamEvent` —— 状态机:把事件折算成「状态 + 效果」,副作用交给协调器执行
+ * 3. `createIncrementalMarkdownNormalizer` —— **增量规整器**(见下)
+ *
+ * 另把原本写在协调器里的 `normalizeChatMarkdown` / `LIST_ITEM_RE` 移到这里
+ * (协调器改为从这里 re-export,对外 API 不变)。
+ */
+
+/* ------------------------------------------------------------------ *
+ * markdown 规整(原先在 api-chat-coordinator.ts)
+ * ------------------------------------------------------------------ */
+
+export const LIST_ITEM_RE = /^\s*(?:[-*+]|\d+[.)])\s+\S/;
+
+/**
+ * 规整 markdown 换行。
+ *
+ * 正文块用的是 marked + `white-space: normal`,单个 \n 会被当成软换行直接折叠,
+ * 于是"列表后紧跟的说明行"会被并进上一个列表项(表现为多段挤成一段)。
+ * 这里把非列表项之间的单换行提升为空行,让它成为独立段落;
+ * 连续列表项之间仍保留单换行,避免打断有序列表的编号。
+ */
+export function normalizeChatMarkdown(text: string): string {
+  const value = String(text || '');
+  if (!value.includes('\n')) {
+    return value;
+  }
+
+  const lines = value.split('\n');
+  const result: string[] = [lines[0]];
+  for (let i = 1; i < lines.length; i++) {
+    const prev = lines[i - 1];
+    const line = lines[i];
+    const prevBlank = prev.trim() === '';
+    const lineBlank = line.trim() === '';
+    const tightList = LIST_ITEM_RE.test(prev) && LIST_ITEM_RE.test(line);
+
+    if (!prevBlank && !lineBlank && !tightList) {
+      result.push('');
+    }
+    result.push(line);
+  }
+  return result.join('\n');
+}
+
+/**
+ * **增量规整器**:把逐片段到达的原始正文,变成「与 `normalizeChatMarkdown(全文)`
+ * 逐步同前缀」的增量文本。
+ *
+ * 为什么需要:上面那个规整函数是**插入**空行的(不是清洗),所以「把 delta 直接拼起来」
+ * 与「整段规整后的结果」在**多段落回答上必然不同** → `answer_end` 的整块替换会与已流文本
+ * 分歧 → 应用层只能整段重建 → `<scope>` 标记被重解析 → 思考中卡片闪现并重播退场动画。
+ *
+ * 做法(每处插入决策只依赖相邻两行,且行的判定在该行结束(收到 \n)时才稳定):
+ * - 已经结束的行:立即按完整决策发出(含它前面该不该插空行)
+ * - 当前**未结束**的行:先扣住;只有当能判定它「已经是列表项前缀」或「不可能成为列表项」
+ *   才释放(扣住的长度最多几字符)
+ *
+ * 不变式:**任一时刻 `已发出的全部文本 === normalizeChatMarkdown(已输入的全部文本)`**。
+ */
+export interface IncrementalMarkdownNormalizer {
+  /** 输入一段原始增量,返回应立即展示的规整增量(可能为空串——还在扣前缀) */
+  push(text: string): string;
+  /** 收尾:释放仍在扣住的部分(EOF / 正常结束前调用;end 用最终全文覆盖,故非必须) */
+  finish(): string;
+}
+
+/** 判断 `line` 是否**已能确定**是列表项(正则无 `$` 锚,命中即不会再被后续字符推翻) */
+const isListItemSoFar = (line: string): boolean => LIST_ITEM_RE.test(line);
+
+/** 判断 `line` 是否**还可能**变成列表项(是则不能提前释放) */
+const couldBecomeListItem = (line: string): boolean =>
+  /^\s*$/.test(line) || // 只有空白,后面可能跟 - 或数字
+  /^\s*[-*+]\s*$/.test(line) ||
+  /^\s*\d+$/.test(line) ||
+  /^\s*\d+[.)]\s*$/.test(line);
+
+export const createIncrementalMarkdownNormalizer = (): IncrementalMarkdownNormalizer => {
+  /** 已发出的全部文本 */
+  let out = '';
+  /** 上一整行(用于判断「本行前是否要插空行」) */
+  let prevLine: string | null = null;
+  /** 当前输入行(可能还没结束) */
+  let pendingLine = '';
+  /** 当前行的「空行分隔 + 内容」是否已发出(若已发出,后续字符直接续写) */
+  let lineOpen = false;
+  /** 是否收到过任何输入(空输入不该在 finish 里凭空产出空行) */
+  let received = false;
+
+  /** 判断两行之间是否需要插入空行(与 normalizeChatMarkdown 同一判据) */
+  const needsBlankBetween = (prev: string, line: string): boolean => {
+    const prevBlank = prev.trim() === '';
+    const lineBlank = line.trim() === '';
+    const tightList = LIST_ITEM_RE.test(prev) && LIST_ITEM_RE.test(line);
+    return !prevBlank && !lineBlank && !tightList;
+  };
+
+  /**
+   * 开出一行:先补「需要的话多一个 \n」(即插入空行),再写内容。
+   * ⚠️ 换行是**行与行之间的分隔符**,所以行末的 `\n` 由「下一行出现」或
+   * 「输入里的 `\n`」来补,不能在这一行结束时就直接加上一个(否则末行会多出换行,
+   * 与 `normalizeChatMarkdown` 的结果不一致)。
+   */
+  const openLine = (line: string): void => {
+    if (prevLine !== null && needsBlankBetween(prevLine, line)) {
+      out += '\n';
+    }
+    out += line;
+    lineOpen = true;
+  };
+
+  return {
+    push(text: string): string {
+      const value = String(text ?? '');
+      if (!value) return '';
+      received = true;
+
+      const before = out.length;
+      let rest = value;
+      while (rest) {
+        const nl = rest.indexOf('\n');
+        if (nl < 0) {
+          // 没有换行:本行还没结束
+          if (lineOpen) {
+            out += rest; // 已开出,直接续写
+          } else {
+            pendingLine += rest;
+            // 能判定就提前释放(扣住的只是「可能变成列表项」的短前缀)
+            if (pendingLine && (isListItemSoFar(pendingLine) || !couldBecomeListItem(pendingLine))) {
+              openLine(pendingLine);
+            }
+          }
+          rest = '';
+          break;
+        }
+
+        // 遇到换行:本行到此结束
+        const piece = rest.slice(0, nl);
+        if (lineOpen) {
+          out += piece;
+        } else {
+          pendingLine += piece;
+          openLine(pendingLine);
+        }
+        out += '\n'; // 这一行与下一行之间的分隔符
+        prevLine = pendingLine;
+        pendingLine = '';
+        lineOpen = false;
+        rest = rest.slice(nl + 1);
+      }
+
+      return out.slice(before);
+    },
+
+    finish(): string {
+      if (!received) return '';
+      const before = out.length;
+      // 输入末尾没有换行的那一行(或「输入以 \n 结尾」时那个尾部空行)
+      if (!lineOpen) {
+        openLine(pendingLine);
+        pendingLine = '';
+      }
+      return out.slice(before);
+    },
+  };
+};
+
+/* ------------------------------------------------------------------ *
+ * 应用层的内容对齐(纯函数,供 useBusinessAssistantChat 调用)
+ * ------------------------------------------------------------------ */
+
+/**
+ * `answer_end` 到了:把已上屏的内容对齐到最终全文。
+ *
+ * 正常路径是**纯尾缀追加**(增量规整器保证了「已上屏的 = 最终全文的逐步前缀」),
+ * 界面零抖动。罕见情况下后端最终文本与增量拼接不一致 → 整块重建:
+ * 基线里可能含 `<scope>` 进度标记,重解析会让「思考中」卡片闪现并重播退场动画,
+ * 所以重建时先把这段剥掉(正文内容不受影响)。
+ */
+export const applyAnswerStreamEnd = (
+  content: string,
+  baseText: string,
+  finalText: string
+): string => {
+  const streamed = String(content || '').slice(String(baseText || '').length);
+  if (finalText.startsWith(streamed)) {
+    return String(content || '') + finalText.slice(streamed.length);
+  }
+  const base = String(baseText || '').replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, '');
+  return base + finalText;
+};
+
+/**
+ * 流式轮里,`answer` 事件那段**开场概述**要不要展示?
+ *
+ * 实测(2026-09-20 两份真实载荷):后端把正文作为 `summary` 流式下发,而 `answer`
+ * 往往是**同一段开头的压缩版**(一份 134 字 vs 正文 2199 字,差别只是空白折叠),
+ * 显示它会出现"开头重复一遍"。所以判据是:**去掉空白后它是不是流式正文的前缀**——
+ * 是 → 压缩版,跳过;不是 → 是另一段内容,**必须保留**(宁可多一段,不能丢字)。
+ */
+export const shouldKeepAnswerOverview = (overview: string, streamBody: string): boolean => {
+  const collapsed = String(overview || '').replace(/\s+/g, '');
+  if (!collapsed) return false;
+  const body = String(streamBody || '').replace(/\s+/g, '');
+  if (!body) return true; // 正文为空:绝不能丢
+
+  // 判据:与正文的**公共前缀**占概述的比重。取 90% 是因为实测的压缩版是
+  // 「截断在词中间」的(正文接着 '###',而概述结尾是半个词 '部分候'),
+  // 严格前缀比较认不出来;而真·另一段内容的公共前缀通常只有几个字。
+  const max = Math.min(collapsed.length, body.length);
+  let common = 0;
+  while (common < max && collapsed[common] === body[common]) common++;
+  return common < collapsed.length * 0.9;
+};
+
+/**
+ * `answer_abort` / 断流:撤掉未定稿的草稿,回到流开始前的内容。
+ *
+ * ⚠️ 应用层做这一步时要**先置空再赋值**(两个 tick 内的连续赋值):
+ * 渲染层是按 content 增量解析行的,不先清空的话旧行会留在界面上。
+ */
+export const applyAnswerStreamAbort = (baseText: string): string => String(baseText || '');
+
+/* ------------------------------------------------------------------ *
+ * 事件解析
+ * ------------------------------------------------------------------ */
+
+export type AnswerStreamTarget = 'summary' | 'answer';
+export type AnswerAbortReason =
+  | 'generation_failed'
+  | 'regenerating'
+  | 'not_used'
+  | 'not_finalized';
+export type AnswerEndStatus = 'completed' | 'fallback';
+
+export type ParsedAnswerStreamEvent =
+  | { kind: 'start'; streamId: string; target: AnswerStreamTarget; sequence: number }
+  | { kind: 'delta'; streamId: string; target: AnswerStreamTarget; sequence: number; text: string }
+  | {
+      kind: 'end';
+      streamId: string;
+      target: AnswerStreamTarget;
+      sequence: number;
+      text: string;
+      status: AnswerEndStatus;
+    }
+  | {
+      kind: 'abort';
+      streamId: string;
+      target: AnswerStreamTarget;
+      sequence: number;
+      reason: AnswerAbortReason;
+    };
+
+const asRecord = (value: unknown): Record<string, unknown> | null =>
+  value && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+
+const pickString = (value: unknown): string | null => {
+  if (typeof value !== 'string') return null;
+  const trimmed = value.trim();
+  return trimmed ? trimmed : null;
+};
+
+const pickSequence = (value: unknown): number | null => {
+  if (typeof value === 'number' && Number.isFinite(value)) return value;
+  if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
+    return Number(value);
+  }
+  return null;
+};
+
+const TARGETS: AnswerStreamTarget[] = ['summary', 'answer'];
+const ABORT_REASONS: AnswerAbortReason[] = [
+  'generation_failed',
+  'regenerating',
+  'not_used',
+  'not_finalized',
+];
+
+/**
+ * 解析流式事件。
+ *
+ * 形状不符(`stream_id` 缺失/空、`target` 非法)→ 返回 null,协调器**不处理**该事件
+ * (正文会退回到原有的 answer/summary 完整事件路径,不会丢内容)。
+ * 其余字段宽松:`sequence` 缺失按 0,`text` 非字符串按空串,`status`/`reason` 非法取兜底。
+ */
+export function parseAnswerStreamEvent(
+  data: unknown,
+  eventName: string
+): ParsedAnswerStreamEvent | null {
+  const node = asRecord(data);
+  if (!node) return null;
+
+  const streamId = pickString(node.stream_id);
+  if (!streamId) return null;
+
+  const target = pickString(node.target) as AnswerStreamTarget | null;
+  if (!target || !TARGETS.includes(target)) return null;
+
+  const sequence = pickSequence(node.sequence) ?? 0;
+  const text = typeof node.text === 'string' ? node.text : '';
+
+  switch (eventName) {
+    case 'answer_start':
+      return { kind: 'start', streamId, target, sequence };
+    case 'answer_delta':
+      return { kind: 'delta', streamId, target, sequence, text };
+    case 'answer_end': {
+      const status = pickString(node.status) === 'completed' ? 'completed' : 'fallback';
+      return { kind: 'end', streamId, target, sequence, text, status };
+    }
+    case 'answer_abort': {
+      const raw = pickString(node.reason) as AnswerAbortReason | null;
+      const reason = raw && ABORT_REASONS.includes(raw) ? raw : 'generation_failed';
+      return { kind: 'abort', streamId, target, sequence, reason };
+    }
+    default:
+      return null;
+  }
+}
+
+/* ------------------------------------------------------------------ *
+ * 状态机
+ * ------------------------------------------------------------------ */
+
+export interface AnswerStreamState {
+  /** 当前活动流的 id;null = 本轮没有活动流(走旧路径) */
+  streamId: string | null;
+  target: AnswerStreamTarget | null;
+  /** 已消费的最大序号 */
+  lastSequence: number;
+  /** 是否出现过序号缺口(拼接不完整,等最终替换兜底) */
+  hasGap: boolean;
+  /** 是否已收到 answer_end(正文已定稿) */
+  completed: boolean;
+  /** 拼接草稿;completed 后即最终正文 */
+  text: string;
+}
+
+export const createAnswerStreamState = (): AnswerStreamState => ({
+  streamId: null,
+  target: null,
+  lastSequence: 0,
+  hasGap: false,
+  completed: false,
+  text: '',
+});
+
+export type AnswerStreamEffect =
+  | { kind: 'none' }
+  | { kind: 'start'; streamId: string; target: AnswerStreamTarget }
+  | { kind: 'append'; text: string; hasGap: boolean }
+  | { kind: 'replace'; streamId: string; target: AnswerStreamTarget; text: string; status: AnswerEndStatus }
+  | { kind: 'discard'; streamId: string; reason: AnswerAbortReason };
+
+/**
+ * 把事件折算成「新状态 + 要执行的效果」。规则(对应契约第 5 节):
+ *
+ * - **start**:任何 start 都重置为新流(含换 id 的新流)——契约「新建或清空对应的正文块」
+ * - **delta**:过期流 / 已完成流 / 已消费序号(`sequence <= lastSequence`)一律忽略;
+   **序号缺口**(`sequence > lastSequence + 1`)置 `hasGap` 但仍按序拼接(等最终替换兜底)
+ * - **end**:同 id 才处理;无论 `completed` 还是 `fallback` 都**整块替换**
+ * - **abort**:同 id 才处理;复位状态(丢弃临时正文)
+ */
+export function applyAnswerStreamEvent(
+  state: AnswerStreamState,
+  ev: ParsedAnswerStreamEvent | null
+): { state: AnswerStreamState; effect: AnswerStreamEffect } {
+  if (!ev) return { state, effect: { kind: 'none' } };
+
+  if (ev.kind === 'start') {
+    return {
+      state: {
+        streamId: ev.streamId,
+        target: ev.target,
+        lastSequence: ev.sequence,
+        hasGap: false,
+        completed: false,
+        text: '',
+      },
+      effect: { kind: 'start', streamId: ev.streamId, target: ev.target },
+    };
+  }
+
+  // 后面的三类都必须同 id(过期流的 end/delta/abort 一律忽略)
+  if (ev.streamId !== state.streamId) {
+    return { state, effect: { kind: 'none' } };
+  }
+
+  if (ev.kind === 'delta') {
+    if (state.completed) return { state, effect: { kind: 'none' } }; // end 之后的 delta 不认
+    if (ev.sequence <= state.lastSequence) return { state, effect: { kind: 'none' } }; // 已消费序号
+    const hasGap = state.hasGap || ev.sequence > state.lastSequence + 1;
+    return {
+      state: {
+        ...state,
+        lastSequence: ev.sequence,
+        hasGap,
+        text: state.text + ev.text,
+      },
+      effect: { kind: 'append', text: ev.text, hasGap },
+    };
+  }
+
+  if (ev.kind === 'end') {
+    return {
+      state: {
+        ...state,
+        completed: true,
+        lastSequence: ev.sequence,
+        text: ev.text,
+      },
+      effect: {
+        kind: 'replace',
+        streamId: ev.streamId,
+        target: ev.target,
+        text: ev.text,
+        status: ev.status,
+      },
+    };
+  }
+
+  // abort
+  return {
+    state: createAnswerStreamState(),
+    effect: { kind: 'discard', streamId: ev.streamId, reason: ev.reason },
+  };
+}

+ 137 - 38
src/components/api-chat-coordinator.ts

@@ -16,6 +16,9 @@
  *                          位置在**参考资料之前**;仅当后端判定问的是 CIPA /
  *                          海外服务平台 / 出海服务时为 true)
  * - answer / summary     → 纯文本
+ * - answer_start / delta / end / abort → answerStreamStart / **message(增量)** /
+ *   answerStreamEnd / answerStreamAbort(F073 正文真实增量流式;正文走 message 通道逐片段上屏,
+ *   结束后由应用侧按最终全文整块替换。见 ./answer-stream.ts)
  *
  * 正文与卡片在 done 时按原顺序一次性输出:新接口本身不逐字流式返回,
  * 这样处理期间中间位置只保留一张不断更新标题的 scope 卡片。
@@ -29,6 +32,19 @@ import mitt, { Emitter } from 'mitt';
 // 企业微信二维码:后端 cipa=true(问的是 CIPA / 海外服务平台 / 出海服务)时展示。
 // 走 Vite 资源导入 → 构建时变成带 hash 的产物地址,不要写死路径。
 import cipaQrcodeUrl from '@/assets/qiyeweixin.png';
+import {
+  applyAnswerStreamEvent,
+  createAnswerStreamState,
+  createIncrementalMarkdownNormalizer,
+  normalizeChatMarkdown,
+  parseAnswerStreamEvent,
+  shouldKeepAnswerOverview,
+  type AnswerAbortReason,
+  type AnswerEndStatus,
+  type AnswerStreamState,
+  type AnswerStreamTarget,
+  type IncrementalMarkdownNormalizer,
+} from './answer-stream';
 
 /* ------------------------------------------------------------------ *
  * 事件负载类型(全部字段可选,保证与后端降级/缺字段时兼容)
@@ -296,38 +312,10 @@ const SOURCE_TYPE_TEXT: Record<string, string> = {
   service_catalog: '服务目录',
 };
 
-const LIST_ITEM_RE = /^\s*(?:[-*+]|\d+[.)])\s+\S/;
-
-/**
- * 规整 markdown 换行。
- *
- * 正文块用的是 marked + `white-space: normal`,单个 \n 会被当成软换行直接折叠,
- * 于是"列表后紧跟的说明行"会被并进上一个列表项(表现为多段挤成一段)。
- * 这里把非列表项之间的单换行提升为空行,让它成为独立段落;
- * 连续列表项之间仍保留单换行,避免打断有序列表的编号。
- */
-export function normalizeChatMarkdown(text: string): string {
-  const value = String(text || '');
-  if (!value.includes('\n')) {
-    return value;
-  }
-
-  const lines = value.split('\n');
-  const result: string[] = [lines[0]];
-  for (let i = 1; i < lines.length; i++) {
-    const prev = lines[i - 1];
-    const line = lines[i];
-    const prevBlank = prev.trim() === '';
-    const lineBlank = line.trim() === '';
-    const tightList = LIST_ITEM_RE.test(prev) && LIST_ITEM_RE.test(line);
-
-    if (!prevBlank && !lineBlank && !tightList) {
-      result.push('');
-    }
-    result.push(line);
-  }
-  return result.join('\n');
-}
+// 规整 markdown 换行(含「列表后紧跟的说明行被并进上一条列表项」的修复)与
+// F073 的增量规整器,都搬到了 ./answer-stream(纯函数模块,便于单独断言)。
+// 这里保持对外 API 不变:
+export { normalizeChatMarkdown } from './answer-stream';
 
 const POLICY_TABLE_START = '<!-- POLICY_TABLE';
 const POLICY_TABLE_END = 'POLICY_TABLE -->';
@@ -802,6 +790,17 @@ export interface ApiChatTotalResponsePayload {
 }
 
 export type ApiChatEventMap = {
+  /** F073:正文真实增量流开始(后端 answer_start)——应用侧据此记录流式基线 */
+  answerStreamStart: { streamId: string; target: AnswerStreamTarget };
+  /** 正文定稿(后端 answer_end):`text` 是**已规整**的最终全文,应用侧整块替换 */
+  answerStreamEnd: {
+    streamId: string;
+    target: AnswerStreamTarget;
+    text: string;
+    status: AnswerEndStatus;
+  };
+  /** 撤去本轮临时正文(后端 answer_abort / 断流 / 出错) */
+  answerStreamAbort: { streamId: string; reason: AnswerAbortReason };
   /** 已翻译成原界面内容标记的文本 */
   message: string;
   error: ChatErrorPayload;
@@ -842,6 +841,11 @@ export class ApiChatCoordinator {
   private pendingCompanyInfo: unknown = null;
   /** 本轮是否需要展示企业微信二维码(result.cipa) */
   private pendingCipa = false;
+  /** F073 正文流状态(start/delta/end/abort 折算;见 answer-stream.ts) */
+  private answerStream: AnswerStreamState = createAnswerStreamState();
+  /** F073 增量规整器:让 delta 通道发出的内容恒等于最终文本的逐步前缀 */
+  private answerStreamNormalizer: IncrementalMarkdownNormalizer =
+    createIncrementalMarkdownNormalizer();
   /** 正文是否已输出(输出后不再发送 scope 进度卡片) */
   private contentFlushed = false;
   /** 最近一次补问的原始数据,供提交时反查候选用 */
@@ -1008,6 +1012,9 @@ export class ApiChatCoordinator {
     // 没有收到 done/error 就结束 → 连接中断。
     // 已到达的内容仍然展示,不因断流丢弃。
     if (this.generating && !this.doneReceived) {
+      // F073:未定稿的流式草稿要撤掉(契约:没有正常终止的 EOF 不能显示为成功答复),
+      // 之后的 flushContent 走兼容路径——完整 answer/summary 事件到达过的部分照常输出
+      this.discardActiveStream('not_finalized');
       await this.flushContent();
       this.failTurn(token, 'disconnected');
     }
@@ -1037,6 +1044,8 @@ export class ApiChatCoordinator {
     this.pendingInterrupt = null;
     this.pendingCompanyInfo = null;
     this.pendingCipa = false;
+    this.answerStream = createAnswerStreamState();
+    this.answerStreamNormalizer = createIncrementalMarkdownNormalizer();
     this.contentFlushed = false;
     this.scopeEmitted = false;
     this.doneReceived = false;
@@ -1073,6 +1082,10 @@ export class ApiChatCoordinator {
   /**
    * 把本轮累积的结果按原界面顺序输出:
    * 正文 → 政策卡片 → 参考资料 → 综合说明 → 补问选项卡
+   *
+   * ⚠️ F073 后正文可能已经在**流式过程中实时输出过**了(`answerStream.completed`):
+   * 那时这里**不再重发正文**(否则界面上会出现两份),但仍负责输出卡片/参考资料等,
+   * 并把「最终正文 + 其余部分」拼进 `totalResponse`(DMS 落库与最终校准都靠它)。
    */
   private async flushContent(): Promise<void> {
     if (this.contentFlushed) {
@@ -1080,8 +1093,17 @@ export class ApiChatCoordinator {
     }
     this.contentFlushed = true;
 
+    // 流式轮:正文已实时上屏,这里只补卡片等;未完成/未启用流式:正文走原来的整段路径
+    const streamBody = this.answerStream.completed ? this.answerStream.text || '' : null;
+
     const parts: string[] = [];
-    if (this.answerText) {
+    if (streamBody === null) {
+      if (this.answerText) {
+        parts.push(normalizeChatMarkdown(this.answerText));
+      }
+    } else if (this.answerText && shouldKeepAnswerOverview(this.answerText, streamBody)) {
+      // 概况不是流式正文的前缀(= 是另一段内容,不是压缩版)→ 保留,不能丢
+      console.warn('[answer-stream] answer 概述不属于流式正文,已保留展示');
       parts.push(normalizeChatMarkdown(this.answerText));
     }
 
@@ -1090,8 +1112,11 @@ export class ApiChatCoordinator {
       parts.push(policyTable);
     }
 
-    if (this.pendingSummary?.text) {
+    if (streamBody === null && this.pendingSummary?.text) {
       parts.push(normalizeChatMarkdown(this.pendingSummary.text));
+    }
+    // notices 与正文分开:流式只替换正文本体,notices 仍来自 summary 事件
+    if (this.pendingSummary) {
       const notices = (this.pendingSummary.notices ?? []).filter(Boolean);
       if (notices.length) {
         parts.push(notices.map((notice) => `- ${notice}`).join('\n'));
@@ -1100,7 +1125,7 @@ export class ApiChatCoordinator {
 
     if (this.pendingInterrupt) {
       // 传入已展示的正文,避免卡片里重复同一段话
-      parts.push(buildQuestionCardsContent(this.pendingInterrupt, this.answerText));
+      parts.push(buildQuestionCardsContent(this.pendingInterrupt, streamBody ?? this.answerText));
     }
 
     // 企业微信二维码:后端标记为 CIPA / 出海类问题时追加。
@@ -1120,8 +1145,16 @@ export class ApiChatCoordinator {
     }
 
     const content = parts.filter((part) => String(part || '').trim().length > 0).join('\n\n');
+
+    // totalResponse 必须是**最终全文**(DMS 落库、历史回读都靠它):
+    // 流式轮的正文在上面被跳过了,这里单独拼回最前面
+    this.totalResponse +=
+      streamBody === null
+        ? content
+        : [streamBody, content].filter((part) => String(part || '').trim().length > 0).join('\n\n');
+
     if (!content) {
-      return;
+      return; // 没有卡片等要补发(纯正文的流式轮就是这种)
     }
 
     // 原 StrXMLFilter 在遇到 <!-- POLICY_TABLE 时,会把同一批 buffer 里位于它
@@ -1133,10 +1166,27 @@ export class ApiChatCoordinator {
       await this.nextFrame();
     }
 
-    this.totalResponse += content;
     this._eventbus.emit('message', content);
   }
 
+  /**
+   * 撤去本轮尚未定稿的流式正文(后端 answer_abort / 出错 / 断流)。
+   *
+   * 必须先于随之而来的 error / close 同步发出:应用侧的 error 监听会立刻
+   * `finishTask` 摘掉任务,abort 晚到就会被任务守卫丢弃。
+   *
+   * ⚠️ **用户主动停止不走这里**(决策:保留已流出的部分文字)。
+   */
+  private discardActiveStream(reason: AnswerAbortReason): void {
+    const { streamId, completed } = this.answerStream;
+    if (streamId === null || completed) {
+      return; // 没有活动流 / 已经定稿:无可撤
+    }
+    this.answerStream = createAnswerStreamState();
+    this.answerStreamNormalizer = createIncrementalMarkdownNormalizer();
+    this._eventbus.emit('answerStreamAbort', { streamId, reason });
+  }
+
   private handleSseEvent(eventName: string, rawData: string) {
     let envelope: any;
     try {
@@ -1169,7 +1219,9 @@ export class ApiChatCoordinator {
       // 正文输出后不再发送,避免和正文抢位置。
       case 'progress':
       case 'heartbeat': {
-        if (this.contentFlushed) {
+        // 正文已开始流式输出(或整轮已 flush)后不再发「思考中」卡片:
+        // 否则流中途夹带的 heartbeat 会把卡片插进正文中间
+        if (this.contentFlushed || this.answerStream.streamId !== null) {
           break;
         }
         const message = data.message || (eventName === 'heartbeat' ? '问题仍在处理中,请稍候……' : '');
@@ -1178,6 +1230,48 @@ export class ApiChatCoordinator {
         break;
       }
 
+      // 正文真实增量流(F073):start / delta / end / abort
+      case 'answer_start':
+      case 'answer_delta':
+      case 'answer_end':
+      case 'answer_abort': {
+        const parsed = parseAnswerStreamEvent(data, eventName);
+        const { state, effect } = applyAnswerStreamEvent(this.answerStream, parsed);
+        this.answerStream = state;
+
+        if (effect.kind === 'start') {
+          // 正文即将到达:此后 progress/heartbeat 不再发 scope(见上面那个 case 的守卫)
+          this._eventbus.emit('answerStreamStart', {
+            streamId: effect.streamId,
+            target: effect.target,
+          });
+        } else if (effect.kind === 'append') {
+          // 逐片段上屏:走既有 message 通道,交给应用侧的 chunkBuffer 按帧批处理
+          // (实测平均 1.7 字/片、一轮最多 746 片,逐个直接渲染会把主线程打满)
+          const increment = this.answerStreamNormalizer.push(effect.text);
+          if (increment) {
+            this._eventbus.emit('message', increment);
+          }
+        } else if (effect.kind === 'replace') {
+          // 整块替换:全文先规整(与增量规整器同一口径),存档供 flushContent/totalResponse 用
+          const finalText = normalizeChatMarkdown(effect.text);
+          this.answerStream = { ...this.answerStream, text: finalText };
+          this._eventbus.emit('answerStreamEnd', {
+            streamId: effect.streamId,
+            target: effect.target,
+            text: finalText,
+            status: effect.status,
+          });
+        } else if (effect.kind === 'discard') {
+          this.answerStreamNormalizer = createIncrementalMarkdownNormalizer();
+          this._eventbus.emit('answerStreamAbort', {
+            streamId: effect.streamId,
+            reason: effect.reason,
+          });
+        }
+        break;
+      }
+
       case 'answer': {
         const text = typeof data.text === 'string' ? data.text : '';
         if (text) {
@@ -1230,6 +1324,8 @@ export class ApiChatCoordinator {
 
       case 'error': {
         const code = data.code || 'processing_failed';
+        // F073:出错时撤掉未定稿的流式草稿(先于 error 事件同步发出)
+        this.discardActiveStream('generation_failed');
         this.generating = false;
         this.loading = false;
         this.ctrl = null;
@@ -1274,6 +1370,9 @@ export class ApiChatCoordinator {
       return;
     }
 
+    // F073:任何中途失败都撤掉未定稿的流式草稿(幂等;必须先于 error/close 发出)
+    this.discardActiveStream('generation_failed');
+
     this.generating = false;
     this.loading = false;
     this.ctrl = null;

+ 6 - 0
src/components/business-assistant/shared.ts

@@ -29,6 +29,12 @@ export interface BusinessAssistantMessage {
   from?: From;
   state?: number;
   history?: boolean;
+  /**
+   * F073:本轮正文走**真实增量流式**(协调器 `answer_stream_start` 已到、`finishTask` 前)。
+   * 渲染层据此关闭打字机改为即时渲染——正文已经是逐片段到达了,再逐字重放等于凭空多等一遍。
+   * 旧后端轮次、mock 测试流从不置位,行为不变。
+   */
+  contentStreaming?: boolean;
   questionSource?: any[];
   mediaList?: any[];
   attachments?: BusinessAssistantMessageAttachment[];

+ 70 - 1
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -36,6 +36,8 @@ import { fetchRemoteSessionRecords, deleteSessionInfo, type RemoteSession } from
 // [已停用] updateSessionInfo 随 1887 会话写入一并停用
 import { upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
 import { syncCompanyInfoFromChat } from '@/network/api/dms/company-info-sync';
+import { hasVisibleMessageContent } from '@/utils/interrupted-message';
+import { applyAnswerStreamAbort, applyAnswerStreamEnd } from '../answer-stream';
 // [已停用] upsertDmsSession 随 1887 会话写入一并停用
 
 interface UseBusinessAssistantChatOptions {
@@ -272,6 +274,10 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
     const session = getSessionById(task.sessionId);
     if (session) {
+      // F073:流式标记只在本轮真正收尾时清除(end/abort 都不清)——
+      // 这样 answer_end 之后追加的卡片/参考资料仍走即时渲染,观感一致
+      const lastAiMessage = getLastAiMessage(session.messages);
+      if (lastAiMessage?.contentStreaming) lastAiMessage.contentStreaming = false;
       finishAiMessage(session.messages); // 已是 Stop 态则内部跳过
     }
     saveHistory();
@@ -298,6 +304,20 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       finished: false,
     };
 
+    /**
+     * F073 增量流式的本任务状态(闭包持有 → 并行会话天然隔离)。
+     * `baseIndex/baseText` 是「流开始那一刻消息已有内容」的快照:正文替换/撤回都以它为界,
+     * 这样 `<scope>` 进度标记等此前已上屏的内容不会被误伤。
+     */
+    const stream = { active: false, streamId: '', baseIndex: -1, baseText: '', lastHistorySaveAt: 0 };
+
+    /** 取本轮的 AI 消息(双守卫:任务还在表里 + 就是这条消息) */
+    const streamTargetMessage = () => {
+      const session = getSessionById(sessionId);
+      const ai = session ? getLastAiMessage(session.messages) : null;
+      return ai && ai.id === aiMessageId ? ai : null;
+    };
+
     task.chunkBuffer = createAiMessageChunkBuffer({
       append: (content) => {
         const session = getSessionById(sessionId);
@@ -306,10 +326,56 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       },
       onFlush: () => {
         void options.onMessageUpdated?.();
+        // 流式期每帧都会 flush:整库 JSON.stringify 太贵,按 500ms 节流;
+        // 收尾(finishTask)会无条件再存一次,不会丢
+        if (stream.active && Date.now() - stream.lastHistorySaveAt < 500) return;
+        stream.lastHistorySaveAt = Date.now();
         saveHistory();
       },
     });
 
+    // ---- F073:正文真实增量流式(start / end / abort;delta 走上面的 message 通道)----
+
+    coordinator?.addEventListener('answerStreamStart', (payload: any) => {
+      if (tasks.value.get(sessionId) !== task) return;
+      const ai = streamTargetMessage();
+      if (!ai) return;
+      task.chunkBuffer.flush(); // 先把已发出的 scope 标记落盘,基线才准
+      stream.baseIndex = ai.content.length;
+      stream.baseText = ai.content;
+      stream.streamId = String(payload?.streamId || '');
+      stream.active = true;
+      // 关掉打字机:正文已经是「真实增量」,不该再被逐字重放(见 BusinessRecord 的守卫)
+      ai.contentStreaming = true;
+    });
+
+    coordinator?.addEventListener('answerStreamEnd', (payload: any) => {
+      if (tasks.value.get(sessionId) !== task) return;
+      const ai = streamTargetMessage();
+      if (!ai) return;
+      if (!stream.active || String(payload?.streamId || '') !== stream.streamId) return; // 过期流
+      task.chunkBuffer.flush(); // 残余增量先落,再按最终全文对齐
+
+      // 正常路径是纯尾缀追加;罕见分歧走整块重建(语义见 applyAnswerStreamEnd)
+      const next = applyAnswerStreamEnd(ai.content, stream.baseText, String(payload?.text ?? ''));
+      if (next !== ai.content) {
+        ai.content = '';
+        ai.content = next;
+      }
+      stream.active = false; // contentStreaming 不清:之后的卡片等仍即时渲染,由 finishTask 统一收尾
+    });
+
+    coordinator?.addEventListener('answerStreamAbort', (payload: any) => {
+      if (tasks.value.get(sessionId) !== task) return;
+      const ai = streamTargetMessage();
+      if (!ai) return;
+      if (!stream.active || String(payload?.streamId || '') !== stream.streamId) return; // 过期流
+      task.chunkBuffer.flush(); // 残余先落(随后被截掉,顺序无污染)
+      ai.content = ''; // 触发渲染层的「清空重解析」,把已渲染的草稿行撤掉
+      ai.content = applyAnswerStreamAbort(stream.baseText); // 回到流开始前(scope 卡片随之复现)
+      stream.active = false;
+    });
+
     coordinator?.addEventListener('message', (msg: string) => {
       if (tasks.value.get(sessionId) !== task) return;
       if (!getSessionById(sessionId)) return;
@@ -351,11 +417,14 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
       // 停止/断流走这里(没有 totalResponse):把已生成的内容补写进 DMS,
       // 否则被中断的问答在库里会只有 question 没有 answer
+      //
+      // ⚠️ 判据用 hasVisibleMessageContent 而不是 trim():F073 撤草稿后 content 可能只剩
+      // `<scope>` 进度标记,trim() 非空会把这段标记当回答写进库
       if (
         session &&
         lastAiMessage &&
         !task.dmsAnswerWritten &&
-        String(lastAiMessage.content || '').trim()
+        hasVisibleMessageContent(lastAiMessage.content)
       ) {
         saveTurnToDms({ sessionId, recordId: lastAiMessage.id, answer: lastAiMessage.content });
       }

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません