From 864974dd922ca1a5a8e3e032c979374bb3add274 Mon Sep 17 00:00:00 2001 From: Kerry1020 Date: Sat, 18 Jul 2026 16:12:38 +0800 Subject: [PATCH 01/15] perf(dialog): add render window to prevent long-conversation DOM freeze Long conversations (500+ messages) would freeze because all messages were rendered into the DOM with full KaTeX markdown. Three optimizations applied: 1. Render window (useMessageWindow composable): - Initial render: last 100 messages, max cap: 200 - Infinite scroll-up via IntersectionObserver sentinel - Scroll position preserved during loadMore() via height delta 2. Lazy markdown for off-screen messages: - Messages >30 positions from renderEnd render as plain text - Skips md-editor-v3 KaTeX rendering (costly per-message) - Reuses existing streaming plain-text pipeline 3. Stable keys: visible slice uses message IDs as keys so Vue doesn't remount existing items when the window shifts --- src/components/MessageItem.vue | 5 ++ src/composables/use-message-window.ts | 125 ++++++++++++++++++++++++++ src/views/DialogView.vue | 69 ++++++++++---- 3 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 src/composables/use-message-window.ts diff --git a/src/components/MessageItem.vue b/src/components/MessageItem.vue index c7b803b7..92b46568 100644 --- a/src/components/MessageItem.vue +++ b/src/components/MessageItem.vue @@ -391,6 +391,7 @@ const props = defineProps<{ message: Message, childNum: number, scrollContainer: HTMLElement, + lazyPlainText?: boolean, branchControl?: { current: number, max: number, @@ -517,6 +518,10 @@ function isStreamingAssistantText(content: MessageContent) { function getStreamingRenderState(content: MessageContent) { if (isStreamingAssistantText(content)) return streamingRenderState.value + // Off-screen messages: skip expensive KaTeX/markdown rendering + if (props.lazyPlainText && (content.type === 'assistant-message' || content.type === 'user-message')) { + return { mode: 'plain-text' as const, text: content.text } + } if (content.type === 'assistant-message' || content.type === 'user-message') { return { mode: 'final' as const, text: content.text } } diff --git a/src/composables/use-message-window.ts b/src/composables/use-message-window.ts new file mode 100644 index 00000000..b375926a --- /dev/null +++ b/src/composables/use-message-window.ts @@ -0,0 +1,125 @@ +import { computed, nextTick, onBeforeUnmount, ref, watch, type Ref } from 'vue' + +/** + * Renders only a sliding window of the message chain to prevent + * DOM bloat in long conversations. Older messages are loaded on + * scroll-up via IntersectionObserver sentinel placed at the top. + * + * Architecture: + * - Initial render: last INITIAL_LIMIT messages + * - Scroll to top → sentinel enters viewport → loadMore (LOAD_MORE_SIZE) + * - Scroll position is preserved by compensating scrollHeight delta + * - Observer is gated with cooldown to prevent rapid-fire loads + */ +export function useMessageWindow( + chain: Ref, + scrollContainer: Ref, +) { + const INITIAL_LIMIT = 100 + const LOAD_MORE_SIZE = 50 + const MAX_RENDER_CAP = 200 // hard limit: never render more than this + const SETTLE_DELAY_MS = 500 + const OBSERVER_COOLDOWN_MS = 600 + + const renderStart = ref(Math.max(0, chain.value.length - INITIAL_LIMIT)) + const renderEnd = ref(chain.value.length) + const isLoadingMore = ref(false) + + const reset = () => { + renderEnd.value = chain.value.length + renderStart.value = Math.max(0, renderEnd.value - INITIAL_LIMIT) + isLoadingMore.value = false + } + + // Stable-keyed visible slice — keys are message IDs so Vue won't remount + const visibleItems = computed(() => + chain.value + .slice(renderStart.value, renderEnd.value) + .map((id, i) => ({ + id, + originalIndex: renderStart.value + i, + })), + ) + + const hasMore = computed(() => renderStart.value > 0) + + function loadMore() { + if (isLoadingMore.value || !hasMore.value) return + isLoadingMore.value = true + + const container = scrollContainer.value + const prevScrollHeight = container?.scrollHeight ?? 0 + const prevScrollTop = container?.scrollTop ?? 0 + + renderStart.value = Math.max(0, renderStart.value - LOAD_MORE_SIZE) + // Enforce max cap: if window exceeds limit, drop oldest messages + if (renderEnd.value - renderStart.value > MAX_RENDER_CAP) { + renderEnd.value = renderStart.value + MAX_RENDER_CAP + } + + // Restore scroll position after Vue re-renders expanded content + nextTick(() => { + if (container) { + const newScrollHeight = container.scrollHeight + container.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight) + } + // Cooldown: don't let the observer fire again immediately + setTimeout(() => { + isLoadingMore.value = false + }, OBSERVER_COOLDOWN_MS) + }) + } + + // ── IntersectionObserver sentinel ── + const sentinelRef = ref() + let sentinelObserver: IntersectionObserver | null = null + let settleTimer: ReturnType | null = null + + function setupSentinel(el: HTMLElement) { + sentinelObserver?.disconnect() + sentinelObserver = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMore() + } + }, + { root: scrollContainer.value ?? null, threshold: 0 }, + ) + sentinelObserver.observe(el) + } + + function teardownSentinel() { + sentinelObserver?.disconnect() + sentinelObserver = null + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } + } + + // Watch the sentinel DOM element: set up observer with delay so + // initial scroll restoration has time to settle before detection. + watch(sentinelRef, (el) => { + teardownSentinel() + if (!el) return + settleTimer = setTimeout(() => { + setupSentinel(el) + settleTimer = null + }, SETTLE_DELAY_MS) + }) + + onBeforeUnmount(() => { + teardownSentinel() + }) + + return { + visibleItems, + renderStart, + renderEnd, + hasMore, + isLoadingMore, + loadMore, + reset, + sentinelRef, + } +} diff --git a/src/views/DialogView.vue b/src/views/DialogView.vue index f019a396..f01d2434 100644 --- a/src/views/DialogView.vue +++ b/src/views/DialogView.vue @@ -157,27 +157,42 @@ :class="['dialog-scroll-container', { 'rd-r-lg': rightDrawerAbove }]" @scroll="onScroll" > +