Browse Source

fix(chat): 修「整块重写时渲染层把新内容中段当正文显示」(用户报告参考资料 JSON 被当文本渲染)

根因两处叠加,都是本次流式改造引入的:
① BusinessRecord 的内容 watch 只有增量路径(diffText = val.slice(count)),假设 content 只会追加;
   而流式收尾的整块重写(卡片重排 / answer_end 重建 / 撤草稿)后,切出来的是新内容的**中段**
   → 中段被当正文渲染(用户看到的就是参考资料 JSON 的中段)
② 我原以为「content = '' 再赋值」能触发清空 —— Vue 的 watcher 默认 pre-flush,
   同步两次赋值会被合并成一次回调,中间的空值看不到,清空动作从未发生

修法:
- 内容 watch 增加「非纯追加」分支:!val.startsWith(旧内容) → resetDisplayContent() + xmlFilter.reset()
  后全量解析;纯追加(流式 delta、answer_end 尾缀补齐)仍走增量路径,不重置不闪
- resetDisplayContent() 顺带清 scope 退场补偿态(scopeExitHeight/Width、recordHeightLock、定时器)
  —— isScopeExitCompensating 为真时正文行是隐藏的,残留会让重写后的正文整段不显示
- hook 三处「先清空再赋值」简化为单次整块赋值,并注释说明为什么不要两步

顺带修好:answer_end 的整块重建、撤草稿两条路径此前依赖同一个失效的清空动作。

验证:新增 1 项契约断言(compose 后不是纯追加);适配层 27/27、流式 88/88、聊天任务 33/33、
判空口径 30/30;build 与 validate-harness 通过。⚠️ 渲染层行为需浏览器确认。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 4 hours ago
parent
commit
747ddea919

File diff suppressed because it is too large
+ 1 - 1
harness/feature_list.json


+ 27 - 0
harness/progress.md

@@ -3031,3 +3031,30 @@ F073 把整篇综合说明当正文流式铺满后,卡片照旧排在正文之
 - 断言新增 2 项(引导语存在、且排在图片之前)——适配层脚本 25 → **27/27 通过**
 - 文案做成导出的常量 `CIPA_QRCODE_TIP`(要改文案只动一处)
 - 同步:`api-chat.md` 的 cipa 节、`feature_list.json` 的 `cipa-qrcode`
+
+### 续 3:修一个真 bug —— 「整块重写」时渲染层把新内容的中段当正文显示
+
+**用户报告**:界面上直接显示出一大段参考资料 JSON(`{"title":"食品经营许可…","publisher":"",…}`)。
+
+**根因(两处叠加,都是我引入的)**:
+1. `BusinessRecord` 的内容 watch 只有**增量**路径:`diffText = val.slice(count.value)`
+   —— 它假设 content 只会往后追加。而我在流式收尾用 `ai.content = ''` + 再赋值做**整块重写**
+   (卡片重排/answer_end 重建/撤草稿),切出来的正是新内容的**中段** → 中段被当正文渲染。
+2. 那个「先清空再赋值」的写法**根本不生效**:Vue 的 watcher 默认 pre-flush,
+   同步两次赋值会被**合并成一次回调**,中间的空值看不到 → 清空动作从未发生。
+
+**修法**(渲染层补能力 + 调用方简化):
+- `BusinessRecord` 的内容 watch 增加**非纯追加**分支:`!val.startsWith(originContent)` →
+  `resetDisplayContent()` + `xmlFilter.reset()`(解析器也有状态:buffer/未闭合标签),再全量解析。
+  纯追加(流式 delta、answer_end 尾缀补齐)仍走增量路径,不重置、不闪。
+- `resetDisplayContent()` 顺带清 **scope 退场补偿态**(`scopeExitHeight/Width`、`recordHeightLock`
+  与定时器)—— `isScopeExitCompensating` 为真时**正文行是隐藏的**,残留会让重写后的正文整段不显示
+  (这是同一类潜在 bug)
+- hook 里三处「先清空再赋值」简化为**单次整块赋值**(渲染层现在能识别重写),并注释说明为什么不要两步
+
+**顺带修好的**:`answer_end` 的整块重建、**撤草稿**两条路径此前依赖同一个失效的清空动作
+(草稿行不会被撤掉)——现在都走新的重置分支了。
+
+**验证**:新增 1 项契约断言(compose 后的内容**不是**正文的纯追加 → 渲染层必须支持整块重写);
+适配层 27/27、流式 88/88、聊天任务 33/33、判空口径 30/30 全绿;`npm run build` 通过。
+⚠️ 渲染层行为只能在浏览器确认(harness 不跑 Vue 组件)——请刷新后再看一次。

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

@@ -460,6 +460,16 @@ console.log('\n【4】协调器假流:happy path(带卡片轮形态,含流
     String(seen.total?.answer || '').slice(0, 80)
   );
   check('正文本身没有在 message 通道里被卡片打断(卡片走 compose)', !text.includes('POLICY_TABLE'));
+  // ⚠️ 契约:compose 让应用层**整块重写**内容(不是往后追加)——
+  // 所以渲染层的增量解析必须能识别"非纯追加"并重置,否则会把新内容的中段当正文渲染
+  // (踩过:参考资料 JSON 的中段变成一坨文本显示在界面上)
+  const composedLeading = String(compose?.leading || '');
+  check(
+    '重排后的内容**不是正文的纯追加**(渲染层必须支持"整块重写")',
+    composedLeading.length > 0 &&
+      !`${composedLeading}\n\n${normalizeChatMarkdown(FINAL_TEXT)}`.startsWith(normalizeChatMarkdown(FINAL_TEXT)),
+    composedLeading.slice(0, 40)
+  );
 }
 
 console.log('\n【5】协调器假流:纯正文轮(无卡片、无 answer 事件)不丢正文');

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

@@ -1164,6 +1164,12 @@ const resetDisplayContent = () => {
   imageRowRevealFinished.value = {};
   imageRowRevealActive.value = {};
   hasVisibleAnswerContent.value = false;
+  // ⚠️ scope 退场补偿态也要清:`isScopeExitCompensating` 为真时**正文行是隐藏的**
+  // (见 shouldRenderTextContentRow),残留会让重写后的正文整段不显示
+  clearScopeExitTimer();
+  scopeExitHeight.value = 0;
+  scopeExitWidth.value = 0;
+  recordHeightLock.value = null;
   count.value = 0;
 };
 
@@ -1535,6 +1541,25 @@ watch(
       return;
     }
 
+    /**
+     * ⚠️ 内容**不是纯追加**时必须整体重置再全量解析。
+     *
+     * 这里的增量解析只吃「比上次多出来的那一段」(`val.slice(count)`),
+     * 所以消息内容被**整块重写**(不是往后追加)时,切出来的会是新内容的**中段**——
+     * 那段中段会被当正文渲染出来(踩过:参考资料 JSON 的中段变成一坨文本显示在界面上)。
+     *
+     * 会整块重写的场景(F073 起):
+     * - 流式收尾的**卡片重排**(把政策卡片挪到正文之上)
+     * - `answer_end` 与增量拼接不一致时的整块重建
+     * - 撤草稿(内容被截短回基线)
+     * 纯追加(流式 delta / answer_end 的尾缀补齐)仍走增量路径,不重置、不闪。
+     */
+    if (!val.startsWith(originContent.value)) {
+      changeFlag++;
+      resetDisplayContent();
+      xmlFilter.reset(); // 解析器也是有状态的(buffer / 未闭合标签),一并清
+    }
+
     const diffText = val.slice(count.value);
     processRecordSections(diffText);
     originContent.value = val;

Some files were not shown because too many files changed in this diff