Add virtualized chat pagination (#1080)

This commit is contained in:
ekko
2026-05-28 09:34:30 +08:00
committed by GitHub
parent 21bb8385f2
commit a6b3bec29b
16 changed files with 692 additions and 161 deletions
@@ -11,6 +11,8 @@ import { useI18n } from 'vue-i18n'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
const chatStore = useChatStore()
const appStore = useAppStore()
const profilesStore = useProfilesStore()
const { t } = useI18n()
const message = useMessage()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
@@ -150,6 +152,9 @@ function selectBridgeCommand(command: { name: string; args: string; insertText?:
const contextLength = ref(256000)
const FALLBACK_CONTEXT = 256000
let contextLengthLoadedKey = ''
let contextLengthRequestKey = ''
let contextLengthRequest: Promise<void> | null = null
// Context length editing
const showContextEditModal = ref(false)
@@ -169,8 +174,8 @@ async function saveContextLimit() {
isSavingContextLimit.value = true
try {
const provider = chatStore.activeSession?.provider || useAppStore().selectedProvider || ''
const model = chatStore.activeSession?.model || useAppStore().selectedModel || ''
const provider = chatStore.activeSession?.provider || appStore.selectedProvider || ''
const model = chatStore.activeSession?.model || appStore.selectedModel || ''
if (!provider || !model) {
message.error(t('chat.contextEditFailed'))
@@ -179,6 +184,7 @@ async function saveContextLimit() {
await setModelContext(provider, model, editingContextLimit.value)
contextLength.value = editingContextLimit.value
contextLengthLoadedKey = currentContextLengthKey()
showContextEditModal.value = false
message.success(t('chat.contextEditSuccess'))
} catch (err: any) {
@@ -188,28 +194,61 @@ async function saveContextLimit() {
}
}
async function loadContextLength() {
try {
const activeSession = chatStore.activeSession
const profile = activeSession?.profile || useProfilesStore().activeProfileName || undefined
contextLength.value = await fetchContextLength(
profile,
activeSession?.provider || undefined,
activeSession?.model || undefined,
)
} catch {
contextLength.value = FALLBACK_CONTEXT
function currentContextLengthParams() {
const activeSession = chatStore.activeSession
return {
profile: activeSession?.profile || profilesStore.activeProfileName || undefined,
provider: activeSession?.provider || undefined,
model: activeSession?.model || undefined,
}
}
function currentContextLengthKey() {
const params = currentContextLengthParams()
return `${params.profile || ''}|${params.provider || ''}|${params.model || ''}`
}
async function loadContextLength() {
const key = currentContextLengthKey()
if (key === contextLengthLoadedKey) return
if (key === contextLengthRequestKey && contextLengthRequest) return contextLengthRequest
contextLengthRequestKey = key
contextLengthRequest = (async () => {
const params = currentContextLengthParams()
try {
const value = await fetchContextLength(params.profile, params.provider, params.model)
if (currentContextLengthKey() !== key) return
contextLength.value = value
contextLengthLoadedKey = key
} catch {
if (currentContextLengthKey() !== key) return
contextLength.value = FALLBACK_CONTEXT
contextLengthLoadedKey = key
} finally {
if (contextLengthRequestKey === key) {
contextLengthRequest = null
contextLengthRequestKey = ''
}
}
})()
return contextLengthRequest
}
onMounted(loadContextLength)
watch(() => useProfilesStore().activeProfileName, loadContextLength)
watch(() => useAppStore().selectedProvider, loadContextLength)
watch(() => useAppStore().selectedModel, loadContextLength)
watch(() => chatStore.activeSession?.id, loadContextLength)
watch(() => chatStore.activeSession?.profile, loadContextLength)
watch(() => chatStore.activeSession?.provider, loadContextLength)
watch(() => chatStore.activeSession?.model, loadContextLength)
watch(
() => [
profilesStore.activeProfileName,
appStore.selectedProvider,
appStore.selectedModel,
chatStore.activeSession?.id,
chatStore.activeSession?.profile,
chatStore.activeSession?.provider,
chatStore.activeSession?.model,
],
loadContextLength,
{ flush: 'post' },
)
const totalTokens = computed(() => {
const context = chatStore.activeSession?.contextTokens
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
@@ -13,7 +14,7 @@ const props = defineProps<{
const chatStore = useChatStore();
const { toolTraceVisible } = useToolTraceVisibility();
const { t } = useI18n();
const listRef = ref<HTMLElement>();
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
// Use provided session or fall back to chatStore's active session
const activeSession = computed(() => props.session || chatStore.activeSession);
@@ -29,38 +30,27 @@ const displayMessages = computed(() =>
);
function isNearBottom(threshold = 200): boolean {
const el = listRef.value;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
return listRef.value?.isNearBottom(threshold) ?? true;
}
function scrollToBottom() {
nextTick(() => {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight;
}
});
listRef.value?.scrollToBottom();
}
function scrollToMessage(messageId: string) {
nextTick(() => {
const el = document.getElementById(`message-${messageId}`);
if (el) {
el.scrollIntoView({ block: 'center' });
}
});
listRef.value?.scrollToMessage(messageId);
}
// Scroll to bottom on session switch
watch(
() => chatStore.activeSessionId,
() => activeSession.value?.id,
(id) => {
if (!id) return;
if (chatStore.focusMessageId) {
nextTick(() => scrollToMessage(chatStore.focusMessageId!));
scrollToMessage(chatStore.focusMessageId);
return;
}
nextTick(() => scrollToBottom());
scrollToBottom();
},
{ immediate: true },
);
@@ -94,37 +84,28 @@ watch(
</script>
<template>
<div ref="listRef" class="message-list">
<div v-if="!activeSession || activeSession.messages.length === 0" class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
<MessageItem
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
</div>
<VirtualMessageList
ref="listRef"
:messages="displayMessages"
>
<template #empty>
<div class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #item="{ message: msg }">
<MessageItem
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
background-color: $bg-card;
.dark & {
background-color: #333333;
}
}
.empty-state {
flex: 1;
display: flex;
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { ref, computed, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import thinkingImageLight from "@/assets/thinking-light.gif";
@@ -12,7 +13,7 @@ const chatStore = useChatStore();
const { t } = useI18n();
const { isDark } = useTheme();
const { toolTraceVisible } = useToolTraceVisibility();
const listRef = ref<HTMLElement>();
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
@@ -84,26 +85,25 @@ function queuedPreview(content: string): string {
}
function isNearBottom(threshold = 200): boolean {
const el = listRef.value;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
return listRef.value?.isNearBottom(threshold) ?? true;
}
function scrollToBottom() {
nextTick(() => {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight;
}
});
listRef.value?.scrollToBottom();
}
function scrollToMessage(messageId: string) {
nextTick(() => {
const el = document.getElementById(`message-${messageId}`);
if (el) {
el.scrollIntoView({ block: 'center' });
}
});
listRef.value?.scrollToMessage(messageId);
}
async function handleTopReach() {
const session = chatStore.activeSession;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
const snapshot = listRef.value?.captureScrollPosition() ?? null;
const loaded = await chatStore.loadOlderMessages(session.id);
if (!loaded) return;
await nextTick();
listRef.value?.restoreScrollPosition(snapshot);
}
// Scroll to bottom on session switch
@@ -112,10 +112,10 @@ watch(
(id) => {
if (!id) return;
if (chatStore.focusMessageId) {
nextTick(() => scrollToMessage(chatStore.focusMessageId!));
scrollToMessage(chatStore.focusMessageId);
return;
}
nextTick(() => scrollToBottom());
scrollToBottom();
},
{ immediate: true },
);
@@ -159,18 +159,33 @@ watch(currentToolCalls, () => {
</script>
<template>
<div ref="listRef" class="message-list">
<div v-if="chatStore.messages.length === 0" class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
<MessageItem
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
<Transition name="fade">
<VirtualMessageList
ref="listRef"
:messages="displayMessages"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="chatStore.activeSession?.hasMoreBefore || chatStore.activeSession?.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="chatStore.activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<MessageItem
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
</template>
<template #after>
<Transition name="fade">
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
<img
:src="isDark ? thinkingImageDark : thinkingImageLight"
@@ -331,8 +346,8 @@ watch(currentToolCalls, () => {
</div>
</div>
</div>
</Transition>
<Transition name="queue-float">
</Transition>
<Transition name="queue-float">
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
<div class="queue-float-header">
<span class="queue-orbit" aria-hidden="true">
@@ -363,36 +378,22 @@ watch(currentToolCalls, () => {
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
background-color: $bg-card;
position: relative;
.dark & {
background-color: #333333;
}
}
.queue-float-panel {
position: sticky;
right: 16px;
bottom: 16px;
z-index: 4;
align-self: flex-end;
width: min(340px, calc(100% - 16px));
margin-top: auto;
margin-top: 16px;
margin-left: auto;
padding: 10px;
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
border-radius: 16px;
@@ -606,6 +607,28 @@ watch(currentToolCalls, () => {
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from "vue";
type VirtualItem = {
id: string | number;
}
const props = withDefaults(defineProps<{
messages: VirtualItem[];
estimatedItemHeight?: number;
overscan?: number;
rowGap?: number;
padding?: string;
topThreshold?: number;
}>(), {
estimatedItemHeight: 180,
overscan: 8,
rowGap: 16,
padding: "20px",
topThreshold: 120,
});
const emit = defineEmits<{
scroll: [];
topReach: [];
}>();
defineSlots<{
empty?: () => any;
before?: () => any;
item?: (props: { message: any }) => any;
after?: () => any;
}>();
const scrollerRef = ref<HTMLElement | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
const heightVersion = ref(0);
const measuredHeights = new Map<string, number>();
const observedElements = new Map<string, HTMLElement>();
const observers = new Map<string, ResizeObserver>();
const messageKeys = computed(() => props.messages.map(messageKey));
function messageKey(message: VirtualItem): string {
return String(message.id);
}
function itemHeight(key: string): number {
return measuredHeights.get(key) || props.estimatedItemHeight;
}
const layout = computed(() => {
heightVersion.value;
const offsets: number[] = [];
let total = 0;
for (const key of messageKeys.value) {
offsets.push(total);
total += itemHeight(key);
}
return { offsets, total };
});
const visibleRange = computed(() => {
const count = props.messages.length;
if (count === 0) return { start: 0, end: -1 };
const overscanPx = props.estimatedItemHeight * props.overscan;
const startPx = Math.max(0, scrollTop.value - overscanPx);
const endPx = scrollTop.value + viewportHeight.value + overscanPx;
const { offsets } = layout.value;
let start = 0;
let low = 0;
let high = count - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const bottom = offsets[mid] + itemHeight(messageKeys.value[mid]);
if (bottom < startPx) low = mid + 1;
else {
start = mid;
high = mid - 1;
}
}
let end = start;
while (end < count - 1 && offsets[end] < endPx) end += 1;
return { start, end };
});
const visibleMessages = computed(() => {
const { start, end } = visibleRange.value;
return end >= start ? props.messages.slice(start, end + 1) : [];
});
const topSpacerHeight = computed(() => layout.value.offsets[visibleRange.value.start] || 0);
const bottomSpacerHeight = computed(() => {
const { end } = visibleRange.value;
if (end < 0) return 0;
const nextOffset = end + 1 < props.messages.length
? layout.value.offsets[end + 1]
: layout.value.total;
return Math.max(0, layout.value.total - nextOffset);
});
function setItemRef(key: string, el: Element | ComponentPublicInstance | null) {
const existing = observedElements.get(key);
if (existing === el) return;
observers.get(key)?.disconnect();
observers.delete(key);
observedElements.delete(key);
if (!(el instanceof HTMLElement)) return;
observedElements.set(key, el);
if (typeof ResizeObserver === "undefined") {
const height = Math.ceil(el.getBoundingClientRect().height || props.estimatedItemHeight);
measuredHeights.set(key, height);
heightVersion.value += 1;
return;
}
const observer = new ResizeObserver(entries => {
const height = Math.ceil(entries[0]?.contentRect.height || props.estimatedItemHeight);
if (measuredHeights.get(key) === height) return;
measuredHeights.set(key, height);
heightVersion.value += 1;
});
observer.observe(el);
observers.set(key, observer);
}
function syncViewport() {
const el = scrollerRef.value;
if (!el) return;
scrollTop.value = el.scrollTop;
viewportHeight.value = el.clientHeight;
}
function handleScroll() {
syncViewport();
emit("scroll");
if (scrollTop.value <= props.topThreshold) emit("topReach");
}
function isNearBottom(threshold = 200): boolean {
const el = scrollerRef.value;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
}
function scrollToBottom() {
nextTick(() => {
const el = scrollerRef.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
syncViewport();
});
}
function scrollToMessage(messageId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
nextTick(() => {
const el = scrollerRef.value;
if (!el) return;
el.scrollTop = Math.max(0, (layout.value.offsets[index] || 0) - el.clientHeight / 2);
syncViewport();
nextTick(() => {
document.getElementById(`message-${messageId}`)?.scrollIntoView({ block: "center" });
});
});
}
function captureScrollPosition() {
const el = scrollerRef.value;
if (!el) return null;
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
};
}
function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: number } | null) {
if (!snapshot) return;
nextTick(() => {
const el = scrollerRef.value;
if (!el) return;
el.scrollTop = Math.max(0, el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop);
syncViewport();
});
}
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
syncViewport();
if (scrollerRef.value && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(syncViewport);
resizeObserver.observe(scrollerRef.value);
}
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
for (const observer of observers.values()) observer.disconnect();
observers.clear();
observedElements.clear();
});
watch(messageKeys, keys => {
const activeKeys = new Set(keys);
for (const key of [...observedElements.keys()]) {
if (activeKeys.has(key)) continue;
observers.get(key)?.disconnect();
observers.delete(key);
observedElements.delete(key);
}
nextTick(syncViewport);
});
defineExpose({
isNearBottom,
scrollToBottom,
scrollToMessage,
captureScrollPosition,
restoreScrollPosition,
});
</script>
<template>
<div
ref="scrollerRef"
class="virtual-message-list"
:style="{ '--virtual-row-gap': `${rowGap}px`, '--virtual-list-padding': padding }"
@scroll="handleScroll"
>
<slot v-if="messages.length === 0" name="empty" />
<template v-else>
<slot name="before" />
<div class="virtual-spacer" :style="{ height: `${topSpacerHeight}px` }" />
<div
v-for="msg in visibleMessages"
:key="msg.id"
:ref="(el) => setItemRef(messageKey(msg), el)"
class="virtual-row"
>
<slot name="item" :message="msg" />
</div>
<div class="virtual-spacer" :style="{ height: `${bottomSpacerHeight}px` }" />
</template>
<slot name="after" />
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.virtual-message-list {
flex: 1;
overflow-y: auto;
padding: var(--virtual-list-padding);
display: flex;
flex-direction: column;
background-color: $bg-card;
position: relative;
.dark & {
background-color: #333333;
}
}
.virtual-spacer {
flex: 0 0 auto;
}
.virtual-row {
padding-bottom: var(--virtual-row-gap);
}
</style>
@@ -4,27 +4,30 @@ import { useI18n } from 'vue-i18n'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import GroupMessageItem from './GroupMessageItem.vue'
import VirtualMessageList from '../chat/VirtualMessageList.vue'
const store = useGroupChatStore()
const { t } = useI18n()
const { toolTraceVisible } = useToolTraceVisibility()
const listRef = ref<HTMLDivElement>()
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
const isNearBottom = ref(true)
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
function checkNearBottom(): void {
if (!listRef.value) return
const { scrollTop, scrollHeight, clientHeight } = listRef.value
isNearBottom.value = scrollHeight - scrollTop - clientHeight < 200
isNearBottom.value = listRef.value?.isNearBottom(200) ?? true
}
function scrollToBottom(): void {
if (!listRef.value) return
listRef.value.scrollTop = listRef.value.scrollHeight
listRef.value?.scrollToBottom()
}
function handleScroll(): void {
checkNearBottom()
async function handleTopReach(): Promise<void> {
if (!store.hasMoreBefore || store.isLoadingOlderMessages) return
const snapshot = listRef.value?.captureScrollPosition() ?? null
const loaded = await store.loadOlderMessages()
if (!loaded) return
await nextTick()
listRef.value?.restoreScrollPosition(snapshot)
}
watch(() => store.messages.length, async () => {
@@ -38,39 +41,42 @@ defineExpose({ scrollToBottom })
</script>
<template>
<div ref="listRef" class="message-list" @scroll="handleScroll">
<div v-if="displayMessages.length === 0" class="empty-state">
<VirtualMessageList
ref="listRef"
:messages="displayMessages"
:estimated-item-height="170"
:row-gap="12"
padding="16px 20px"
@scroll="checkNearBottom"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
<GroupMessageItem
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:agents="store.agents"
:current-user-id="store.userId"
/>
</div>
</template>
<template #before>
<div
v-if="store.hasMoreBefore || store.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<GroupMessageItem
:message="msg"
:agents="store.agents"
:current-user-id="store.userId"
/>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.message-list {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
background-color: $bg-card;
position: relative;
.dark & {
background-color: #333333;
}
}
.empty-state {
flex: 1;
display: flex;
@@ -90,4 +96,32 @@ defineExpose({ scrollToBottom })
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>