Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/lib/virtual-scroll.js
Original file line number Diff line number Diff line change
@@ -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 }
}
140 changes: 137 additions & 3 deletions src/pages/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1863,18 +1884,52 @@ 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()
}

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) {
Expand Down Expand Up @@ -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() {
Expand Down
5 changes: 5 additions & 0 deletions src/style/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@
gap: var(--space-sm);
}

.msg-virtual-spacer {
height: 0;
flex-shrink: 0;
}

/* 消息通用 */
.msg {
display: flex;
Expand Down
24 changes: 24 additions & 0 deletions tests/virtual-scroll.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})