Browse Source

[Fix R4-R4-5] 复核 FR-1 收口:缓存失效源并入 props.messages.length(删除 AI 消息场景),同步注释与交付说明

lizuo 2 weeks ago
parent
commit
6f478e0d61

+ 4 - 2
docs/ai-changes/20260929-audit-r4.md

@@ -27,6 +27,7 @@
 | 6 | `[Fix R4-R4-5] …` | `ConversationNav` 滚动跟随缓存 wrappers 列表 |
 | 7 | `[Fix R4-R4-7/8] …` | `markdownSafe` 注释旧路径更正;`useChatScroll` 卸载时 `cancelAnimationFrame` |
 | 8 | `[Docs] …` | 本说明 + `docs/ai-changes/README.md` |
+| 9 | `[Fix R4-R4-5] …(复核 FR-1 收口)` | 缓存失效源补 `props.messages.length`(删 AI 消息场景)+ 注释/文档同步 |
 
 ## ③ 改动面
 
@@ -40,7 +41,7 @@
 | `src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue` | M3:props 加 `isAdmin?: boolean`(默认 false);`ThinkLevelPopup` **连同前面的 `.action-divider`** 包 `v-if="isAdmin"`。未改 `ThinkLevelPopup` 组件内部 |
 | `src/components/Modal/src/index.less` | R4-4:删除与 `antCss.less` 重复的 `.zxm-modal-close-x` 96px 规则块;保留本块独有的 `font-weight/outline` |
 | `src/views/ventAI/manageAssistent/lib/markdown.ts` | R4-2:`extractMarkdownLinks` 加 `MD_LINK_CACHE_MAX=200` FIFO LRU(与 `renderMarkdownCached` 同型) |
-| `src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue` | R4-5:新增 `getWrappers()` 缓存(`qaPairs.length` watch 失效 + O(1) `container.contains` 存活校验),`updateActivePairByScroll`/`scrollToPair` 改用它,rAF 合并不变 |
+| `src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue` | R4-5:新增 `getWrappers()` 缓存(失效源=`qaPairs.length` + `props.messages.length` + O(1) `container.contains` 存活校验),`updateActivePairByScroll`/`scrollToPair` 改用它,rAF 合并不变 |
 | `src/utils/markdownSafe.ts` | R4-7(顺手):头部注释的旧路径 `chatModal/utils.ts` 更正为 `lib/markdown.ts`(纯注释) |
 | `src/views/ventAI/manageAssistent/hooks/useChatScroll.ts` | R4-8(顺手):`onBeforeUnmount` 时 `cancelAnimationFrame(scrollRafId)` |
 
@@ -93,8 +94,9 @@
 ### R4-5 滚动跟随 wrappers 缓存
 
 - **原来的行为**:`updateActivePairByScroll` 在滚动 rAF 回调里每帧 `querySelectorAll('.message-wrapper')` 全量查询(>100 条时每帧 O(n))。
-- **现在的行为**:查询结果缓存为数组,仅在 `qaPairs.length` watch(已有)失效;另加 O(1) 存活校验(`container.contains` 首个 wrapper),防 DOM 整批替换(如切换任务同问答对数)后用到游离节点。`scrollToPair` 的点击路径也复用。rAF 合并不变。
+- **现在的行为**:查询结果缓存为数组,失效源=**「消息对数量 + 消息条数」**(`watch([qaPairs.length, props.messages.length])`);另加 O(1) 存活校验(`container.contains` 首个 wrapper),防 DOM 整批替换(如切换任务同问答对数)后用到游离节点。`scrollToPair` 的点击路径也复用。rAF 合并不变。
 - **与原来的差别**:滚动期每帧省一次 O(n) DOM 查询;判定逻辑与选中行为不变。
+- **复核后修正(FR-1)**:初版只 watch `qaPairs.length`,而它只随 **user** 消息数变化——删除 AI 消息(`ChatMessages.vue` 的 delete 按钮)不会改它,缓存会陈旧(外观级、可自愈)。已把 `props.messages.length` 并入失效源并同步注释。
 
 ### R4-7 / R4-8(顺手项)
 

+ 8 - 5
src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue

@@ -242,9 +242,11 @@
   // [审计-第四轮 R4-5] wrappers 列表缓存。
   // 原行为:updateActivePairByScroll 在滚动 rAF 回调里每帧对消息容器 querySelectorAll('.message-wrapper')
   //         全量查询(消息 >100 条时每帧一次 O(n) DOM 查询)。
-  // 现行为:查询结果缓存为数组,仅在消息对数量变化(qaPairs.length watch,已存在于下方)时失效重建;
+  // 现行为:查询结果缓存为数组,在"消息对数量**或**消息条数"变化时失效重建(watch 源见下方 297-30x 行);
   //         另加 O(1) 存活校验(容器是否仍持有首个 wrapper),防 DOM 整批替换(如切换任务)后用到游离节点。
-  // 为什么:滚动期每帧省一次 O(n) 查询,rAF 合并不变;消息 DOM 只随消息增删变化,必经 qaPairs.length 变化。
+  // 为什么:滚动期每帧省一次 O(n) 查询,rAF 合并不变;失效源必须同时覆盖两类 DOM 变化——
+  //         qaPairs.length 只随 **user** 消息数变化,而删除 AI 消息(ChatMessages 的 delete 按钮)不改它,
+  //         只盯 qaPairs.length 会漏失效、用到陈旧 wrapper 列表(外观级陈旧,非崩溃)。
   let cachedWrappers: Element[] | null = null;
   const getWrappers = (): Element[] => {
     const container = getContainer();
@@ -292,10 +294,11 @@
     });
   };
 
-  // 消息增减(新问答、加载历史、切换任务)后同步一次选中状态
-  // [审计-第四轮 R4-5] 消息对数量变化意味着 .message-wrapper DOM 已增删,缓存的 wrappers 列表随之失效
+  // 消息增减(新问答、加载历史、切换任务、删除单条消息)后同步一次选中状态
+  // [审计-第四轮 R4-5] 缓存失效源=「消息对数量 + 消息条数」:.message-wrapper DOM 随两者增删变化。
+  // 只盯 qaPairs.length 会漏掉"删除 AI 消息"——user 消息数不变 → qaPairs.length 不变,但 DOM 少一个 wrapper。
   watch(
-    () => qaPairs.value.length,
+    [() => qaPairs.value.length, () => props.messages.length],
     () => {
       cachedWrappers = null;
       nextTick(updateActivePairByScroll);