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
+4
View File
@@ -75,6 +75,10 @@ export interface RunEvent {
export interface ResumeSessionPayload {
session_id: string
messages: any[]
messageTotal?: number
messageLoadedCount?: number
messagePageLimit?: number
hasMoreBefore?: boolean
isWorking: boolean
isAborting?: boolean
events: Array<{ event: string; data: RunEvent }>
+9 -2
View File
@@ -162,8 +162,15 @@ export async function listRooms(): Promise<{ rooms: RoomInfo[] }> {
return request('/api/hermes/group-chat/rooms')
}
export async function getRoomDetail(roomId: string): Promise<{ room: RoomInfo; messages: ChatMessage[]; agents: RoomAgent[]; members: MemberInfo[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}`)
export async function getRoomDetail(
roomId: string,
options: { offset?: number; limit?: number } = {},
): Promise<{ room: RoomInfo; messages: ChatMessage[]; agents: RoomAgent[]; members: MemberInfo[]; total?: number; offset?: number; limit?: number; hasMore?: boolean }> {
const params = new URLSearchParams()
if (options.offset != null) params.set('offset', String(options.offset))
if (options.limit != null) params.set('limit', String(options.limit))
const query = params.toString()
return request(`/api/hermes/group-chat/rooms/${roomId}${query ? `?${query}` : ''}`)
}
export async function joinRoomByCode(code: string): Promise<{ room: RoomInfo }> {
@@ -30,6 +30,15 @@ export interface SessionDetail extends SessionSummary {
messages: HermesMessage[]
}
export interface PaginatedSessionMessages {
session: SessionSummary
messages: HermesMessage[]
total: number
offset: number
limit: number
hasMore: boolean
}
export interface SessionSearchResult extends SessionSummary {
matched_message_id: number | null
snippet: string
@@ -96,6 +105,26 @@ export async function fetchSession(id: string, profile?: string | null): Promise
}
}
export async function fetchSessionMessagesPage(
id: string,
offset: number,
limit = 300,
profile?: string | null,
): Promise<PaginatedSessionMessages | null> {
try {
const params = new URLSearchParams()
params.set('offset', String(offset))
params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const res = await request<PaginatedSessionMessages>(
`/api/hermes/sessions/conversations/${encodeURIComponent(id)}/messages/paginated?${params}`,
)
return res
} catch {
return null
}
}
/**
* Fetch Hermes session detail only (exclude api_server source)
*/
@@ -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>
+59 -4
View File
@@ -1,5 +1,5 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, onSessionCommand, respondClarify, type RunEvent, type ResumeSessionPayload, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { deleteSession as deleteSessionApi, fetchSessionMessagesPage, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getActiveProfileName } from '@/api/client'
import { getDownloadUrl } from '@/api/hermes/download'
import { defineStore } from 'pinia'
@@ -77,6 +77,10 @@ export interface Session {
model?: string
provider?: string
messageCount?: number
messageTotal?: number
loadedMessageCount?: number
hasMoreBefore?: boolean
isLoadingOlderMessages?: boolean
inputTokens?: number
outputTokens?: number
contextTokens?: number
@@ -281,6 +285,9 @@ function mapHermesSession(s: SessionSummary): Session {
model: s.model,
provider: s.provider || (s as any).billing_provider || '',
messageCount: s.message_count,
messageTotal: s.message_count,
loadedMessageCount: 0,
hasMoreBefore: false,
inputTokens: s.input_tokens,
outputTokens: s.output_tokens,
endedAt: s.ended_at != null ? Math.round(s.ended_at * 1000) : null,
@@ -511,13 +518,18 @@ export const useChatStore = defineStore('chat', () => {
const sid = activeSessionId.value
if (!sid) return false
try {
const detail = await fetchSession(sid, activeSession.value?.profile)
if (!detail) return false
const target = sessions.value.find(s => s.id === sid)
if (!target) return false
const limit = Math.max(target.loadedMessageCount || 300, 300)
const detail = await fetchSessionMessagesPage(sid, 0, limit, activeSession.value?.profile)
if (!detail) return false
const mapped = mapHermesMessages(detail.messages || [])
target.messages = mapped
if (detail.title) target.title = detail.title
target.loadedMessageCount = detail.messages.length
target.messageTotal = detail.total
target.messageCount = detail.total
target.hasMoreBefore = detail.hasMore
if (detail.session.title) target.title = detail.session.title
return true
} catch (err) {
console.error('Failed to refresh active session:', err)
@@ -620,6 +632,10 @@ export const useChatStore = defineStore('chat', () => {
if ((data as any).contextTokens != null) target.contextTokens = (data as any).contextTokens
if (data.messages?.length) {
target.messages = mapHermesMessages(data.messages as any[])
target.loadedMessageCount = data.messageLoadedCount ?? data.messages.length
target.messageTotal = data.messageTotal ?? target.messageCount ?? target.loadedMessageCount
target.messageCount = target.messageTotal
target.hasMoreBefore = data.hasMoreBefore ?? target.loadedMessageCount < target.messageTotal
}
if (!target.title) {
const firstUser = target.messages.find(m => m.role === 'user')
@@ -728,6 +744,36 @@ export const useChatStore = defineStore('chat', () => {
}
}
async function loadOlderMessages(sessionId = activeSessionId.value): Promise<boolean> {
if (!sessionId) return false
const target = sessions.value.find(s => s.id === sessionId)
if (!target || target.isLoadingOlderMessages || !target.hasMoreBefore) return false
const offset = target.loadedMessageCount || 0
const limit = 300
target.isLoadingOlderMessages = true
try {
const page = await fetchSessionMessagesPage(sessionId, offset, limit, target.profile)
if (!page || page.messages.length === 0) {
target.hasMoreBefore = false
return false
}
const existingIds = new Set(target.messages.map(message => message.id))
const olderMessages = mapHermesMessages(page.messages).filter(message => !existingIds.has(message.id))
target.messages = [...olderMessages, ...target.messages]
target.loadedMessageCount = offset + page.messages.length
target.messageTotal = page.total
target.messageCount = page.total
target.hasMoreBefore = page.hasMore
return olderMessages.length > 0
} catch (err) {
console.error('Failed to load older session messages:', err)
return false
} finally {
target.isLoadingOlderMessages = false
}
}
function newChat(options: { profile?: string; model?: string; provider?: string } = {}): Session {
const appStore = useAppStore()
const session = createSession({
@@ -1438,6 +1484,10 @@ export const useChatStore = defineStore('chat', () => {
if (Array.isArray(data.messages)) {
target.messages = mapHermesMessages(data.messages as any[])
target.loadedMessageCount = data.messageLoadedCount ?? data.messages.length
target.messageTotal = data.messageTotal ?? target.messageCount ?? target.loadedMessageCount
target.messageCount = target.messageTotal
target.hasMoreBefore = data.hasMoreBefore ?? target.loadedMessageCount < target.messageTotal
const lastAssistant = [...target.messages].reverse().find(m => m.role === 'assistant')
if (data.isWorking && lastAssistant) {
lastAssistant.isStreaming = true
@@ -2518,6 +2568,10 @@ export const useChatStore = defineStore('chat', () => {
if (!data.isWorking) setCompressionState(sid, null)
if (data.messages?.length && activeSession.value) {
activeSession.value.messages = mapHermesMessages(data.messages as any[])
activeSession.value.loadedMessageCount = data.messageLoadedCount ?? data.messages.length
activeSession.value.messageTotal = data.messageTotal ?? activeSession.value.messageCount ?? activeSession.value.loadedMessageCount
activeSession.value.messageCount = activeSession.value.messageTotal
activeSession.value.hasMoreBefore = data.hasMoreBefore ?? activeSession.value.loadedMessageCount < activeSession.value.messageTotal
}
resumeServerWorkingRun(sid)
}, activeSession.value?.profile)
@@ -2618,6 +2672,7 @@ export const useChatStore = defineStore('chat', () => {
newChat,
newCliSession,
switchSession,
loadOlderMessages,
switchSessionModel,
addOrUpdateSession,
clearProviderFromSessions,
@@ -125,6 +125,23 @@ export const useGroupChatStore = defineStore('groupChat', () => {
const contextStatuses = ref<Map<string, { agentName: string; status: string }>>(new Map())
const autoPlaySpeechEnabled = ref(false)
const pendingApprovals = ref<Map<string, GroupPendingApproval>>(new Map())
const totalMessages = ref(0)
const loadedMessageCount = ref(0)
const hasMoreBefore = ref(false)
const isLoadingOlderMessages = ref(false)
function resetMessagePaging() {
totalMessages.value = 0
loadedMessageCount.value = 0
hasMoreBefore.value = false
isLoadingOlderMessages.value = false
}
function applyMessagePaging(res: { messages: ChatMessage[]; total?: number; hasMore?: boolean }) {
loadedMessageCount.value = res.messages.length
totalMessages.value = res.total ?? res.messages.length
hasMoreBefore.value = res.hasMore ?? loadedMessageCount.value < totalMessages.value
}
function setAutoPlaySpeech(enabled: boolean) {
autoPlaySpeechEnabled.value = enabled
@@ -232,6 +249,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
messages.value = [...messages.value]
} else {
messages.value.push(resolvedMsg)
loadedMessageCount.value += 1
totalMessages.value = Math.max(totalMessages.value + 1, loadedMessageCount.value)
}
if (autoPlaySpeechEnabled.value && resolvedMsg.role === 'assistant' && resolvedMsg.content?.trim()) {
setTimeout(() => playMessageSpeech(resolvedMsg.id, resolvedMsg.content), 300)
@@ -266,6 +285,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
messages.value = [...messages.value]
} else {
messages.value.push(msg)
loadedMessageCount.value += 1
totalMessages.value = Math.max(totalMessages.value + 1, loadedMessageCount.value)
}
})
@@ -400,6 +421,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
if (room) room.totalTokens = data.totalTokens
if (data.roomId === currentRoomId.value) {
messages.value = []
resetMessagePaging()
typingUsers.value.clear()
contextStatuses.value.clear()
pendingApprovals.value.clear()
@@ -412,6 +434,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
connected.value = false
currentRoomId.value = null
messages.value = []
resetMessagePaging()
members.value = []
agents.value = []
roomName.value = ''
@@ -436,6 +459,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
currentRoomId.value = res.room.id
roomName.value = res.room.name
messages.value = res.messages
applyMessagePaging(res)
agents.value = res.agents
members.value = res.members || []
} catch (err: any) {
@@ -481,6 +505,28 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
}
async function loadOlderMessages(): Promise<boolean> {
const roomId = currentRoomId.value
if (!roomId || isLoadingOlderMessages.value || !hasMoreBefore.value) return false
const offset = loadedMessageCount.value
isLoadingOlderMessages.value = true
try {
const res = await getRoomDetail(roomId, { offset, limit: 300 })
const existingIds = new Set(messages.value.map(message => message.id))
const olderMessages = res.messages.filter(message => !existingIds.has(message.id))
messages.value = [...olderMessages, ...messages.value]
loadedMessageCount.value = offset + res.messages.length
totalMessages.value = res.total ?? totalMessages.value
hasMoreBefore.value = res.hasMore ?? loadedMessageCount.value < totalMessages.value
return olderMessages.length > 0
} catch (err: any) {
error.value = err.message
return false
} finally {
isLoadingOlderMessages.value = false
}
}
async function sendMessage(content: string, attachments?: Attachment[]) {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
@@ -503,6 +549,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
role: 'user',
attachments: attachments.map(att => ({ ...att, url: urlMap.get(att.name) || att.url, file: undefined })),
})
loadedMessageCount.value += 1
totalMessages.value = Math.max(totalMessages.value + 1, loadedMessageCount.value)
}
return new Promise<void>((resolve, reject) => {
@@ -560,6 +608,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
if (currentRoomId.value === roomId) {
currentRoomId.value = null
messages.value = []
resetMessagePaging()
members.value = []
agents.value = []
roomName.value = ''
@@ -586,6 +635,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
try {
const res = await clearRoomContext(currentRoomId.value)
messages.value = []
resetMessagePaging()
typingUsers.value.clear()
contextStatuses.value.clear()
const idx = rooms.value.findIndex(r => r.id === currentRoomId.value)
@@ -690,6 +740,10 @@ export const useGroupChatStore = defineStore('groupChat', () => {
pendingApprovals,
activePendingApproval,
autoPlaySpeechEnabled,
totalMessages,
loadedMessageCount,
hasMoreBefore,
isLoadingOlderMessages,
userId,
userName,
// Computed
@@ -703,6 +757,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
setUserInfo,
setAutoPlaySpeech,
joinRoom,
loadOlderMessages,
sendMessage,
loadRooms,
emitTyping,
@@ -452,7 +452,7 @@ export function updateSessionStats(id: string): void {
export function getSessionDetailPaginated(
id: string,
offset = 0,
limit = 500,
limit = 300,
): PaginatedSessionDetailResult | null {
if (!isSqliteAvailable()) {
return null
@@ -182,10 +182,13 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
return
}
const messages = chatServer.getStorage().getMessages(ctx.params.roomId)
const offset = ctx.query.offset ? Math.max(0, parseInt(ctx.query.offset as string, 10) || 0) : 0
const limit = ctx.query.limit ? Math.max(1, parseInt(ctx.query.limit as string, 10) || 300) : 300
const messages = chatServer.getStorage().getMessages(ctx.params.roomId, limit, offset)
const total = chatServer.getStorage().getMessageCount(ctx.params.roomId)
const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
const members = chatServer.getStorage().getRoomMembers(ctx.params.roomId)
ctx.body = { room, messages, agents, members }
ctx.body = { room, messages, agents, members, total, offset, limit, hasMore: offset + messages.length < total }
})
// List rooms
@@ -390,16 +390,23 @@ class ChatStorage {
// ─── Messages ─────────────────────────────────────────────
getMessages(roomId: string, limit = 500): ChatMessage[] {
getMessages(roomId: string, limit = 300, offset = 0): ChatMessage[] {
const rows = (this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
).all(roomId, limit) || []) as any[]
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?'
).all(roomId, limit, offset) || []) as any[]
return sortGroupMessages(rows.map(row => ({
...row,
tool_calls: parseJsonArray(row.tool_calls),
})))
}
getMessageCount(roomId: string): number {
const row = this.db()?.prepare(
'SELECT COUNT(*) as total FROM gc_messages WHERE roomId = ?'
).get(roomId) as { total: number } | undefined
return row?.total || 0
}
getMessage(messageId: string): ChatMessage | null {
const row = this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE id = ?'
@@ -68,6 +68,10 @@ export async function loadSessionStateFromDb(sid: string, _sessionMap: Map<strin
logger.info('[chat-run-socket] loaded session %s from DB (%d messages)', sid, messages.length)
return {
messages,
messageTotal: actualDetail?.total || messages.length,
messageLoadedCount: actualDetail?.messages.length || messages.length,
messagePageLimit: actualDetail?.limit,
hasMoreBefore: actualDetail?.hasMore || false,
isWorking: false,
events: [],
inputTokens,
@@ -334,6 +334,10 @@ export class ChatRunSocket {
socket.emit('resumed', {
session_id: sid,
messages: state.messages,
messageTotal: state.messageTotal,
messageLoadedCount: state.messageLoadedCount,
messagePageLimit: state.messagePageLimit,
hasMoreBefore: state.hasMoreBefore,
isWorking: state.isWorking,
isAborting: state.isAborting || false,
events: state.isWorking ? state.events : [],
@@ -43,6 +43,10 @@ export interface QueuedRun {
export interface SessionState {
messages: SessionMessage[]
messageTotal?: number
messageLoadedCount?: number
messagePageLimit?: number
hasMoreBefore?: boolean
isWorking: boolean
events: Array<{ event: string; data: any }>
abortController?: AbortController