perf(chat): 对话区 virtua 窗口化渲染,解决挤压卡顿与全量渲染
- v-for 全量渲染改为 virtua Virtualizer 窗口化,只挂载可视区+buffer 消息块 - 追底引擎 resize 弹簧动画改 instant:拖拽挤压时逐帧瞬时吸底,内容向上生长 - 消息按对象身份(WeakMap)分配稳定 key,支撑 virtua 逐项高度缓存 - 用户气泡随滚动重挂载时补测量/补观察,保证折叠状态正确恢复 - 滚动追踪日志默认关闭(逐scroll事件日志本身是每帧开销),排障可显式开启 - 修复 2 处依赖 DOM 层级的 CSS 结构选择器 - 新增依赖 virtua(~3kB)
This commit is contained in:
parent
d891ccf08f
commit
2b84a7adf8
31
package-lock.json
generated
31
package-lock.json
generated
@ -35,6 +35,7 @@
|
|||||||
"sherpa-onnx": "^1.13.2",
|
"sherpa-onnx": "^1.13.2",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"virtua": "^0.49.3",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-stick-to-bottom": "^1.0.0",
|
"vue-stick-to-bottom": "^1.0.0",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
@ -7463,6 +7464,36 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/virtua": {
|
||||||
|
"version": "0.49.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.49.3.tgz",
|
||||||
|
"integrity": "sha512-k1Yn988Vz/L40uDtEWPjfdVo15Suumh4tU4/z5Srs0elNcU9DgBskqdh3llpHyAlXrCeHWTfcclYvY1uU4MmIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.14.0",
|
||||||
|
"react-dom": ">=16.14.0",
|
||||||
|
"solid-js": ">=1.0",
|
||||||
|
"svelte": ">=5.0",
|
||||||
|
"vue": ">=3.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
"sherpa-onnx": "^1.13.2",
|
"sherpa-onnx": "^1.13.2",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"virtua": "^0.49.3",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-stick-to-bottom": "^1.0.0",
|
"vue-stick-to-bottom": "^1.0.0",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
|
|||||||
@ -5,15 +5,24 @@
|
|||||||
ref="scrollRef"
|
ref="scrollRef"
|
||||||
>
|
>
|
||||||
<div class="messages-flow" ref="contentRef">
|
<div class="messages-flow" ref="contentRef">
|
||||||
|
<!-- 窗口化渲染:virtua 只挂载可视区+buffer 的消息块,未挂载项按实测高度缓存占位 -->
|
||||||
|
<Virtualizer
|
||||||
|
v-if="stickScrollElement"
|
||||||
|
:data="filteredMessages || []"
|
||||||
|
:scroll-ref="stickScrollElement"
|
||||||
|
:buffer-size="400"
|
||||||
|
:keep-mounted="keepMountedIndexes"
|
||||||
|
>
|
||||||
|
<template #default="{ item: msg, index }">
|
||||||
<div
|
<div
|
||||||
v-for="(msg, index) in filteredMessages || []"
|
:key="getMessageKey(msg, index)"
|
||||||
:key="index"
|
|
||||||
class="message-block"
|
class="message-block"
|
||||||
:class="{
|
:class="{
|
||||||
'message-block--compact-user': getMessageVisibility(msg) === 'compact',
|
'message-block--compact-user': getMessageVisibility(msg) === 'compact',
|
||||||
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
|
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
|
||||||
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index),
|
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index),
|
||||||
'message-block--multi-agent': isMultiAgentMessage(msg),
|
'message-block--multi-agent': isMultiAgentMessage(msg),
|
||||||
|
'message-block--first': index === 0,
|
||||||
'message-block--last': index === (filteredMessages || []).length - 1
|
'message-block--last': index === (filteredMessages || []).length - 1
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@ -635,18 +644,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else class="system-message">
|
||||||
v-else
|
|
||||||
class="system-message"
|
|
||||||
@vue:mounted="
|
|
||||||
() =>
|
|
||||||
console.log('[ChatArea] 渲染系统消息:', {
|
|
||||||
index,
|
|
||||||
role: msg.role,
|
|
||||||
content: msg.content
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="collapsible-block system-block"
|
class="collapsible-block system-block"
|
||||||
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
|
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
|
||||||
@ -674,6 +672,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Virtualizer>
|
||||||
</div>
|
</div>
|
||||||
<div class="messages-bottom-spacer" aria-hidden="true"></div>
|
<div class="messages-bottom-spacer" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -682,6 +682,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||||
import { useStickToBottom } from 'vue-stick-to-bottom';
|
import { useStickToBottom } from 'vue-stick-to-bottom';
|
||||||
|
import { Virtualizer } from 'virtua/vue';
|
||||||
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
||||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||||
import StackedBlocks from './StackedBlocks.vue';
|
import StackedBlocks from './StackedBlocks.vue';
|
||||||
@ -773,6 +774,16 @@ const registerUserBubbleRef = (msg: any, index: number, el: Element | null) => {
|
|||||||
const key = getUserBubbleKey(msg, index);
|
const key = getUserBubbleKey(msg, index);
|
||||||
if (el instanceof HTMLElement) {
|
if (el instanceof HTMLElement) {
|
||||||
userBubbleRefs.set(key, el);
|
userBubbleRefs.set(key, el);
|
||||||
|
// 虚拟列表下气泡随滚动挂载/卸载:挂载时补做观察与高度测量,保证折叠状态正确恢复
|
||||||
|
if (userBubbleResizeObserver) {
|
||||||
|
userBubbleResizeObserver.observe(el);
|
||||||
|
}
|
||||||
|
const state = userBubbleFoldStates.value[key];
|
||||||
|
if (state && state.foldHeight === 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (el.isConnected) measureUserBubbles();
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
userBubbleRefs.delete(key);
|
userBubbleRefs.delete(key);
|
||||||
}
|
}
|
||||||
@ -1128,14 +1139,39 @@ const {
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
stopScroll
|
stopScroll
|
||||||
} = useStickToBottom({
|
} = useStickToBottom({
|
||||||
resize: {
|
// 宽度拖拽挤压时不再弹簧追底:逐帧瞬时吸附底部,
|
||||||
damping: 0.5,
|
// 视觉上内容向上生长(底部锚定),消除“变高→追底”的反复计算循环
|
||||||
stiffness: 0.28,
|
resize: 'instant',
|
||||||
mass: 0.75
|
|
||||||
},
|
|
||||||
initial: 'instant'
|
initial: 'instant'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// —— 虚拟列表支撑 ——
|
||||||
|
// 消息对象没有后端 id,这里按对象身份(WeakMap)分配稳定 key,
|
||||||
|
// 供 virtua 的逐项高度缓存与列表项复用,避免改动 store / 后端消息结构
|
||||||
|
const messageKeyCache = new WeakMap<object, string>();
|
||||||
|
let messageKeySeq = 0;
|
||||||
|
const getMessageKey = (msg: any, index: number): string => {
|
||||||
|
if (msg && typeof msg === 'object') {
|
||||||
|
let key = messageKeyCache.get(msg as object);
|
||||||
|
if (!key) {
|
||||||
|
const rawId = (msg as any).id;
|
||||||
|
key = rawId != null ? `msg-${String(rawId)}` : `mk-${++messageKeySeq}`;
|
||||||
|
messageKeyCache.set(msg as object, key);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return `idx-${index}`;
|
||||||
|
};
|
||||||
|
// Virtualizer 需要显式拿到滚动容器(其直接父元素 .messages-flow 并非滚动容器),
|
||||||
|
// 等模板 ref 就绪后再挂载 Virtualizer,确保其内部滚动监听一次到位
|
||||||
|
const stickScrollElement = computed<HTMLElement | undefined>(() => scrollRef.value ?? undefined);
|
||||||
|
// 流式期间保持最后一条消息常驻,避免用户上翻时流式块反复卸载/重挂载
|
||||||
|
const keepMountedIndexes = computed<number[]>(() => {
|
||||||
|
if (!props.streamingMessage) return [];
|
||||||
|
const last = latestMessageIndex.value;
|
||||||
|
return last >= 0 ? [last] : [];
|
||||||
|
});
|
||||||
|
|
||||||
// 公式渲染后如果已经在底部附近,主动追底,避免 KaTeX 渲染导致的高度跳变让 stick-to-bottom 锁不住
|
// 公式渲染后如果已经在底部附近,主动追底,避免 KaTeX 渲染导致的高度跳变让 stick-to-bottom 锁不住
|
||||||
provide('mathRenderedCallback', () => {
|
provide('mathRenderedCallback', () => {
|
||||||
if (isNearBottom.value && stickScrollToBottom) {
|
if (isNearBottom.value && stickScrollToBottom) {
|
||||||
@ -1168,7 +1204,9 @@ let wheelListener: ((event: WheelEvent) => void) | null = null;
|
|||||||
let traceAttachLogged = false;
|
let traceAttachLogged = false;
|
||||||
|
|
||||||
function isScrollBounceTraceEnabled() {
|
function isScrollBounceTraceEnabled() {
|
||||||
if (typeof window === 'undefined') return true;
|
// 默认关闭滚动追踪日志(逐 scroll 事件的高频日志本身就是性能开销),
|
||||||
|
// 排障时通过 window.__SCROLL_BOUNCE_TRACE__ = true 或 localStorage.scrollBounceTrace = '1' 显式打开
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
try {
|
try {
|
||||||
const explicit = (window as any).__SCROLL_BOUNCE_TRACE__;
|
const explicit = (window as any).__SCROLL_BOUNCE_TRACE__;
|
||||||
if (explicit === false || explicit === '0') return false;
|
if (explicit === false || explicit === '0') return false;
|
||||||
@ -1176,9 +1214,9 @@ function isScrollBounceTraceEnabled() {
|
|||||||
const localFlag = window.localStorage?.getItem('scrollBounceTrace');
|
const localFlag = window.localStorage?.getItem('scrollBounceTrace');
|
||||||
if (localFlag === '0' || localFlag === 'false') return false;
|
if (localFlag === '0' || localFlag === 'false') return false;
|
||||||
if (localFlag === '1' || localFlag === 'true') return true;
|
if (localFlag === '1' || localFlag === 'true') return true;
|
||||||
return true;
|
return false;
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1282,15 +1320,20 @@ function attachBounceListener() {
|
|||||||
lastObservedHeight = height;
|
lastObservedHeight = height;
|
||||||
|
|
||||||
if (heightDelta !== 0) {
|
if (heightDelta !== 0) {
|
||||||
console.log('[ChatAreaScrollDebug] height-change', {
|
bounceTraceLog(
|
||||||
heightDelta,
|
'height-change',
|
||||||
scrollHeight: height,
|
{
|
||||||
top,
|
heightDelta,
|
||||||
delta,
|
scrollHeight: height,
|
||||||
escapedFromLock: escapedFromLock.value,
|
top,
|
||||||
isAtBottom: isAtBottom.value,
|
delta,
|
||||||
isNearBottom: isNearBottom.value,
|
escapedFromLock: escapedFromLock.value,
|
||||||
});
|
isAtBottom: isAtBottom.value,
|
||||||
|
isNearBottom: isNearBottom.value
|
||||||
|
},
|
||||||
|
'height-change',
|
||||||
|
120
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 大幅高度增长检测:当 scrollHeight 突增(内容大幅展开/渲染),
|
// 大幅高度增长检测:当 scrollHeight 突增(内容大幅展开/渲染),
|
||||||
@ -1303,15 +1346,20 @@ function attachBounceListener() {
|
|||||||
heightDelta > largeGrowthThreshold &&
|
heightDelta > largeGrowthThreshold &&
|
||||||
!escapedFromLock.value
|
!escapedFromLock.value
|
||||||
) {
|
) {
|
||||||
console.log('[ChatAreaScrollDebug] TRIGGER:large-growth-scroll', {
|
bounceTraceLog(
|
||||||
heightDelta,
|
'large-growth:trigger',
|
||||||
threshold: largeGrowthThreshold,
|
{
|
||||||
top,
|
heightDelta,
|
||||||
delta,
|
threshold: largeGrowthThreshold,
|
||||||
escapedFromLock: escapedFromLock.value,
|
top,
|
||||||
isAtBottom: isAtBottom.value,
|
delta,
|
||||||
isNearBottom: isNearBottom.value,
|
escapedFromLock: escapedFromLock.value,
|
||||||
});
|
isAtBottom: isAtBottom.value,
|
||||||
|
isNearBottom: isNearBottom.value
|
||||||
|
},
|
||||||
|
'large-growth:trigger',
|
||||||
|
0
|
||||||
|
);
|
||||||
isHandlingLargeGrowth = true;
|
isHandlingLargeGrowth = true;
|
||||||
markProgrammaticHint('ChatArea.largeGrowth');
|
markProgrammaticHint('ChatArea.largeGrowth');
|
||||||
suppressUserIntentUntil = Date.now() + 900;
|
suppressUserIntentUntil = Date.now() + 900;
|
||||||
@ -1408,7 +1456,7 @@ function attachBounceListener() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
el.addEventListener('scroll', scrollListener, { passive: true });
|
el.addEventListener('scroll', scrollListener, { passive: true });
|
||||||
if (!traceAttachLogged) {
|
if (!traceAttachLogged && isScrollBounceTraceEnabled()) {
|
||||||
traceAttachLogged = true;
|
traceAttachLogged = true;
|
||||||
console.warn('[SCROLL_BOUNCE_TRACE]', 'listener-attached', {
|
console.warn('[SCROLL_BOUNCE_TRACE]', 'listener-attached', {
|
||||||
hasElement: !!el,
|
hasElement: !!el,
|
||||||
@ -1550,15 +1598,6 @@ async function stickScrollToBottom(
|
|||||||
) {
|
) {
|
||||||
const el = scrollRef.value;
|
const el = scrollRef.value;
|
||||||
if (el) {
|
if (el) {
|
||||||
console.log('[ChatAreaScrollDebug] CALL:stickScrollToBottom', {
|
|
||||||
behavior: options.behavior || 'auto',
|
|
||||||
force: !!options.force,
|
|
||||||
preserveScrollPosition: !!options.preserveScrollPosition,
|
|
||||||
isAtBottom: isAtBottom.value,
|
|
||||||
isNearBottom: isNearBottom.value,
|
|
||||||
escapedFromLock: escapedFromLock.value,
|
|
||||||
...getScrollMetrics(el),
|
|
||||||
});
|
|
||||||
bounceTraceLog(
|
bounceTraceLog(
|
||||||
'programmatic:scrollToBottom:before',
|
'programmatic:scrollToBottom:before',
|
||||||
{
|
{
|
||||||
@ -1999,9 +2038,11 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
nextTick(() => observeUserBubbles());
|
nextTick(() => observeUserBubbles());
|
||||||
}
|
}
|
||||||
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
|
if (isScrollBounceTraceEnabled()) {
|
||||||
hasScrollRef: !!scrollRef.value
|
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
|
||||||
});
|
hasScrollRef: !!scrollRef.value
|
||||||
|
});
|
||||||
|
}
|
||||||
timerHandle = window.setInterval(() => {
|
timerHandle = window.setInterval(() => {
|
||||||
nowMs.value = Date.now();
|
nowMs.value = Date.now();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|||||||
@ -122,7 +122,8 @@
|
|||||||
contain: layout style;
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-flow > .message-block.message-block--last {
|
/* 窗口化后 message-block 外层有 virtua 列表项包装,不再是 messages-flow 直接子元素,改用后代选择器 */
|
||||||
|
.messages-flow .message-block--last {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,7 +215,8 @@
|
|||||||
padding-right: 12px !important;
|
padding-right: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-block:first-child {
|
/* 窗口化后每个 message-block 都是包装项的首子,:first-child 会匹配所有块,改用显式 class */
|
||||||
|
.message-block--first {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user