diff --git a/src/lib/virtual-scroll.js b/src/lib/virtual-scroll.js new file mode 100644 index 00000000..e1074654 --- /dev/null +++ b/src/lib/virtual-scroll.js @@ -0,0 +1,36 @@ +export function getItemHeight(items, idx, heights, avgHeight) { + const id = items[idx]?.id + return heights.get(id) || avgHeight +} + +export function buildPrefixHeights(items, heights, avgHeight) { + const prefix = [0] + for (let i = 0; i < items.length; i++) { + prefix[i + 1] = prefix[i] + getItemHeight(items, i, heights, avgHeight) + } + return prefix +} + +export function findStartIndex(prefix, scrollTop) { + let lo = 0, hi = prefix.length - 1 + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2) + if (prefix[mid] <= scrollTop) lo = mid + 1 + else hi = mid + } + return Math.max(0, lo - 1) +} + +export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, overscan, windowSize, heights) { + const prefix = buildPrefixHeights(items, heights, avgHeight) + const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan) + const end = Math.min(items.length, start + windowSize + overscan * 2) + return { start, end, prefix } +} + +export function getSpacerHeights(prefix, start, end) { + const top = prefix[start] + const total = prefix[prefix.length - 1] + const bottom = Math.max(0, total - prefix[end]) + return { top, bottom, total } +} diff --git a/src/pages/chat.js b/src/pages/chat.js index 6c39aaf1..9635e90d 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -6,6 +6,7 @@ import { api, invalidate } from '../lib/tauri-api.js' import { navigate } from '../router.js' import { wsClient, uuid } from '../lib/ws-client.js' import { renderMarkdown } from '../lib/markdown.js' +import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js' import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js' import { toast } from '../components/toast.js' import { showModal, showConfirm } from '../components/modal.js' @@ -64,6 +65,17 @@ let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTi let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0 let _isLoadingHistory = false + +const VIRTUAL_WINDOW = 40 +const VIRTUAL_OVERSCAN = 20 +let _virtualEnabled = true +let _virtualHeights = new Map() +let _virtualAvgHeight = 64 +let _virtualRange = { start: 0, end: 0, prefix: [0] } +let _virtualItems = [] +let _virtualTopSpacer = null +let _virtualBottomSpacer = null +let _virtualRenderPending = false let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null let _seenRunIds = new Set() let _pageActive = false @@ -289,6 +301,15 @@ function bindEvents(page) { scrollToBottom(true) }) _messagesEl.addEventListener('click', () => hideCmdPanel()) + _messagesEl.addEventListener('click', (e) => { + const target = e.target?.closest?.('.msg-spoiler') + if (!target) return + if (target.closest('code, pre')) return + target.classList.toggle('revealed') + }) + _messagesEl.addEventListener('scroll', () => { + if (_virtualEnabled) requestVirtualRender() + }) } async function loadModelOptions(showToast = false) { @@ -1863,11 +1884,39 @@ function showLightbox(src) { document.addEventListener('keydown', onKey) } -function appendSystemMessage(text) { +function insertMessageByTime(wrap, ts) { + const tsValue = Number(ts || Date.now()) + wrap.dataset.ts = String(tsValue) + + if (!_virtualEnabled) { + const items = Array.from(_messagesEl.querySelectorAll('.msg')) + for (const node of items) { + const nodeTs = parseInt(node.dataset.ts || '0', 10) + if (nodeTs > tsValue) { + _messagesEl.insertBefore(wrap, node) + return + } + } + _messagesEl.insertBefore(wrap, _typingEl) + return + } + + if (!wrap.dataset.vid) wrap.dataset.vid = uuid() + const vid = wrap.dataset.vid + const existingIdx = _virtualItems.findIndex(item => item.id === vid) + const entry = { id: vid, ts: tsValue, node: wrap } + if (existingIdx >= 0) _virtualItems.splice(existingIdx, 1) + let insertIdx = _virtualItems.findIndex(item => item.ts > tsValue) + if (insertIdx < 0) insertIdx = _virtualItems.length + _virtualItems.splice(insertIdx, 0, entry) + requestVirtualRender(true) +} + +function appendSystemMessage(text, ts) { const wrap = document.createElement('div') wrap.className = 'msg msg-system' wrap.textContent = text - _messagesEl.insertBefore(wrap, _typingEl) + insertMessageByTime(wrap, ts) scrollToBottom() } @@ -1875,6 +1924,12 @@ function clearMessages() { _messagesEl.querySelectorAll('.msg').forEach(m => m.remove()) _autoScrollEnabled = true _lastScrollTop = 0 + _virtualItems = [] + _virtualHeights = new Map() + _virtualAvgHeight = 64 + _virtualRange = { start: 0, end: 0, prefix: [0] } + if (_virtualTopSpacer) _virtualTopSpacer.style.height = '0px' + if (_virtualBottomSpacer) _virtualBottomSpacer.style.height = '0px' } function showTyping(show) { @@ -1904,7 +1959,86 @@ function scrollToBottom(force = false) { function isAtBottom() { if (!_messagesEl) return true - return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < 80 + const threshold = 80 + return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < threshold +} + +function ensureVirtualSpacers() { + if (!_messagesEl) return + if (!_virtualTopSpacer) { + _virtualTopSpacer = document.createElement('div') + _virtualTopSpacer.className = 'msg-virtual-spacer' + _messagesEl.insertBefore(_virtualTopSpacer, _messagesEl.firstChild) + } + if (!_virtualBottomSpacer) { + _virtualBottomSpacer = document.createElement('div') + _virtualBottomSpacer.className = 'msg-virtual-spacer' + _messagesEl.insertBefore(_virtualBottomSpacer, _typingEl) + } +} + +function requestVirtualRender(force = false) { + if (!_virtualEnabled || !_messagesEl) return + if (_virtualRenderPending && !force) return + _virtualRenderPending = true + requestAnimationFrame(() => { + _virtualRenderPending = false + doVirtualRender() + }) +} + +function doVirtualRender() { + if (!_virtualEnabled || !_messagesEl) return + ensureVirtualSpacers() + const atBottom = isAtBottom() + const scrollTop = _messagesEl.scrollTop + const viewport = _messagesEl.clientHeight + const items = _virtualItems + const { start, end, prefix } = computeVirtualRange(items, scrollTop, viewport, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights) + _virtualRange = { start, end, prefix } + const { top, bottom } = getSpacerHeights(prefix, start, end) + _virtualTopSpacer.style.height = `${top}px` + _virtualBottomSpacer.style.height = `${bottom}px` + + const visibleIds = new Set(items.slice(start, end).map(i => i.id)) + _messagesEl.querySelectorAll('.msg').forEach(node => { + const vid = node.dataset.vid + if (!vid || !visibleIds.has(vid)) node.remove() + }) + + const anchor = _virtualTopSpacer.nextSibling + let refNode = anchor + for (let i = start; i < end; i++) { + const item = items[i] + if (!item?.node) continue + if (item.node.parentNode !== _messagesEl) { + _messagesEl.insertBefore(item.node, refNode || _virtualBottomSpacer) + } + refNode = item.node.nextSibling + } + + requestAnimationFrame(() => { + let total = 0, count = 0 + items.slice(start, end).forEach(item => { + const el = item.node + if (!el || !el.getBoundingClientRect) return + const h = Math.max(1, Math.ceil(el.getBoundingClientRect().height)) + if (h) { + _virtualHeights.set(item.id, h) + total += h + count += 1 + } + }) + if (count) _virtualAvgHeight = Math.max(24, Math.round(total / count)) + + if (atBottom) { + scrollToBottom() + } else { + const newTop = _virtualTopSpacer.offsetHeight + const delta = newTop - top + if (delta !== 0) _messagesEl.scrollTop = scrollTop + delta + } + }) } function updateSendState() { diff --git a/src/style/chat.css b/src/style/chat.css index 63fbacad..6c143e12 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -142,6 +142,11 @@ gap: var(--space-sm); } +.msg-virtual-spacer { + height: 0; + flex-shrink: 0; +} + /* 消息通用 */ .msg { display: flex; diff --git a/tests/virtual-scroll.test.js b/tests/virtual-scroll.test.js new file mode 100644 index 00000000..5b9d618a --- /dev/null +++ b/tests/virtual-scroll.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { buildPrefixHeights, computeVirtualRange, getSpacerHeights } from '../src/lib/virtual-scroll.js' + +describe('virtual scroll helpers', () => { + it('builds prefix heights with avg fallback', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + const heights = new Map([['b', 80]]) + const prefix = buildPrefixHeights(items, heights, 50) + expect(prefix).toEqual([0, 50, 130, 180]) + }) + + it('computes range with window cap', () => { + const items = Array.from({ length: 200 }, (_, i) => ({ id: String(i) })) + const heights = new Map() + const { start, end } = computeVirtualRange(items, 0, 600, 30, 20, 40, heights) + expect(end - start).toBeLessThanOrEqual(80) + }) + + it('spacer heights sum to total', () => { + const prefix = [0, 50, 100, 150] + const { top, bottom, total } = getSpacerHeights(prefix, 1, 2) + expect(top + bottom + (prefix[2] - prefix[1])).toBe(total) + }) +})