Ver código fonte

chore(chat): 补「一小段重渲」的诊断(不改行为)

用户报告大面积重渲已消失,但偶尔仍有一小段重新渲染。本轮先做代码级排查并补齐诊断,
不改任何行为。

已排除(有代码依据):
- 打字机不会重放:流式期间 activeTypewriter=false 走 renderInstantly 把 typedText 直接设成
  全文;finishTask 只把 contentStreaming 置回 false,内容没变,而 TextContent 只 watch
  content/paused,不会因此重新打字。
- body 淡入 Transition 不会重放:shouldRenderBusinessRecordBody 恒为 true。
- changeFlag 只是动画回调守卫,不触发渲染。

剩下能让「已显示过的内容」重画的只有三类,本轮把缺日志的两类补上(仅非生产):
- debugRowMutation():行被删除 / 内容被回缩时打点(rowId、类型、前后长度、内容开头)
- closeTextRow():三处「关闭行去尾空白」收敛成一个函数,只在真的变短时打点
- beginScopeExit():打点「scope 退场补偿开始」,带 exitHeight 与当时的正文行数
  (补偿期间 shouldRenderTextContentRow 会让所有正文行不渲染,正文行数>0 就说明确有内容被藏起又挂回)

验证:npm run build 通过;verify-answer-stream 131/131;verify-answer-stream-payload 全绿;
validate-harness exit 0。

另附上一轮「可删内容体检」的脚本 harness/tools/audit-unused.mjs(import 图可达性分析,
可重复跑)。若不需要可以删掉。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 5 horas atrás
pai
commit
d1c1466be8

+ 29 - 0
harness/progress.md

@@ -3247,3 +3247,32 @@ DMS 主机(`121.43.55.7:10081`)是纯 API、根路径 404,没有登录页
 **待用户做**:把账号密码填进 `.dms-account`(或直接告诉我不用管、继续用剪贴板方式)。
 填完 `npm run dms:login` 一次即可;之后 `npm run dev` 会自动续。
 ⚠️ **成功登录那一步我没有实测**(没有密码),第一次跑请看一眼输出。
+
+### 续 9:定位「偶尔一小段重新渲染」(补诊断,待用户复现)
+
+**用户报告**:大面积重渲没了,但**偶尔还会有一小段重新渲染**。
+
+**已排除的(代码级论证,不是猜)**:
+- **打字机**不会重放:流式期间 `activeTypewriter=false` → `TextContent` 走 `renderInstantly()`,
+  把 `typedText` 直接设成全文;`finishTask` 只把 `contentStreaming` 置回 false,
+  **内容没变**,而 TextContent 只 watch `content`/`paused`,不会因此重新打字。
+- **body 淡入 Transition** 不会重放:`shouldRenderBusinessRecordBody` 恒为 `true`,从不切换。
+- **`changeFlag`** 只是 200ms 动画回调的守卫,不触发渲染。
+
+**剩下能让「已经显示过的内容」重画的,只有三类**(纯追加不会重渲任何已有内容):
+1. 整条消息被**重置重解析**(内容非纯追加)—— 已有 `[record] 内容被整块重写` 日志
+2. **某一行已显示的内容被改写/删除**(去尾空白回缩、删行)—— 本轮补上日志
+3. **scope 退场补偿**:`isScopeExitCompensating` 为真时**所有正文行都不渲染**
+   (`shouldRenderTextContentRow`),补偿结束再挂回来 —— 本轮补上日志(带「当时有几个正文行」)
+
+**本轮改动(只加诊断,不改行为;均仅非生产环境)**:
+- `debugRowMutation()`:行被删除 / 内容被回缩时打一行(带 rowId、类型、前后长度、内容开头)
+- `closeTextRow()`:把三处「关闭行时去尾空白」收敛成一个函数,**只在真的变短时**打点
+- `beginScopeExit()` 里打一行「scope 退场补偿开始」,带 exitHeight 与当时的正文行数/卡片行数
+
+**运行过的验证**:`npm run build` 通过;`verify-answer-stream` 131/131;
+`verify-answer-stream-payload` 全绿;`validate-harness` exit 0。
+
+**下一步**:请用户复现一次「一小段重渲」,把控制台里下列关键字任一行发来 ——
+`[answer-stream]`(内容层改写)、`[record]`(重置/行改动/补偿)、`[TextContent][typewriter]`。
+哪一类出现,答案就在哪一层。

+ 167 - 0
harness/tools/audit-unused.mjs

@@ -0,0 +1,167 @@
+/**
+ * 死代码体检:从入口出发做 **import 图可达性分析**,列出「没人 import 到」的模块,
+ * 外加静态资源与 npm 依赖的线索。
+ *
+ * 怎么跑:node harness/tools/audit-unused.mjs
+ *
+ * 为什么用「可达性」而不是「文件名有没有出现在别处」:
+ * 本项目有大量**注释掉的旧代码**(`[已停用]` / `// import ...`),按文本出现判断会把
+ * 死文件当成活的 —— 第一版就是这么写的,三项全是「无」,纯属自欺。
+ *
+ * ⚠️ 仍是**体检不是判决**,两类已知盲区:
+ *   ① 动态路径(`import(`./pages/${name}`)`)解析不出来 → 可能误报
+ *   ② 模板里靠 `unplugin-vue-components` 自动注册的组件(本项目配了 AntDesignVueResolver)
+ *      → 只影响 node_modules,不影响 src 下的自有组件
+ * 所以删之前**逐个 grep 复核**。
+ */
+
+import { readFileSync, existsSync, readdirSync } from 'node:fs';
+import { join, dirname, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.vscode', '.idea']);
+
+const walk = (dir, out = []) => {
+  for (const entry of readdirSync(dir, { withFileTypes: true })) {
+    if (entry.isDirectory()) {
+      if (SKIP_DIRS.has(entry.name)) continue;
+      walk(join(dir, entry.name), out);
+    } else {
+      out.push(join(dir, entry.name));
+    }
+  }
+  return out;
+};
+
+const files = walk(repoRoot);
+const rel = (f) => relative(repoRoot, f).replace(/\\/g, '/');
+
+/** 入口:index.html 里的 module script + 约定俗成的入口文件 */
+const ENTRY_CANDIDATES = ['src/main.ts', 'src/index.ts', 'index.html', 'vite.config.ts'];
+
+/** 取出一段代码里的所有模块说明符(静态 import / 动态 import / require / 裸 `import 'x'`) */
+const extractSpecifiers = (code) => {
+  const out = new Set();
+  const patterns = [
+    /(?:^|[\s;{(])import\s+[^'"]*?from\s*['"]([^'"]+)['"]/g, // import x from 'y'
+    /(?:^|[\s;{(])import\s*['"]([^'"]+)['"]/g, // import 'y'
+    /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // import('y')
+    /(?:^|[\s;}])export\s+[^'"]*?from\s*['"]([^'"]+)['"]/g, // export { x } from 'y' —— 桶文件靠它,漏了会误报
+    /(?:^|[\s;}])export\s*\*\s*from\s*['"]([^'"]+)['"]/g, // export * from 'y'
+    /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // require('y')
+  ];
+  for (const re of patterns) {
+    for (const m of code.matchAll(re)) out.add(m[1]);
+  }
+  return [...out];
+};
+
+/** 把模块说明符解析成磁盘文件(支持 @ 别名与省略扩展名 / index) */
+const resolveSpecifier = (spec, fromFile) => {
+  let base;
+  if (spec.startsWith('@/')) base = join(repoRoot, 'src', spec.slice(2));
+  else if (spec.startsWith('./') || spec.startsWith('../')) base = resolve(dirname(fromFile), spec);
+  else return null; // 裸包名(npm 依赖):不参与本次可达性分析
+
+  const candidates = [
+    base,
+    `${base}.ts`,
+    `${base}.js`,
+    `${base}.vue`,
+    `${base}.json`,
+    join(base, 'index.ts'),
+    join(base, 'index.js'),
+    join(base, 'index.vue'),
+  ];
+  return candidates.find((c) => existsSync(c) && !readdirSafe(c)) ?? null;
+};
+const readdirSafe = (p) => {
+  try {
+    return readdirSync(p);
+  } catch {
+    return null;
+  }
+};
+
+/* ---------------- 可达性:从入口做 BFS ---------------- */
+const reachable = new Set();
+const queue = [];
+
+// index.html 里被引用的 TS 也算入口
+const indexHtml = join(repoRoot, 'index.html');
+if (existsSync(indexHtml)) {
+  for (const m of readFileSync(indexHtml, 'utf8').matchAll(/<script[^>]+src=['"]([^'"]+)['"]/g)) {
+    const p = resolve(join(repoRoot, m[1]));
+    if (existsSync(p)) queue.push(p);
+  }
+}
+for (const e of ENTRY_CANDIDATES) {
+  const p = join(repoRoot, e);
+  if (existsSync(p)) queue.push(p);
+}
+
+while (queue.length) {
+  const file = queue.pop();
+  const key = resolve(file);
+  if (reachable.has(key)) continue;
+  reachable.add(key);
+  if (!/\.(ts|vue|js|mjs|html)$/.test(file)) continue;
+  let code = '';
+  try {
+    code = readFileSync(file, 'utf8');
+  } catch {
+    continue;
+  }
+  for (const spec of extractSpecifiers(code)) {
+    const target = resolveSpecifier(spec, file);
+    if (target && !reachable.has(resolve(target))) queue.push(target);
+  }
+}
+
+/* ---------------- 1. 不可达的 src 模块 ---------------- */
+const srcCode = files.filter((f) => /^src\//.test(rel(f)) && /\.(ts|js|vue)$/.test(f) && !/\.d\.ts$/.test(f));
+const unreachable = srcCode.filter((f) => !reachable.has(resolve(f)) && !/\.config\./.test(f));
+
+/* ---------------- 2. 静态资源 ---------------- */
+const codeText = files
+  .filter((f) => {
+    const r = rel(f);
+    if (r.startsWith('harness/') || /\.md$/i.test(r)) return false;
+    return /\.(ts|vue|js|mjs|html|json|less|scss|css)$/.test(r);
+  })
+  .map((f) => {
+    try {
+      return readFileSync(f, 'utf8');
+    } catch {
+      return '';
+    }
+  })
+  .join('\n');
+const ASSET_EXT = /\.(png|jpe?g|svg|gif|webp|mp4|webm|woff2?|ttf|otf|eot)$/i;
+const unusedAssets = files
+  .filter((f) => ASSET_EXT.test(f) && !/^dist\//.test(rel(f)) && !/^harness\//.test(rel(f)))
+  .filter((f) => (codeText.split(f.split(/[\\/]/).pop()).length - 1) <= 0);
+
+/* ---------------- 3. npm 依赖 ---------------- */
+const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
+const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
+const unusedDeps = deps.filter((dep) => (codeText.split(dep).length - 1) <= 0);
+
+/* ---------------- 输出 ---------------- */
+const section = (title, items, note) => {
+  console.log(`\n=== ${title} ===`);
+  if (note) console.log(`(${note})`);
+  if (!items.length) console.log('  无');
+  else for (const i of items) console.log('  ', i);
+};
+
+console.log(`入口:${ENTRY_CANDIDATES.filter((e) => existsSync(join(repoRoot, e))).join(', ')}`);
+console.log(`可达模块:${reachable.size} 个`);
+section(
+  '**没人 import 到**的 src 模块(死代码候选)',
+  unreachable.map(rel),
+  '判据:从入口做 import 图可达性;注释掉的 import 不算数'
+);
+section('疑似没人引用的静态资源', unusedAssets.map(rel), '⚠️ 目录级/批量引用看不出来,需人工确认');
+section('疑似没人 import 的 npm 依赖', unusedDeps, '⚠️ 配置里按字符串用到的会命中,需人工确认');

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

@@ -1153,6 +1153,45 @@ const processRecordSections = (input: string) => {
   }
 };
 
+/**
+ * 诊断(仅非生产):**已经显示过的内容**被改小或被删掉时打一行。
+ *
+ * 为什么只盯这两种:用户报的「一小段重新渲染」在渲染层只可能来自两类操作 ——
+ *   ① 整条消息被重置重解析(已有 `[record] 内容被整块重写` 那行)
+ *   ② 某一行**已经显示过**的内容被改写 / 被删除(就是这里)
+ * 纯追加(流式增长)不会重渲任何已有内容,所以完全不必打点(打了每帧都会刷屏)。
+ */
+const debugRowMutation = (
+  what: string,
+  row: DisplayRecordRow | undefined,
+  before: string,
+  after: string
+) => {
+  if (import.meta.env.MODE === "production") {
+    return;
+  }
+  console.warn(`[record] ${what}`, {
+    rowId: row?.id,
+    type: row?.type,
+    before: String(before ?? "").length,
+    after: String(after ?? "").length,
+    head: String(before ?? "").slice(0, 40),
+  });
+};
+
+/**
+ * 关闭一行文本:去掉尾部空白,并在**真的变短**时打一行诊断
+ * —— 变短意味着这一行已经显示过的内容被改写,会重渲(纯追加不会)。
+ */
+const closeTextRow = (row: DisplayRecordRow) => {
+  const before = row.content;
+  const after = trimDisplayTextContentEnd(before);
+  if (after !== before) {
+    debugRowMutation("行内容被回缩(关闭行时去尾空白)", row, before, after);
+  }
+  row.content = after;
+};
+
 const resetDisplayContent = () => {
   if (textAnimWrapper.value) {
     textAnimWrapper.value.innerHTML = "";
@@ -1270,6 +1309,16 @@ const beginScopeExit = (scopeRow: DisplayRecordRow | null) => {
   scopeExitHeight.value = exitHeight;
   scopeExitWidth.value = exitWidth;
   isScopeExitBodyTransitionDisabled.value = true;
+  if (import.meta.env.MODE !== "production") {
+    // 诊断用:`isScopeExitCompensating` 为真时**所有正文行都不渲染**(见 shouldRenderTextContentRow),
+    // 补偿结束再挂回来。如果这一步发生在正文已经流出一段之后,观感就是「一小段正文重渲」。
+    // 出问题时看这行的 `已渲染正文行数`:>0 就说明确实有已显示的正文被藏起来又挂回。
+    console.warn("[record] scope 退场补偿开始(正文行会暂时隐藏)", {
+      exitHeight,
+      textRows: displayRecordRows.value.filter((row) => row.type === RecordRowTypes.Text).length,
+      policyRows: displayRecordRows.value.filter((row) => row.type === RecordRowTypes.PolicyTable).length,
+    });
+  }
   emit("scopeHeightLockChange", {
     recordId: record.value.id,
     active: shouldLockRecordHeight && exitHeight > 0,
@@ -1353,6 +1402,7 @@ const updateLastRecordRow = (
         break;
       }
 
+      debugRowMutation("删除行(scope 前导)", trailingRow, trailingRow.content, "");
       displayRecordRows.value.pop();
     }
 
@@ -1372,6 +1422,7 @@ const updateLastRecordRow = (
         break;
       }
 
+      debugRowMutation("删除行(空 scope 状态)", trailingRow, trailingRow.content, "");
       displayRecordRows.value.pop();
     }
 
@@ -1429,7 +1480,7 @@ const updateLastRecordRow = (
       if (!lastRow || lastRow.state === RecordRowStates.Displayed || lastRow.type !== type) {
         if (lastRow && lastRow.type !== type) {
           if (lastRow.type === RecordRowTypes.Text) {
-            lastRow.content = trimDisplayTextContentEnd(lastRow.content);
+            closeTextRow(lastRow);
           }
           lastRow.state = RecordRowStates.Displayed;
         }
@@ -1460,7 +1511,7 @@ const updateLastRecordRow = (
       lastRow.state = state;
 
       if (segment.breakAfter) {
-        lastRow.content = trimDisplayTextContentEnd(lastRow.content);
+        closeTextRow(lastRow);
         lastRow.state = RecordRowStates.Displayed;
       }
     }
@@ -1471,7 +1522,7 @@ const updateLastRecordRow = (
   if (!lastRow || lastRow.state === RecordRowStates.Displayed || lastRow.type !== type) {
     if (lastRow && lastRow.type !== type) {
       if (lastRow.type === RecordRowTypes.Text) {
-        lastRow.content = trimDisplayTextContentEnd(lastRow.content);
+        closeTextRow(lastRow);
       }
       lastRow.state = RecordRowStates.Displayed;
     }