@ibiz-template-plugin/ai-chat 0.0.118 → 0.0.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ # 消息列表内存优化:视口外占位回收(msglist-memory-optimization)
2
+
3
+ > 记录本次「聊天消息列表内存暴增/崩溃」优化的背景、方案演进与现行实现机制,供后续新增任务与 bug 修复参考。
4
+ > 涉及文件:`src/components/chat-message-item/chat-message-item.tsx`(核心)、`markdown-message.tsx` / `user-message.tsx` / `error-message.tsx`(配套 Cherry 清理)。
5
+
6
+ ## 1. 背景:为什么内存会崩溃
7
+
8
+ - 消息正文由 Cherry Markdown 渲染,每个实例内含 CodeMirror、解析器、事件监听器,**单实例内存开销大**。
9
+ - 原方案「渐进真实绘制 + 绘制过永久留 DOM」(`chat-messages.tsx`):初始绘制最新 20 条,向上滚动逐批扩展,**绘制过的消息永久真实渲染**。
10
+ - 结果:长会话从头滚到尾后,所有消息的 Cherry 实例常驻内存,内存随滚动单调增长,最终崩溃。
11
+
12
+ ## 2. 方案演进(均已验证并放弃,勿恢复)
13
+
14
+ | 方案 | 放弃原因 |
15
+ | --- | --- |
16
+ | 自研虚拟列表(绝对定位 + spacer) | 滚动条抖动严重 |
17
+ | react-virtuoso | 滚动条乱滚/抖动 |
18
+ | 列表级 IO 驱动 + 估算参与(rAF 队列/距离排序/renderStartIndex 收窄) | 复杂度高,滚动条抖动未解决,被有意推翻 |
19
+ | 估算高度占位 | 高度失准导致滚动条跳动 |
20
+ | 图片 lazy 加载 | 加载后撑高导致占位失准 |
21
+ | CSS `content-visibility: auto` | 只跳过渲染不卸载 DOM,Cherry/CodeMirror 内存无法释放,不解决根本问题 |
22
+
23
+ **现行方案**:渐进渲染(列表级,管「从未绘制的更早历史」)+ item 级实测高度占位(管「绘制过但离开视口的消息」),两层互补,`chat-messages.tsx` / `use-message-renderer.ts` 不做改动。
24
+
25
+ ## 3. 现行机制:item 级「实测高度占位 + 双缓冲过渡」
26
+
27
+ 全部逻辑在 `ChatMessageItem` 内自管理(`chat-message-item.tsx`)。
28
+
29
+ ### 3.1 状态
30
+
31
+ | 状态 | 实现 | 说明 |
32
+ | --- | --- | --- |
33
+ | `height` | `useSignal(0)` | 高度缓存:RO 持续写入组件实际外高(小数),随图片加载/Cherry 异步渲染同步更新 |
34
+ | `phase` | `useSignal<Phase>('real')` | `real`(真实绘制)/ `placeholder`(占位)/ `transition`(双缓冲过渡) |
35
+ | `isWaitingRef` | `useRef` | 流式标记,IO 回调闭包经它读取,避免持有过期 message |
36
+
37
+ ### 3.2 阶段流转
38
+
39
+ ```
40
+ ┌──────────────────────────────────────────────┐
41
+ │ IO isIntersecting=true │
42
+ ┌──────┐ │ (占位继续锚定,真实组件隐藏重挂) ┌────┐ │
43
+ │ real │──────┼─────────────────────────────────▶ │占位 │◀───┼── IO isIntersecting=false
44
+ └──────┘ ▼ └────┘ │ (iswaiting/height=0 不占位)
45
+ transition ── RO 实测隐藏中的真实组件: ▲
46
+ │ ├ 高度≈缓存(≤0.5px) ──▶ 切 real │
47
+ │ └ 不一致且稳定 100ms ──▶ 接受新高度切 real(只动一次滚动条)
48
+ └── 滚走可随时中断回 placeholder
49
+ ```
50
+
51
+ - **real → placeholder**:滚出视口(150% 缓冲带外)→ 卸载真实组件(Cherry destroy 释放内存)→ 渲染 `height` 精确高度的占位 div。
52
+ - **placeholder → transition**:进入视口 → 占位**继续留在文档流锚定 scrollHeight**,真实组件以 `absolute + visibility: hidden` 隐藏重挂(不影响布局)。
53
+ - **transition → real**:RO 实测隐藏真实组件,高度与缓存一致(差 ≤ `HEIGHT_EPSILON`=0.5px)立即切换;不一致则无 RO 事件稳定 `SETTLE_DELAY`=100ms 后接受新高度(内容真实变化时只动一次滚动条)。
54
+
55
+ **为什么需要双缓冲**:直接占位→真实重挂载时,内容重建(Cherry 异步初始化、代码高亮重跑、图片重新解码)会产生高度瞬变,`scrollHeight` 反复变化 → 滚动条跳动。双缓冲下过渡全程 scrollHeight 零变化。
56
+
57
+ ### 3.3 关键实现细节(改代码前必读)
58
+
59
+ 1. **观察目标分工**:IO 观察稳定的 wrapper(`containerRef`,含 `id=messageid`,永不重挂);RO 观察真实组件容器(`realRef`,transition 期间是隐藏的 absolute 元素)。
60
+ 2. **测高用 `borderBoxSize[0].blockSize`**:占位 div 直接替换真实组件容器,高度必须与元素**外高**完全一致;`contentBox`/`clientHeight` 会漏掉 padding 导致漂移。
61
+ 3. **wrapper `minHeight + boxSizing: 'content-box'` 锁定**:挡住真实绘制阶段的「变矮」瞬变;显式 content-box 防宿主应用全局 border-box 重置(否则 min-height 会多算 wrapper 上下 6px padding)。用 min-height 而非 height:视口内正常长高(图片、流式)仍能跟随。
62
+ 4. **`position: relative` 仅在 transition 期间设置**:真实阶段 wrapper 无定位,工具栏弹层的定位基准不变(避免影响既有弹层行为)。
63
+ 5. **IO 配置**:`root=null`(视口)——intersectionRect 会被中间滚动容器裁剪,滚出容器即不相交,组件自包含、多聊天实例可用;`rootMargin: '150% 0px'` 提供缓冲带减少切换频率。
64
+ 6. **三类守卫**(缺一不可):
65
+ - `el.offsetParent === null`:容器隐藏(keep-alive 失活)忽略 IO 事件,激活后 IO 自动重估;
66
+ - `iswaiting === true`:流式消息高度持续增长,不占位;
67
+ - `height <= 0`:未实测过高度不占位(真实渲染至少一次才有占位资格)。
68
+ 7. **Cherry destroy 清理**(占位回收释放内存的前提,勿删):`markdown-message.tsx` / `user-message.tsx` / `error-message.tsx` 卸载时 `cherry.destroy()`,`deep-research-result.tsx:218` 已有。Cherry 均为单实例守卫(有实例则 `setMarkdown`),无重复创建泄漏。
69
+
70
+ ### 3.4 与 chat-messages(列表层)的关系
71
+
72
+ - 列表层的渐进渲染(初始 20 条 + 逐帧切片 + 触顶加载历史)、贴底判定、keep-alive 恢复**全部未动**。
73
+ - 消息首次被渐进渲染纳入 → item 挂载实测高度 → 滚出视口自动被 item 占位回收 → 再次进入视口经 transition 恢复。
74
+ - 话题切换:messages 清空重载,item 卸载重建,item 内状态(height/phase)自然重置。
75
+
76
+ ## 4. 已知边界与注意事项(新增任务/修 bug 指引)
77
+
78
+ 1. **transition 兜底路径的瞬变风险**:若重挂后内容迟迟不渲染(如 Cherry 初始化阻塞 >100ms 且期间无布局变化触发 RO),settle 定时器会接受「中间态小高度」,内容渲染完成后再一次长高 → 单次跳动。概率低;若实测出现,调大 `SETTLE_DELAY`(150~200ms)或在 settle 接受时要求 `boxHeight > 0`。
79
+ 2. **不要引入**:估算高度占位、图片 lazy、绝对定位虚拟列表、手动 scrollTop 补偿——均已被实测否决(见第 2 节)。
80
+ 3. **不要删除**:wrapper 的 minHeight 锁定、transition 双缓冲、三类守卫、Cherry destroy——滚动条稳定性与内存回收都依赖它们。
81
+ 4. **占位回收会卸载 ChatToolbar**(children 随真实组件卸载),视口外工具栏状态不保留;如需保留状态需提升到 controller 层。
82
+ 5. **tsc 已知既有错误**:`chat-message-item.tsx` 的 TS2769(`h(com, ...)` 联合类型)在优化前就存在(HEAD 同样报错),仓库多处历史类型错误导致 `pnpm build` 失败,与本次改动无关;本文件 eslint 通过。
83
+ 6. **`vite.config.ts` 的 `isDebug=true`** 为本地调试构建配置(sourcemap/不压缩/preserveModules),**不要随功能一起提交**。
84
+
85
+ ## 5. 验证清单(改动后回归)
86
+
87
+ 1. **内存**:长会话来回滚动一遍 → DevTools Performance Monitor 内存曲线有界;heap snapshot 无 Cherry/CodeMirror 残留实例。
88
+ 2. **滚动条稳定**:快速拖动滚动条,占位↔真实切换全程 thumb 无跳动/闪动。
89
+ 3. **内容填充**:快速拖动经过区域 0.5s 内填满,无长时间空白。
90
+ 4. **回归**:贴底跟随、发送消息强制贴底、流式输出不占位、回到底部按钮、keep-alive 失活/激活恢复、话题切换、向上滚动渐进扩展与历史加载。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibiz-template-plugin/ai-chat",
3
- "version": "0.0.118",
3
+ "version": "0.0.120",
4
4
  "description": "iBiz AI交互框插件",
5
5
  "type": "module",
6
6
  "main": "dist/index.es.js",