Add virtualized chat pagination (#1080)
This commit is contained in:
@@ -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 }>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user