Przeglądaj źródła

fix(chat): 加固内容整块替换的路径并加诊断(用户报「内容突然消失变成另一段」)

用户症状:正文先流出一部分,随后突然消失、变成另一段内容;最终内容正常但过程不合适。
已排除"后端改稿":三份真实载荷实测 concat(deltas) === answer_end.text、归整后以增量为前缀。

加固(都是可证明的脆弱点):
- 不再按"开流时的基线快照"切片:hook 自己累计流式正文(stream.body,在 message 监听里按
  stream.active 累计),end/compose/abort 三处一律**后缀匹配**;对不上时原样不动或直接把
  最终正文接上(宁可多留也不切坏、绝不丢字)
- 三处「先 content='' 再赋值」改为单次整块赋值(那两步会被 Vue 的 pre-flush watcher 合并,
  等于没写;上一轮报告"已改"其实是脚本替换静默失败,此处纠正)
- 加诊断日志(仅非生产):[answer-stream] start/end/compose/abort(带长度、是否整块重建)
  与 [record] 内容被整块重写(带前后长度)——"内容被换掉"类问题只能靠这类信息定位

验证:verify-answer-stream 90/90(新增兜底分支断言);build 与 validate-harness 通过。
⚠️ 真实触发原因待用户复现时提供控制台日志后定位。
gongtianxiao 9 godzin temu
rodzic
commit
66b00f560b

+ 24 - 0
harness/progress.md

@@ -3058,3 +3058,27 @@ F073 把整篇综合说明当正文流式铺满后,卡片照旧排在正文之
 **验证**:新增 1 项契约断言(compose 后的内容**不是**正文的纯追加 → 渲染层必须支持整块重写);
 适配层 27/27、流式 88/88、聊天任务 33/33、判空口径 30/30 全绿;`npm run build` 通过。
 ⚠️ 渲染层行为只能在浏览器确认(harness 不跑 Vue 组件)——请刷新后再看一次。
+
+### 续 4:加固「内容被整块替换」的路径 + 加诊断(用户报「内容突然消失变成另一段」)
+
+**用户症状**:正文先流出一部分,随后**突然消失、变成另一段内容**;最终内容正常,但过程不合适。
+
+**已排除**:真后端三份载荷实测 `concat(deltas) === answer_end.text`(草稿没被后端替换)、
+归整后以增量拼接为前缀 ✓ —— 所以不是"后端改稿"这一条。
+
+**本轮做的加固(都是能证明的脆弱点)**:
+1. **切片基线不再用开流时的快照**:改为在 hook 里**自己累计流式正文**(`stream.body`,在 message
+   监听里按 `stream.active` 累计),三个改写路径(end 对齐 / 卡片重排 / 撤稿)一律做**后缀匹配**,
+   不匹配时**原样不动或直接把最终正文接上**(宁可多留也不切坏、绝不丢字)
+2. 三处「先 `content=''` 再赋值」改为**单次整块赋值**(此前那两步会被 Vue 的 pre-flush watcher
+   合并,等于没写;上一轮我报告"已改"其实脚本替换静默失败了,已纠正)
+3. 加诊断日志(仅非生产):`[answer-stream] start/end/compose/abort`(带长度、是否整块重建)与
+   `[record] 内容被整块重写,已重置并全量重解析`(带前后长度)——
+   这类"内容被换掉"的问题只能靠"哪条路径改写了内容、改了多少"来定位
+
+**验证**:`verify-answer-stream.mjs` **90/90 通过**(新增兜底分支断言:对不上时不切、
+abort 对不上时不动);`npm run build` 通过。
+
+**⚠️ 仍未定论**:真实触发原因需要用户复现时看控制台日志(哪几条 `[answer-stream]` / `[record]`
+出现、顺序与长度)。**待用户提供**后即可定位并改正(若确为 `answer_abort(regenerating)`,
+那是契约规定的"撤草稿等重生成",届时需要与用户确认观感怎么处理)。

+ 13 - 7
harness/tools/verify-answer-stream.mjs

@@ -291,24 +291,30 @@ console.log('\n【3】流状态机');
 console.log('\n【3b】应用层的内容对齐(end 替换 / abort 撤稿)');
 {
   const BASE = '<scope title="思考中">\n</scope>\n';
+  // 新签名:(当前内容, 流式正文, 最终正文) —— 用「流式正文」做后缀匹配,不按基线长度切
   check(
-    '正常路径:最终文本是已上屏内容的前缀延伸 → 纯尾缀追加(不加空行、不重建)',
-    applyAnswerStreamEnd(BASE + '一、说明', BASE, '一、说明。二、补充') === BASE + '一、说明。二、补充'
+    '正常路径:最终文本以增量拼接为前缀 → 纯尾缀追加(不加空行、不重建)',
+    applyAnswerStreamEnd(BASE + '一、说明', '一、说明', '一、说明。二、补充') === BASE + '一、说明。二、补充'
   );
   check(
     '已上屏一长段、最终只多了尾巴 → 不重复整段',
-    applyAnswerStreamEnd(BASE + '甲', BASE, '甲乙丙') === BASE + '甲乙丙'
+    applyAnswerStreamEnd(BASE + '甲', '甲', '甲乙丙') === BASE + '甲乙丙'
   );
   check(
     '罕见分歧(最终文本与增量拼接不一致)→ 整块重建,且基线里的 <scope> 被剥掉(避免卡片闪现)',
-    applyAnswerStreamEnd(BASE + '草稿', BASE, '完全不同的最终文本') === '完全不同的最终文本'
+    applyAnswerStreamEnd(BASE + '草稿', '草稿', '完全不同的最终文本') === '完全不同的最终文本'
   );
   check(
     '重建时**基线(流开始前就有的内容)保留**,最终文本接在其后 —— 只有 scope 标记会被剥掉',
-    applyAnswerStreamEnd('前言', '前言', '最终') === '前言最终'
+    applyAnswerStreamEnd('前言草稿', '草稿', '最终') === '前言最终'
   );
-  check('abort 撤稿:回到流开始前的内容', applyAnswerStreamAbort(BASE) === BASE);
-  check('abort 撤稿:空基线返回空串', applyAnswerStreamAbort('') === '');
+  check(
+    '兜底:上屏内容与流式正文对不上(被别的路径改过)→ 不切,直接把最终正文接上(不丢字)',
+    applyAnswerStreamEnd('被改过的内容', '对不上的正文', '最终') === '被改过的内容最终'
+  );
+  check('abort 撤稿:从上屏内容里去掉流式正文', applyAnswerStreamAbort(BASE + '草稿', '草稿') === BASE);
+  check('abort 兜底:对不上就不动(宁可多留也不切坏)', applyAnswerStreamAbort('别的内容', '对不上') === '别的内容');
+  check('abort:空正文返回原内容', applyAnswerStreamAbort('原内容', '') === '原内容');
 
   // answer 开场概述该不该显示(实测:后端把正文作为 summary 流式下发,
   // 而 answer 常是同一段开头的**压缩版**——显示会重复开头;但万一是另一段内容就不能丢)

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

@@ -1558,6 +1558,14 @@ watch(
       changeFlag++;
       resetDisplayContent();
       xmlFilter.reset(); // 解析器也是有状态的(buffer / 未闭合标签),一并清
+      if (import.meta.env.MODE !== "production") {
+        // 诊断用:整块重写会让整条消息重新解析(观感上是"内容被换掉")。
+        // 出问题时看这行就知道是哪个长度差触发的。
+        console.warn("[record] 内容被整块重写,已重置并全量重解析", {
+          before: originContent.value.length,
+          after: val.length,
+        });
+      }
     }
 
     const diffText = val.slice(count.value);

+ 21 - 9
src/components/answer-stream.ts

@@ -186,14 +186,21 @@ export const createIncrementalMarkdownNormalizer = (): IncrementalMarkdownNormal
  */
 export const applyAnswerStreamEnd = (
   content: string,
-  baseText: string,
+  streamedBody: string,
   finalText: string
 ): string => {
-  const streamed = String(content || '').slice(String(baseText || '').length);
-  if (finalText.startsWith(streamed)) {
-    return String(content || '') + finalText.slice(streamed.length);
+  const text = String(content || '');
+  const body = String(streamedBody || '');
+
+  // 兜底:上屏内容末尾不是这段流式正文(被别的路径改过)→ 什么都不切,直接把最终正文接上,**绝不丢字**
+  if (!body || !text.endsWith(body)) {
+    return body ? text + finalText : text + finalText;
   }
-  const base = String(baseText || '').replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, '');
+  if (finalText.startsWith(body)) {
+    return text + finalText.slice(body.length); // 正常路径:纯尾缀追加
+  }
+  // 罕见分歧(后端最终校验改过草稿):整块重建;基线里的 <scope> 先剥掉,免得卡片闪现重播
+  const base = text.slice(0, text.length - body.length).replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, '');
   return base + finalText;
 };
 
@@ -244,12 +251,17 @@ export const composeStreamedContent = (
 };
 
 /**
- * `answer_abort` / 断流:撤掉未定稿的草稿,回到流开始前的内容
+ * `answer_abort` / 断流:撤掉未定稿的草稿。
  *
- * ⚠️ 应用层做这一步时要**先置空再赋值**(两个 tick 内的连续赋值):
- * 渲染层是按 content 增量解析行的,不先清空的话旧行会留在界面上
+ * 同 `applyAnswerStreamEnd`:用**后缀匹配**从当前内容里去掉这段流式正文,
+ * 而不是按「开流时的基线长度」切(基线可能已被别的路径改过,切了会错位)
  */
-export const applyAnswerStreamAbort = (baseText: string): string => String(baseText || '');
+export const applyAnswerStreamAbort = (content: string, streamedBody: string): string => {
+  const text = String(content || '');
+  const body = String(streamedBody || '');
+  if (!body || !text.endsWith(body)) return text; // 对不上就不动(宁可多留,也不切坏)
+  return text.slice(0, text.length - body.length);
+};
 
 /* ------------------------------------------------------------------ *
  * 事件解析

+ 45 - 14
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -309,7 +309,19 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
      * `baseIndex/baseText` 是「流开始那一刻消息已有内容」的快照:正文替换/撤回都以它为界,
      * 这样 `<scope>` 进度标记等此前已上屏的内容不会被误伤。
      */
-    const stream = { active: false, streamId: '', baseIndex: -1, baseText: '', lastHistorySaveAt: 0 };
+    const stream = { active: false, streamId: '', baseIndex: -1, baseText: '', body: '', lastHistorySaveAt: 0 };
+
+    /**
+     * 流式相关的诊断日志(仅非生产环境)。
+     *
+     * 为什么留着:内容被整块替换时用户会看到「先出现的内容突然消失」,
+     * 而这类问题只看最终结果看不出来 —— 得知道**是哪条路径改写了内容**、改了多少。
+     * 出问题时报这几行日志即可定位(见 F073 的记录)。
+     */
+    const debugStream = (tag: string, detail: Record<string, unknown>) => {
+      if (import.meta.env.MODE === 'production') return;
+      console.warn(`[answer-stream] ${tag}`, detail);
+    };
 
     /** 取本轮的 AI 消息(双守卫:任务还在表里 + 就是这条消息) */
     const streamTargetMessage = () => {
@@ -343,8 +355,12 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       task.chunkBuffer.flush(); // 先把已发出的 scope 标记落盘,基线才准
       stream.baseIndex = ai.content.length;
       stream.baseText = ai.content;
+      // 自己累计「流出来的正文」:后面改写内容都以它为准做**后缀匹配**,
+      // 不再用「按基线长度切片」——基线可能已经被别的路径改过(那样切片会错位)
+      stream.body = '';
       stream.streamId = String(payload?.streamId || '');
       stream.active = true;
+      debugStream('start', { base: stream.baseText.length });
       // 关掉打字机:正文已经是「真实增量」,不该再被逐字重放(见 BusinessRecord 的守卫)
       ai.contentStreaming = true;
     });
@@ -356,12 +372,17 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       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;
-      }
+      // 用**自己累计的流式正文**做前缀比较/切片(不要按基线长度切:基线可能已被别的路径改过)
+      const before = ai.content;
+      const finalText = String(payload?.text ?? '');
+      const next = applyAnswerStreamEnd(before, stream.body, finalText);
+      if (next !== before) ai.content = next;
+      debugStream('end', {
+        流式正文: stream.body.length,
+        最终正文: finalText.length,
+        追加: next.length - before.length,
+        整块重建: !finalText.startsWith(stream.body),
+      });
       stream.active = false; // contentStreaming 不清:之后的卡片等仍即时渲染,由 finishTask 统一收尾
     });
 
@@ -371,12 +392,16 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       const ai = streamTargetMessage();
       if (!ai) return;
       task.chunkBuffer.flush(); // 残余增量先落,正文才是完整的
-      const base = stream.baseText;
-      const body = ai.content.slice(base.length);
+      const before = ai.content;
+      const body = stream.body;
+      // 基线由「上屏内容减去流式正文」**推导**,不用开流时的快照
+      const base = before.endsWith(body) ? before.slice(0, before.length - body.length) : before;
+      if (!before.endsWith(body)) {
+        console.warn('[answer-stream] 上屏内容与流式正文对不上,重排时按当前内容兜底(不丢字)');
+      }
       const next = composeStreamedContent(base, String(payload?.leading || ''), body, String(payload?.trailing || ''));
-      if (next === ai.content) return;
-      ai.content = '';
-      ai.content = next;
+      if (next !== before) ai.content = next;
+      debugStream('compose', { 正文: body.length, 卡片: String(payload?.leading || '').length, 尾部: String(payload?.trailing || '').length });
     });
 
     coordinator?.addEventListener('answerStreamAbort', (payload: any) => {
@@ -385,14 +410,20 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       if (!ai) return;
       if (!stream.active || String(payload?.streamId || '') !== stream.streamId) return; // 过期流
       task.chunkBuffer.flush(); // 残余先落(随后被截掉,顺序无污染)
-      ai.content = ''; // 触发渲染层的「清空重解析」,把已渲染的草稿行撤掉
-      ai.content = applyAnswerStreamAbort(stream.baseText); // 回到流开始前(scope 卡片随之复现)
+      const before = ai.content;
+      const body = stream.body;
+      // 撤稿 = 从上屏内容里去掉这段流式正文(同样用后缀匹配,不按快照长度切)
+      ai.content = applyAnswerStreamAbort(before, body);
+      debugStream('abort', { reason: payload?.reason, 撤掉: before.length - ai.content.length });
       stream.active = false;
     });
 
     coordinator?.addEventListener('message', (msg: string) => {
       if (tasks.value.get(sessionId) !== task) return;
       if (!getSessionById(sessionId)) return;
+      // 正在流式时,这条通道上跑的就是正文增量 —— 自己留一份,
+      // 收尾对齐/卡片重排/撤稿都以它为准(见 stream.body 的说明)
+      if (stream.active) stream.body += msg;
       task.chunkBuffer.push(msg);
     });