@ibiz-template-plugin/ai-chat 0.0.119 → 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.
- package/dist/index.es.js +6065 -5729
- package/dist/index.legacy.js +2 -2
- package/dist/style.css +2 -2
- package/dist/types/components/chat-container/chat-container.d.ts +25 -8
- package/dist/types/components/chat-net-disk/file-new-panel/file-new-panel.d.ts +7 -0
- package/dist/types/components/common/breadcrumb/breadcrumb.d.ts +1 -1
- package/dist/types/components/common/index.d.ts +1 -1
- package/dist/types/components/common/{lazy-list-tree/lazy-list-tree.d.ts → lazy-level-list/lazy-level-list.d.ts} +7 -7
- package/dist/types/components/common/lazy-tree/lazy-tree.d.ts +4 -0
- package/dist/types/components/flat-chat-container/flat-chat-container.d.ts +54 -0
- package/dist/types/components/popup/popup.d.ts +2 -1
- package/dist/types/controller/ai-action-panel/ai-action-panel.controller.d.ts +0 -8
- package/dist/types/controller/file-system/file-system.controller.d.ts +26 -3
- package/dist/types/interface/i-file-system-options/i-file-system-options.d.ts +18 -0
- package/dist/types/locale/en/index.d.ts +1 -0
- package/dist/types/locale/zh-CN/index.d.ts +1 -0
- package/docs/msglist-memory-optimization.md +90 -0
- package/package.json +1 -1
|
@@ -166,6 +166,10 @@ export declare class ChatContainer extends Component<ChatContainerProps, ChatCon
|
|
|
166
166
|
* @memberof ChatContainer
|
|
167
167
|
*/
|
|
168
168
|
private interactable;
|
|
169
|
+
/**
|
|
170
|
+
* resize 节流句柄
|
|
171
|
+
*/
|
|
172
|
+
private resizeRafId;
|
|
169
173
|
/**
|
|
170
174
|
* 消息完成事件回调
|
|
171
175
|
*/
|
|
@@ -174,6 +178,19 @@ export declare class ChatContainer extends Component<ChatContainerProps, ChatCon
|
|
|
174
178
|
* 最小化状态变化事件回调
|
|
175
179
|
*/
|
|
176
180
|
private onMinimizeChangeHandler;
|
|
181
|
+
/**
|
|
182
|
+
* 浏览器窗口尺寸变化处理
|
|
183
|
+
* - 触发节流后统一重算:容器最小宽高、左侧栏宽度、右侧面板宽度
|
|
184
|
+
*/
|
|
185
|
+
private handleWindowResize;
|
|
186
|
+
/**
|
|
187
|
+
* 浏览器窗口尺寸变化后的重算逻辑
|
|
188
|
+
* 1. 校正窗口自身尺寸,不低于最小宽高,且不超出可视区域
|
|
189
|
+
* 2. 收敛左侧栏宽度到 [左最小宽, 容器宽 - 中最小宽 - 右占用]
|
|
190
|
+
* 3. 收敛右侧面板宽度到 [右最小宽, 容器宽 - 中最小宽 - 左占用]
|
|
191
|
+
* 4. 同步输入区高度,保证消息区最小高
|
|
192
|
+
*/
|
|
193
|
+
private recalcOnResize;
|
|
177
194
|
listener(): void;
|
|
178
195
|
componentDidUpdate(): void;
|
|
179
196
|
/**
|
|
@@ -382,6 +399,7 @@ export declare class ChatContainer extends Component<ChatContainerProps, ChatCon
|
|
|
382
399
|
getRightWidth(): string;
|
|
383
400
|
/**
|
|
384
401
|
* @description 获取宽度
|
|
402
|
+
* @private
|
|
385
403
|
* @returns {*} {{
|
|
386
404
|
* centreMinWidth: number;
|
|
387
405
|
* leftWidth: number;
|
|
@@ -391,13 +409,7 @@ export declare class ChatContainer extends Component<ChatContainerProps, ChatCon
|
|
|
391
409
|
* }}
|
|
392
410
|
* @memberof ChatContainer
|
|
393
411
|
*/
|
|
394
|
-
getWidth
|
|
395
|
-
centreMinWidth: number;
|
|
396
|
-
leftWidth: number;
|
|
397
|
-
rightWidth: number;
|
|
398
|
-
leftMinWidth: number;
|
|
399
|
-
rightMinWidth: number;
|
|
400
|
-
};
|
|
412
|
+
private getWidth;
|
|
401
413
|
/**
|
|
402
414
|
* 侧栏宽度拖动开始
|
|
403
415
|
*/
|
|
@@ -411,7 +423,7 @@ export declare class ChatContainer extends Component<ChatContainerProps, ChatCon
|
|
|
411
423
|
*/
|
|
412
424
|
handleSidebarResizeEnd: () => void;
|
|
413
425
|
/**
|
|
414
|
-
* 将侧栏宽度校正到合理区间 [
|
|
426
|
+
* 将侧栏宽度校正到合理区间 [最小宽, 容器宽度-右侧最小宽]
|
|
415
427
|
*/
|
|
416
428
|
clampSidebarWidth(width: number): number;
|
|
417
429
|
/**
|
|
@@ -460,6 +472,11 @@ export declare class ChatContainer extends Component<ChatContainerProps, ChatCon
|
|
|
460
472
|
* 提交拖动边框后的输入区高度(持久化到状态与本地缓存)
|
|
461
473
|
*/
|
|
462
474
|
commitInputHeight: () => void;
|
|
475
|
+
/**
|
|
476
|
+
* 将面板宽度矫正到合理区间 [最小宽, 容器宽度-左侧最小宽]
|
|
477
|
+
* @param width
|
|
478
|
+
*/
|
|
479
|
+
clampPanelWidth(width: number): void;
|
|
463
480
|
/**
|
|
464
481
|
* 操作区面板宽度拖动开始
|
|
465
482
|
*/
|
|
@@ -22,6 +22,13 @@ interface FileNewPanelProps {
|
|
|
22
22
|
ok: boolean;
|
|
23
23
|
data: LazyTreeNode;
|
|
24
24
|
}) => void;
|
|
25
|
+
/**
|
|
26
|
+
* @description 后缀名
|
|
27
|
+
* @default '.txt'
|
|
28
|
+
* @type {string}
|
|
29
|
+
* @memberof FileNewPanelProps
|
|
30
|
+
*/
|
|
31
|
+
suffixName?: string;
|
|
25
32
|
}
|
|
26
33
|
export declare const FileNewPanel: (props: FileNewPanelProps) => import("preact").JSX.Element;
|
|
27
34
|
export {};
|
|
@@ -5,7 +5,7 @@ export { Slider } from './slider/slider';
|
|
|
5
5
|
export { Switch } from './switch/switch';
|
|
6
6
|
export { Actions, type IAction } from './actions/actions';
|
|
7
7
|
export { Breadcrumb } from './breadcrumb/breadcrumb';
|
|
8
|
-
export {
|
|
8
|
+
export { LazyLevelList, type LazyLevelListRef, type LazyLevelListMode, } from './lazy-level-list/lazy-level-list';
|
|
9
9
|
export { ContextMenu, type IContextMenuAction, } from './context-menu/context-menu';
|
|
10
10
|
export { Drawer } from './drawer/drawer';
|
|
11
11
|
export { LazyTree, type LazyTreeRef, type LazyTreeNode, type INodeEditInfo, } from './lazy-tree/lazy-tree';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { INodeEditInfo, LazyTreeNode } from '../lazy-tree/lazy-tree';
|
|
2
2
|
import { IAction } from '../actions/actions';
|
|
3
3
|
|
|
4
|
-
export type
|
|
5
|
-
interface
|
|
4
|
+
export type LazyLevelListMode = 'list' | 'card';
|
|
5
|
+
interface LazyLevelListProps {
|
|
6
6
|
/**
|
|
7
7
|
* 根节点数据
|
|
8
8
|
*/
|
|
@@ -83,17 +83,17 @@ interface LazyListTreeProps {
|
|
|
83
83
|
* 展示模式
|
|
84
84
|
* @default 'list'
|
|
85
85
|
*/
|
|
86
|
-
mode?:
|
|
86
|
+
mode?: LazyLevelListMode;
|
|
87
87
|
}
|
|
88
|
-
export interface
|
|
88
|
+
export interface LazyLevelListRef {
|
|
89
89
|
/**
|
|
90
90
|
* 重载
|
|
91
91
|
* @param
|
|
92
92
|
* @returns
|
|
93
93
|
*/
|
|
94
|
-
overloadNode: () => Promise<void>;
|
|
94
|
+
overloadNode: (node: LazyTreeNode) => Promise<void>;
|
|
95
95
|
}
|
|
96
|
-
export declare const
|
|
97
|
-
ref?: import('preact').Ref<
|
|
96
|
+
export declare const LazyLevelList: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<LazyLevelListProps> & {
|
|
97
|
+
ref?: import('preact').Ref<LazyLevelListRef> | undefined;
|
|
98
98
|
}>;
|
|
99
99
|
export {};
|
|
@@ -112,10 +112,42 @@ export declare class FlatChatContainer extends Component<FlatChatContainerProps>
|
|
|
112
112
|
* 操作区面板宽度拖动起始值(px)
|
|
113
113
|
*/
|
|
114
114
|
private panelDragStartWidth;
|
|
115
|
+
/**
|
|
116
|
+
* resize 节流句柄
|
|
117
|
+
*/
|
|
118
|
+
private resizeRafId;
|
|
115
119
|
state: {
|
|
116
120
|
mountedSlots: Record<string, boolean>;
|
|
117
121
|
inputHeight: number | null;
|
|
118
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* @description 获取宽度
|
|
125
|
+
* @private
|
|
126
|
+
* @returns {*} {{
|
|
127
|
+
* centreMinWidth: number;
|
|
128
|
+
* leftWidth: number;
|
|
129
|
+
* rightWidth: number;
|
|
130
|
+
* leftMinWidth: number;
|
|
131
|
+
* rightMinWidth: number;
|
|
132
|
+
* }}
|
|
133
|
+
* @memberof FlatChatContainer
|
|
134
|
+
*/
|
|
135
|
+
private getWidth;
|
|
136
|
+
/**
|
|
137
|
+
* 浏览器窗口尺寸变化处理
|
|
138
|
+
* - 触发节流后统一重算:容器最小宽高、左侧栏宽度、右侧面板宽度
|
|
139
|
+
*/
|
|
140
|
+
private handleWindowResize;
|
|
141
|
+
/**
|
|
142
|
+
* @description 浏览器窗口尺寸变化后的重算逻辑
|
|
143
|
+
* 1. 收敛左侧栏宽度到 [左最小宽, 容器宽 - 中最小宽 - 右占用]
|
|
144
|
+
* 2. 收敛右侧面板宽度到 [右最小宽, 容器宽 - 中最小宽 - 左占用]
|
|
145
|
+
* 3. 同步输入区高度,保证消息区最小高
|
|
146
|
+
* @private
|
|
147
|
+
* @returns {*} {void}
|
|
148
|
+
* @memberof FlatChatContainer
|
|
149
|
+
*/
|
|
150
|
+
private recalcOnResize;
|
|
119
151
|
/**
|
|
120
152
|
* @description 添加扩展插槽
|
|
121
153
|
* @private
|
|
@@ -131,11 +163,24 @@ export declare class FlatChatContainer extends Component<FlatChatContainerProps>
|
|
|
131
163
|
* @memberof FlatChatContainer
|
|
132
164
|
*/
|
|
133
165
|
componentDidMount(): void;
|
|
166
|
+
/**
|
|
167
|
+
* @description 销毁
|
|
168
|
+
* @memberof FlatChatContainer
|
|
169
|
+
*/
|
|
170
|
+
componentWillUnmount(): void;
|
|
134
171
|
/**
|
|
135
172
|
* @description 更新
|
|
136
173
|
* @memberof FlatChatContainer
|
|
137
174
|
*/
|
|
138
175
|
componentDidUpdate(prevProps: FlatChatContainerProps): void;
|
|
176
|
+
/**
|
|
177
|
+
* 拖动容器边框时实时校正输入区高度,保证消息区不小于最小高度
|
|
178
|
+
*/
|
|
179
|
+
syncInputHeight: () => void;
|
|
180
|
+
/**
|
|
181
|
+
* 提交拖动边框后的输入区高度(持久化到状态与本地缓存)
|
|
182
|
+
*/
|
|
183
|
+
commitInputHeight: () => void;
|
|
139
184
|
/**
|
|
140
185
|
* @description 同步操作区面板状态(展开收起动画由 CSS 过渡完成)
|
|
141
186
|
* @memberof FlatChatContainer
|
|
@@ -166,6 +211,10 @@ export declare class FlatChatContainer extends Component<FlatChatContainerProps>
|
|
|
166
211
|
* 右侧聊天区宽度
|
|
167
212
|
*/
|
|
168
213
|
getRightWidth(): string;
|
|
214
|
+
/**
|
|
215
|
+
* 将侧栏宽度校正到合理区间 [最小宽, 容器宽度-右侧最小宽]
|
|
216
|
+
*/
|
|
217
|
+
clampSidebarWidth(width: number): number;
|
|
169
218
|
/**
|
|
170
219
|
* 侧栏宽度拖动开始
|
|
171
220
|
*/
|
|
@@ -178,6 +227,11 @@ export declare class FlatChatContainer extends Component<FlatChatContainerProps>
|
|
|
178
227
|
* 侧栏宽度拖动结束
|
|
179
228
|
*/
|
|
180
229
|
handleSidebarResizeEnd: () => void;
|
|
230
|
+
/**
|
|
231
|
+
* 将面板宽度矫正到合理区间 [最小宽, 容器宽度-左侧最小宽]
|
|
232
|
+
* @param width
|
|
233
|
+
*/
|
|
234
|
+
clampPanelWidth(width: number): void;
|
|
181
235
|
/**
|
|
182
236
|
* 操作区面板宽度拖动开始
|
|
183
237
|
*/
|
|
@@ -6,7 +6,7 @@ interface ActionItem {
|
|
|
6
6
|
caption: string;
|
|
7
7
|
icon?: VNode;
|
|
8
8
|
}
|
|
9
|
-
export declare const Popup: ({ triggerMode, children, actions, content, position, isOpen: isOpenProp, onToggleOpen, popupClassName, onAction, }: {
|
|
9
|
+
export declare const Popup: ({ triggerMode, children, actions, content, disabled, position, isOpen: isOpenProp, onToggleOpen, popupClassName, onAction, }: {
|
|
10
10
|
triggerMode: "click" | "hover";
|
|
11
11
|
children: import('preact').ComponentChildren;
|
|
12
12
|
actions?: ActionItem[];
|
|
@@ -14,6 +14,7 @@ export declare const Popup: ({ triggerMode, children, actions, content, position
|
|
|
14
14
|
position?: Position;
|
|
15
15
|
isOpen?: boolean;
|
|
16
16
|
popupClassName?: string;
|
|
17
|
+
disabled?: boolean;
|
|
17
18
|
onToggleOpen?: (isOpen: boolean) => void;
|
|
18
19
|
onAction?: (actionId: string, event: MouseEvent) => void;
|
|
19
20
|
}) => import("preact").JSX.Element;
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { Signal } from '@preact/signals';
|
|
2
2
|
import { TActionPanelComponent } from '../../interface';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* 将操作区面板宽度校正到合理区间 [最小宽度, 容器宽度/3]
|
|
6
|
-
*
|
|
7
|
-
* @param {number} width 目标宽度
|
|
8
|
-
* @param {number} containerWidth 容器整体宽度
|
|
9
|
-
* @returns {*} {number}
|
|
10
|
-
*/
|
|
11
|
-
export declare function clampPanelWidth(width: number, containerWidth: number): number;
|
|
12
4
|
/**
|
|
13
5
|
* 操作区面板控制器
|
|
14
6
|
* - 面板为容器级单例,挂在 ChatBaseController 上,切话题不重建
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IFile, IFileItem, IMessageUtil, IFileItemParams, IFileUploadPrams, IFileSystemOptions, FileUploaderOptions } from '../../interface';
|
|
1
|
+
import { IFile, IFileItem, IMessageUtil, IConfirmUtil, IFileItemParams, IFileUploadPrams, IFileSystemOptions, FileUploaderOptions } from '../../interface';
|
|
2
2
|
import { AiChatController } from '../ai-chat/ai-chat.controller';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -38,6 +38,13 @@ export declare class FileSystemController {
|
|
|
38
38
|
* @memberof FileSystemController
|
|
39
39
|
*/
|
|
40
40
|
get message(): IMessageUtil | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* @description 提示
|
|
43
|
+
* @readonly
|
|
44
|
+
* @type {(IConfirmUtil | undefined)}
|
|
45
|
+
* @memberof FileSystemController
|
|
46
|
+
*/
|
|
47
|
+
get confirm(): IConfirmUtil | undefined;
|
|
41
48
|
/**
|
|
42
49
|
* @description 文件预览模式
|
|
43
50
|
* @readonly
|
|
@@ -66,12 +73,12 @@ export declare class FileSystemController {
|
|
|
66
73
|
*/
|
|
67
74
|
fetch(params: IFileItemParams): Promise<IFileItem | undefined>;
|
|
68
75
|
/**
|
|
69
|
-
* @description
|
|
76
|
+
* @description 系统文件上传
|
|
70
77
|
* @param {IFileUploadPrams} params
|
|
71
78
|
* @returns {*} {(Promise<IFile | undefined>)}
|
|
72
79
|
* @memberof FileSystemController
|
|
73
80
|
*/
|
|
74
|
-
|
|
81
|
+
uploadSystemFile(params: IFileUploadPrams): Promise<IFile | undefined>;
|
|
75
82
|
/**
|
|
76
83
|
* @description 获取文件内容
|
|
77
84
|
* @param {string} id
|
|
@@ -149,4 +156,20 @@ export declare class FileSystemController {
|
|
|
149
156
|
* @memberof FileSystemController
|
|
150
157
|
*/
|
|
151
158
|
delete(file_path: string): Promise<boolean>;
|
|
159
|
+
/**
|
|
160
|
+
* @description 选择文件
|
|
161
|
+
* @param {string} [accept='']
|
|
162
|
+
* @param {boolean} [multiple=false]
|
|
163
|
+
* @returns {*} {(Promise<FileList | undefined>)}
|
|
164
|
+
* @memberof FileSystemController
|
|
165
|
+
*/
|
|
166
|
+
selectFile(accept?: string, multiple?: boolean): Promise<FileList | undefined>;
|
|
167
|
+
/**
|
|
168
|
+
* @description 上传文件到系统
|
|
169
|
+
* @param {string} file_path 文件路径
|
|
170
|
+
* @param {File} file 文件
|
|
171
|
+
* @returns {*} {Promise<void>}
|
|
172
|
+
* @memberof FileSystemController
|
|
173
|
+
*/
|
|
174
|
+
uploadFileToSystem(file_path: string, file: File): Promise<boolean>;
|
|
152
175
|
}
|
|
@@ -23,6 +23,19 @@ interface IFileSystemUploadParams {
|
|
|
23
23
|
srfaiagent?: string;
|
|
24
24
|
srfscope?: string;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* @description 下载文件参数
|
|
28
|
+
* @interface IFileSystemDownLoadParams
|
|
29
|
+
*/
|
|
30
|
+
interface IFileSystemDownLoadParams {
|
|
31
|
+
sessionid: string;
|
|
32
|
+
folder: string;
|
|
33
|
+
id: string;
|
|
34
|
+
file_path: string;
|
|
35
|
+
srfaiagent?: string;
|
|
36
|
+
srfscope?: string;
|
|
37
|
+
timeout?: number;
|
|
38
|
+
}
|
|
26
39
|
/**
|
|
27
40
|
* @description 新建目录参数
|
|
28
41
|
* @interface IFileSystemCreateParams
|
|
@@ -143,6 +156,11 @@ export interface IFileSystemOptions {
|
|
|
143
156
|
name: string;
|
|
144
157
|
folder: string;
|
|
145
158
|
} | undefined>;
|
|
159
|
+
/**
|
|
160
|
+
* @description 下载文件
|
|
161
|
+
* @memberof IFileSystemOptions
|
|
162
|
+
*/
|
|
163
|
+
downloadFile: (ctx: object, param: object, other: IFileSystemDownLoadParams) => Promise<IFileSystemResult>;
|
|
146
164
|
/**
|
|
147
165
|
* @description 获取文件内容
|
|
148
166
|
* @param {object} ctx
|
|
@@ -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 失活/激活恢复、话题切换、向上滚动渐进扩展与历史加载。
|