Add virtualized chat pagination (#1080)
This commit is contained in:
@@ -75,6 +75,10 @@ export interface RunEvent {
|
|||||||
export interface ResumeSessionPayload {
|
export interface ResumeSessionPayload {
|
||||||
session_id: string
|
session_id: string
|
||||||
messages: any[]
|
messages: any[]
|
||||||
|
messageTotal?: number
|
||||||
|
messageLoadedCount?: number
|
||||||
|
messagePageLimit?: number
|
||||||
|
hasMoreBefore?: boolean
|
||||||
isWorking: boolean
|
isWorking: boolean
|
||||||
isAborting?: boolean
|
isAborting?: boolean
|
||||||
events: Array<{ event: string; data: RunEvent }>
|
events: Array<{ event: string; data: RunEvent }>
|
||||||
|
|||||||
@@ -162,8 +162,15 @@ export async function listRooms(): Promise<{ rooms: RoomInfo[] }> {
|
|||||||
return request('/api/hermes/group-chat/rooms')
|
return request('/api/hermes/group-chat/rooms')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRoomDetail(roomId: string): Promise<{ room: RoomInfo; messages: ChatMessage[]; agents: RoomAgent[]; members: MemberInfo[] }> {
|
export async function getRoomDetail(
|
||||||
return request(`/api/hermes/group-chat/rooms/${roomId}`)
|
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 }> {
|
export async function joinRoomByCode(code: string): Promise<{ room: RoomInfo }> {
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ export interface SessionDetail extends SessionSummary {
|
|||||||
messages: HermesMessage[]
|
messages: HermesMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaginatedSessionMessages {
|
||||||
|
session: SessionSummary
|
||||||
|
messages: HermesMessage[]
|
||||||
|
total: number
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionSearchResult extends SessionSummary {
|
export interface SessionSearchResult extends SessionSummary {
|
||||||
matched_message_id: number | null
|
matched_message_id: number | null
|
||||||
snippet: string
|
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)
|
* Fetch Hermes session detail only (exclude api_server source)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const profilesStore = useProfilesStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
||||||
@@ -150,6 +152,9 @@ function selectBridgeCommand(command: { name: string; args: string; insertText?:
|
|||||||
|
|
||||||
const contextLength = ref(256000)
|
const contextLength = ref(256000)
|
||||||
const FALLBACK_CONTEXT = 256000
|
const FALLBACK_CONTEXT = 256000
|
||||||
|
let contextLengthLoadedKey = ''
|
||||||
|
let contextLengthRequestKey = ''
|
||||||
|
let contextLengthRequest: Promise<void> | null = null
|
||||||
|
|
||||||
// Context length editing
|
// Context length editing
|
||||||
const showContextEditModal = ref(false)
|
const showContextEditModal = ref(false)
|
||||||
@@ -169,8 +174,8 @@ async function saveContextLimit() {
|
|||||||
|
|
||||||
isSavingContextLimit.value = true
|
isSavingContextLimit.value = true
|
||||||
try {
|
try {
|
||||||
const provider = chatStore.activeSession?.provider || useAppStore().selectedProvider || ''
|
const provider = chatStore.activeSession?.provider || appStore.selectedProvider || ''
|
||||||
const model = chatStore.activeSession?.model || useAppStore().selectedModel || ''
|
const model = chatStore.activeSession?.model || appStore.selectedModel || ''
|
||||||
|
|
||||||
if (!provider || !model) {
|
if (!provider || !model) {
|
||||||
message.error(t('chat.contextEditFailed'))
|
message.error(t('chat.contextEditFailed'))
|
||||||
@@ -179,6 +184,7 @@ async function saveContextLimit() {
|
|||||||
|
|
||||||
await setModelContext(provider, model, editingContextLimit.value)
|
await setModelContext(provider, model, editingContextLimit.value)
|
||||||
contextLength.value = editingContextLimit.value
|
contextLength.value = editingContextLimit.value
|
||||||
|
contextLengthLoadedKey = currentContextLengthKey()
|
||||||
showContextEditModal.value = false
|
showContextEditModal.value = false
|
||||||
message.success(t('chat.contextEditSuccess'))
|
message.success(t('chat.contextEditSuccess'))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -188,28 +194,61 @@ async function saveContextLimit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadContextLength() {
|
function currentContextLengthParams() {
|
||||||
try {
|
const activeSession = chatStore.activeSession
|
||||||
const activeSession = chatStore.activeSession
|
return {
|
||||||
const profile = activeSession?.profile || useProfilesStore().activeProfileName || undefined
|
profile: activeSession?.profile || profilesStore.activeProfileName || undefined,
|
||||||
contextLength.value = await fetchContextLength(
|
provider: activeSession?.provider || undefined,
|
||||||
profile,
|
model: activeSession?.model || undefined,
|
||||||
activeSession?.provider || undefined,
|
|
||||||
activeSession?.model || undefined,
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
contextLength.value = FALLBACK_CONTEXT
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
onMounted(loadContextLength)
|
||||||
watch(() => useProfilesStore().activeProfileName, loadContextLength)
|
watch(
|
||||||
watch(() => useAppStore().selectedProvider, loadContextLength)
|
() => [
|
||||||
watch(() => useAppStore().selectedModel, loadContextLength)
|
profilesStore.activeProfileName,
|
||||||
watch(() => chatStore.activeSession?.id, loadContextLength)
|
appStore.selectedProvider,
|
||||||
watch(() => chatStore.activeSession?.profile, loadContextLength)
|
appStore.selectedModel,
|
||||||
watch(() => chatStore.activeSession?.provider, loadContextLength)
|
chatStore.activeSession?.id,
|
||||||
watch(() => chatStore.activeSession?.model, loadContextLength)
|
chatStore.activeSession?.profile,
|
||||||
|
chatStore.activeSession?.provider,
|
||||||
|
chatStore.activeSession?.model,
|
||||||
|
],
|
||||||
|
loadContextLength,
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
const totalTokens = computed(() => {
|
const totalTokens = computed(() => {
|
||||||
const context = chatStore.activeSession?.contextTokens
|
const context = chatStore.activeSession?.contextTokens
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import VirtualMessageList from "./VirtualMessageList.vue";
|
||||||
import MessageItem from "./MessageItem.vue";
|
import MessageItem from "./MessageItem.vue";
|
||||||
import { useChatStore } from "@/stores/hermes/chat";
|
import { useChatStore } from "@/stores/hermes/chat";
|
||||||
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||||
@@ -13,7 +14,7 @@ const props = defineProps<{
|
|||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const { toolTraceVisible } = useToolTraceVisibility();
|
const { toolTraceVisible } = useToolTraceVisibility();
|
||||||
const { t } = useI18n();
|
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
|
// Use provided session or fall back to chatStore's active session
|
||||||
const activeSession = computed(() => props.session || chatStore.activeSession);
|
const activeSession = computed(() => props.session || chatStore.activeSession);
|
||||||
@@ -29,38 +30,27 @@ const displayMessages = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
function isNearBottom(threshold = 200): boolean {
|
function isNearBottom(threshold = 200): boolean {
|
||||||
const el = listRef.value;
|
return listRef.value?.isNearBottom(threshold) ?? true;
|
||||||
if (!el) return true;
|
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
nextTick(() => {
|
listRef.value?.scrollToBottom();
|
||||||
if (listRef.value) {
|
|
||||||
listRef.value.scrollTop = listRef.value.scrollHeight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToMessage(messageId: string) {
|
function scrollToMessage(messageId: string) {
|
||||||
nextTick(() => {
|
listRef.value?.scrollToMessage(messageId);
|
||||||
const el = document.getElementById(`message-${messageId}`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ block: 'center' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom on session switch
|
// Scroll to bottom on session switch
|
||||||
watch(
|
watch(
|
||||||
() => chatStore.activeSessionId,
|
() => activeSession.value?.id,
|
||||||
(id) => {
|
(id) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (chatStore.focusMessageId) {
|
if (chatStore.focusMessageId) {
|
||||||
nextTick(() => scrollToMessage(chatStore.focusMessageId!));
|
scrollToMessage(chatStore.focusMessageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nextTick(() => scrollToBottom());
|
scrollToBottom();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -94,37 +84,28 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="listRef" class="message-list">
|
<VirtualMessageList
|
||||||
<div v-if="!activeSession || activeSession.messages.length === 0" class="empty-state">
|
ref="listRef"
|
||||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
:messages="displayMessages"
|
||||||
<p>{{ t("chat.emptyState") }}</p>
|
>
|
||||||
</div>
|
<template #empty>
|
||||||
<MessageItem
|
<div class="empty-state">
|
||||||
v-for="msg in displayMessages"
|
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||||
:key="msg.id"
|
<p>{{ t("chat.emptyState") }}</p>
|
||||||
:message="msg"
|
</div>
|
||||||
:highlight="chatStore.focusMessageId === msg.id"
|
</template>
|
||||||
/>
|
<template #item="{ message: msg }">
|
||||||
</div>
|
<MessageItem
|
||||||
|
:message="msg"
|
||||||
|
:highlight="chatStore.focusMessageId === msg.id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VirtualMessageList>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/styles/variables" as *;
|
@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 {
|
.empty-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from "vue";
|
import { ref, computed, nextTick, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import VirtualMessageList from "./VirtualMessageList.vue";
|
||||||
import MessageItem from "./MessageItem.vue";
|
import MessageItem from "./MessageItem.vue";
|
||||||
import { useChatStore } from "@/stores/hermes/chat";
|
import { useChatStore } from "@/stores/hermes/chat";
|
||||||
import thinkingImageLight from "@/assets/thinking-light.gif";
|
import thinkingImageLight from "@/assets/thinking-light.gif";
|
||||||
@@ -12,7 +13,7 @@ const chatStore = useChatStore();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const { toolTraceVisible } = useToolTraceVisibility();
|
const { toolTraceVisible } = useToolTraceVisibility();
|
||||||
const listRef = ref<HTMLElement>();
|
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
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 {
|
function isNearBottom(threshold = 200): boolean {
|
||||||
const el = listRef.value;
|
return listRef.value?.isNearBottom(threshold) ?? true;
|
||||||
if (!el) return true;
|
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
nextTick(() => {
|
listRef.value?.scrollToBottom();
|
||||||
if (listRef.value) {
|
|
||||||
listRef.value.scrollTop = listRef.value.scrollHeight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToMessage(messageId: string) {
|
function scrollToMessage(messageId: string) {
|
||||||
nextTick(() => {
|
listRef.value?.scrollToMessage(messageId);
|
||||||
const el = document.getElementById(`message-${messageId}`);
|
}
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ block: 'center' });
|
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
|
// Scroll to bottom on session switch
|
||||||
@@ -112,10 +112,10 @@ watch(
|
|||||||
(id) => {
|
(id) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (chatStore.focusMessageId) {
|
if (chatStore.focusMessageId) {
|
||||||
nextTick(() => scrollToMessage(chatStore.focusMessageId!));
|
scrollToMessage(chatStore.focusMessageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nextTick(() => scrollToBottom());
|
scrollToBottom();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -159,18 +159,33 @@ watch(currentToolCalls, () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="listRef" class="message-list">
|
<VirtualMessageList
|
||||||
<div v-if="chatStore.messages.length === 0" class="empty-state">
|
ref="listRef"
|
||||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
:messages="displayMessages"
|
||||||
<p>{{ t("chat.emptyState") }}</p>
|
@top-reach="handleTopReach"
|
||||||
</div>
|
>
|
||||||
<MessageItem
|
<template #empty>
|
||||||
v-for="msg in displayMessages"
|
<div class="empty-state">
|
||||||
:key="msg.id"
|
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||||
:message="msg"
|
<p>{{ t("chat.emptyState") }}</p>
|
||||||
:highlight="chatStore.focusMessageId === msg.id"
|
</div>
|
||||||
/>
|
</template>
|
||||||
<Transition name="fade">
|
<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">
|
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
|
||||||
<img
|
<img
|
||||||
:src="isDark ? thinkingImageDark : thinkingImageLight"
|
:src="isDark ? thinkingImageDark : thinkingImageLight"
|
||||||
@@ -331,8 +346,8 @@ watch(currentToolCalls, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition name="queue-float">
|
<Transition name="queue-float">
|
||||||
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
|
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
|
||||||
<div class="queue-float-header">
|
<div class="queue-float-header">
|
||||||
<span class="queue-orbit" aria-hidden="true">
|
<span class="queue-orbit" aria-hidden="true">
|
||||||
@@ -363,36 +378,22 @@ watch(currentToolCalls, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</template>
|
||||||
|
</VirtualMessageList>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/styles/variables" as *;
|
@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 {
|
.queue-float-panel {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
align-self: flex-end;
|
|
||||||
width: min(340px, calc(100% - 16px));
|
width: min(340px, calc(100% - 16px));
|
||||||
margin-top: auto;
|
margin-top: 16px;
|
||||||
|
margin-left: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
|
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
|
||||||
border-radius: 16px;
|
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-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.4s ease;
|
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 { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||||
import GroupMessageItem from './GroupMessageItem.vue'
|
import GroupMessageItem from './GroupMessageItem.vue'
|
||||||
|
import VirtualMessageList from '../chat/VirtualMessageList.vue'
|
||||||
|
|
||||||
const store = useGroupChatStore()
|
const store = useGroupChatStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { toolTraceVisible } = useToolTraceVisibility()
|
const { toolTraceVisible } = useToolTraceVisibility()
|
||||||
const listRef = ref<HTMLDivElement>()
|
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
|
||||||
const isNearBottom = ref(true)
|
const isNearBottom = ref(true)
|
||||||
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
|
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
|
||||||
|
|
||||||
function checkNearBottom(): void {
|
function checkNearBottom(): void {
|
||||||
if (!listRef.value) return
|
isNearBottom.value = listRef.value?.isNearBottom(200) ?? true
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listRef.value
|
|
||||||
isNearBottom.value = scrollHeight - scrollTop - clientHeight < 200
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(): void {
|
function scrollToBottom(): void {
|
||||||
if (!listRef.value) return
|
listRef.value?.scrollToBottom()
|
||||||
listRef.value.scrollTop = listRef.value.scrollHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll(): void {
|
async function handleTopReach(): Promise<void> {
|
||||||
checkNearBottom()
|
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 () => {
|
watch(() => store.messages.length, async () => {
|
||||||
@@ -38,39 +41,42 @@ defineExpose({ scrollToBottom })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="listRef" class="message-list" @scroll="handleScroll">
|
<VirtualMessageList
|
||||||
<div v-if="displayMessages.length === 0" class="empty-state">
|
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" />
|
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||||
<p>{{ t("chat.emptyState") }}</p>
|
<p>{{ t("chat.emptyState") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<GroupMessageItem
|
</template>
|
||||||
v-for="msg in displayMessages"
|
<template #before>
|
||||||
:key="msg.id"
|
<div
|
||||||
:message="msg"
|
v-if="store.hasMoreBefore || store.isLoadingOlderMessages"
|
||||||
:agents="store.agents"
|
class="history-loader"
|
||||||
:current-user-id="store.userId"
|
>
|
||||||
/>
|
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #item="{ message: msg }">
|
||||||
|
<GroupMessageItem
|
||||||
|
:message="msg"
|
||||||
|
:agents="store.agents"
|
||||||
|
:current-user-id="store.userId"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VirtualMessageList>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/styles/variables" as *;
|
@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 {
|
.empty-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -90,4 +96,32 @@ defineExpose({ scrollToBottom })
|
|||||||
font-size: 14px;
|
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>
|
</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 { 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 { getActiveProfileName } from '@/api/client'
|
||||||
import { getDownloadUrl } from '@/api/hermes/download'
|
import { getDownloadUrl } from '@/api/hermes/download'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
@@ -77,6 +77,10 @@ export interface Session {
|
|||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
messageCount?: number
|
messageCount?: number
|
||||||
|
messageTotal?: number
|
||||||
|
loadedMessageCount?: number
|
||||||
|
hasMoreBefore?: boolean
|
||||||
|
isLoadingOlderMessages?: boolean
|
||||||
inputTokens?: number
|
inputTokens?: number
|
||||||
outputTokens?: number
|
outputTokens?: number
|
||||||
contextTokens?: number
|
contextTokens?: number
|
||||||
@@ -281,6 +285,9 @@ function mapHermesSession(s: SessionSummary): Session {
|
|||||||
model: s.model,
|
model: s.model,
|
||||||
provider: s.provider || (s as any).billing_provider || '',
|
provider: s.provider || (s as any).billing_provider || '',
|
||||||
messageCount: s.message_count,
|
messageCount: s.message_count,
|
||||||
|
messageTotal: s.message_count,
|
||||||
|
loadedMessageCount: 0,
|
||||||
|
hasMoreBefore: false,
|
||||||
inputTokens: s.input_tokens,
|
inputTokens: s.input_tokens,
|
||||||
outputTokens: s.output_tokens,
|
outputTokens: s.output_tokens,
|
||||||
endedAt: s.ended_at != null ? Math.round(s.ended_at * 1000) : null,
|
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
|
const sid = activeSessionId.value
|
||||||
if (!sid) return false
|
if (!sid) return false
|
||||||
try {
|
try {
|
||||||
const detail = await fetchSession(sid, activeSession.value?.profile)
|
|
||||||
if (!detail) return false
|
|
||||||
const target = sessions.value.find(s => s.id === sid)
|
const target = sessions.value.find(s => s.id === sid)
|
||||||
if (!target) return false
|
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 || [])
|
const mapped = mapHermesMessages(detail.messages || [])
|
||||||
target.messages = mapped
|
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
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh active session:', 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 as any).contextTokens != null) target.contextTokens = (data as any).contextTokens
|
||||||
if (data.messages?.length) {
|
if (data.messages?.length) {
|
||||||
target.messages = mapHermesMessages(data.messages as any[])
|
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) {
|
if (!target.title) {
|
||||||
const firstUser = target.messages.find(m => m.role === 'user')
|
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 {
|
function newChat(options: { profile?: string; model?: string; provider?: string } = {}): Session {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
@@ -1438,6 +1484,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
if (Array.isArray(data.messages)) {
|
if (Array.isArray(data.messages)) {
|
||||||
target.messages = mapHermesMessages(data.messages as any[])
|
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')
|
const lastAssistant = [...target.messages].reverse().find(m => m.role === 'assistant')
|
||||||
if (data.isWorking && lastAssistant) {
|
if (data.isWorking && lastAssistant) {
|
||||||
lastAssistant.isStreaming = true
|
lastAssistant.isStreaming = true
|
||||||
@@ -2518,6 +2568,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
if (!data.isWorking) setCompressionState(sid, null)
|
if (!data.isWorking) setCompressionState(sid, null)
|
||||||
if (data.messages?.length && activeSession.value) {
|
if (data.messages?.length && activeSession.value) {
|
||||||
activeSession.value.messages = mapHermesMessages(data.messages as any[])
|
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)
|
resumeServerWorkingRun(sid)
|
||||||
}, activeSession.value?.profile)
|
}, activeSession.value?.profile)
|
||||||
@@ -2618,6 +2672,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
newChat,
|
newChat,
|
||||||
newCliSession,
|
newCliSession,
|
||||||
switchSession,
|
switchSession,
|
||||||
|
loadOlderMessages,
|
||||||
switchSessionModel,
|
switchSessionModel,
|
||||||
addOrUpdateSession,
|
addOrUpdateSession,
|
||||||
clearProviderFromSessions,
|
clearProviderFromSessions,
|
||||||
|
|||||||
@@ -125,6 +125,23 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
const contextStatuses = ref<Map<string, { agentName: string; status: string }>>(new Map())
|
const contextStatuses = ref<Map<string, { agentName: string; status: string }>>(new Map())
|
||||||
const autoPlaySpeechEnabled = ref(false)
|
const autoPlaySpeechEnabled = ref(false)
|
||||||
const pendingApprovals = ref<Map<string, GroupPendingApproval>>(new Map())
|
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) {
|
function setAutoPlaySpeech(enabled: boolean) {
|
||||||
autoPlaySpeechEnabled.value = enabled
|
autoPlaySpeechEnabled.value = enabled
|
||||||
@@ -232,6 +249,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
messages.value = [...messages.value]
|
messages.value = [...messages.value]
|
||||||
} else {
|
} else {
|
||||||
messages.value.push(resolvedMsg)
|
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()) {
|
if (autoPlaySpeechEnabled.value && resolvedMsg.role === 'assistant' && resolvedMsg.content?.trim()) {
|
||||||
setTimeout(() => playMessageSpeech(resolvedMsg.id, resolvedMsg.content), 300)
|
setTimeout(() => playMessageSpeech(resolvedMsg.id, resolvedMsg.content), 300)
|
||||||
@@ -266,6 +285,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
messages.value = [...messages.value]
|
messages.value = [...messages.value]
|
||||||
} else {
|
} else {
|
||||||
messages.value.push(msg)
|
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 (room) room.totalTokens = data.totalTokens
|
||||||
if (data.roomId === currentRoomId.value) {
|
if (data.roomId === currentRoomId.value) {
|
||||||
messages.value = []
|
messages.value = []
|
||||||
|
resetMessagePaging()
|
||||||
typingUsers.value.clear()
|
typingUsers.value.clear()
|
||||||
contextStatuses.value.clear()
|
contextStatuses.value.clear()
|
||||||
pendingApprovals.value.clear()
|
pendingApprovals.value.clear()
|
||||||
@@ -412,6 +434,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
connected.value = false
|
connected.value = false
|
||||||
currentRoomId.value = null
|
currentRoomId.value = null
|
||||||
messages.value = []
|
messages.value = []
|
||||||
|
resetMessagePaging()
|
||||||
members.value = []
|
members.value = []
|
||||||
agents.value = []
|
agents.value = []
|
||||||
roomName.value = ''
|
roomName.value = ''
|
||||||
@@ -436,6 +459,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
currentRoomId.value = res.room.id
|
currentRoomId.value = res.room.id
|
||||||
roomName.value = res.room.name
|
roomName.value = res.room.name
|
||||||
messages.value = res.messages
|
messages.value = res.messages
|
||||||
|
applyMessagePaging(res)
|
||||||
agents.value = res.agents
|
agents.value = res.agents
|
||||||
members.value = res.members || []
|
members.value = res.members || []
|
||||||
} catch (err: any) {
|
} 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[]) {
|
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||||
const socket = getSocket()
|
const socket = getSocket()
|
||||||
if (!socket || !currentRoomId.value) return
|
if (!socket || !currentRoomId.value) return
|
||||||
@@ -503,6 +549,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
attachments: attachments.map(att => ({ ...att, url: urlMap.get(att.name) || att.url, file: undefined })),
|
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) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
@@ -560,6 +608,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
if (currentRoomId.value === roomId) {
|
if (currentRoomId.value === roomId) {
|
||||||
currentRoomId.value = null
|
currentRoomId.value = null
|
||||||
messages.value = []
|
messages.value = []
|
||||||
|
resetMessagePaging()
|
||||||
members.value = []
|
members.value = []
|
||||||
agents.value = []
|
agents.value = []
|
||||||
roomName.value = ''
|
roomName.value = ''
|
||||||
@@ -586,6 +635,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
try {
|
try {
|
||||||
const res = await clearRoomContext(currentRoomId.value)
|
const res = await clearRoomContext(currentRoomId.value)
|
||||||
messages.value = []
|
messages.value = []
|
||||||
|
resetMessagePaging()
|
||||||
typingUsers.value.clear()
|
typingUsers.value.clear()
|
||||||
contextStatuses.value.clear()
|
contextStatuses.value.clear()
|
||||||
const idx = rooms.value.findIndex(r => r.id === currentRoomId.value)
|
const idx = rooms.value.findIndex(r => r.id === currentRoomId.value)
|
||||||
@@ -690,6 +740,10 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
activePendingApproval,
|
activePendingApproval,
|
||||||
autoPlaySpeechEnabled,
|
autoPlaySpeechEnabled,
|
||||||
|
totalMessages,
|
||||||
|
loadedMessageCount,
|
||||||
|
hasMoreBefore,
|
||||||
|
isLoadingOlderMessages,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
// Computed
|
// Computed
|
||||||
@@ -703,6 +757,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
setUserInfo,
|
setUserInfo,
|
||||||
setAutoPlaySpeech,
|
setAutoPlaySpeech,
|
||||||
joinRoom,
|
joinRoom,
|
||||||
|
loadOlderMessages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
loadRooms,
|
loadRooms,
|
||||||
emitTyping,
|
emitTyping,
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ export function updateSessionStats(id: string): void {
|
|||||||
export function getSessionDetailPaginated(
|
export function getSessionDetailPaginated(
|
||||||
id: string,
|
id: string,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 500,
|
limit = 300,
|
||||||
): PaginatedSessionDetailResult | null {
|
): PaginatedSessionDetailResult | null {
|
||||||
if (!isSqliteAvailable()) {
|
if (!isSqliteAvailable()) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -182,10 +182,13 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
|
|||||||
return
|
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 agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
|
||||||
const members = chatServer.getStorage().getRoomMembers(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
|
// List rooms
|
||||||
|
|||||||
@@ -390,16 +390,23 @@ class ChatStorage {
|
|||||||
|
|
||||||
// ─── Messages ─────────────────────────────────────────────
|
// ─── Messages ─────────────────────────────────────────────
|
||||||
|
|
||||||
getMessages(roomId: string, limit = 500): ChatMessage[] {
|
getMessages(roomId: string, limit = 300, offset = 0): ChatMessage[] {
|
||||||
const rows = (this.db()?.prepare(
|
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 ?'
|
'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) || []) as any[]
|
).all(roomId, limit, offset) || []) as any[]
|
||||||
return sortGroupMessages(rows.map(row => ({
|
return sortGroupMessages(rows.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
tool_calls: parseJsonArray(row.tool_calls),
|
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 {
|
getMessage(messageId: string): ChatMessage | null {
|
||||||
const row = this.db()?.prepare(
|
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 = ?'
|
'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)
|
logger.info('[chat-run-socket] loaded session %s from DB (%d messages)', sid, messages.length)
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
|
messageTotal: actualDetail?.total || messages.length,
|
||||||
|
messageLoadedCount: actualDetail?.messages.length || messages.length,
|
||||||
|
messagePageLimit: actualDetail?.limit,
|
||||||
|
hasMoreBefore: actualDetail?.hasMore || false,
|
||||||
isWorking: false,
|
isWorking: false,
|
||||||
events: [],
|
events: [],
|
||||||
inputTokens,
|
inputTokens,
|
||||||
|
|||||||
@@ -334,6 +334,10 @@ export class ChatRunSocket {
|
|||||||
socket.emit('resumed', {
|
socket.emit('resumed', {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
messages: state.messages,
|
messages: state.messages,
|
||||||
|
messageTotal: state.messageTotal,
|
||||||
|
messageLoadedCount: state.messageLoadedCount,
|
||||||
|
messagePageLimit: state.messagePageLimit,
|
||||||
|
hasMoreBefore: state.hasMoreBefore,
|
||||||
isWorking: state.isWorking,
|
isWorking: state.isWorking,
|
||||||
isAborting: state.isAborting || false,
|
isAborting: state.isAborting || false,
|
||||||
events: state.isWorking ? state.events : [],
|
events: state.isWorking ? state.events : [],
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export interface QueuedRun {
|
|||||||
|
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
messages: SessionMessage[]
|
messages: SessionMessage[]
|
||||||
|
messageTotal?: number
|
||||||
|
messageLoadedCount?: number
|
||||||
|
messagePageLimit?: number
|
||||||
|
hasMoreBefore?: boolean
|
||||||
isWorking: boolean
|
isWorking: boolean
|
||||||
events: Array<{ event: string; data: any }>
|
events: Array<{ event: string; data: any }>
|
||||||
abortController?: AbortController
|
abortController?: AbortController
|
||||||
|
|||||||
Reference in New Issue
Block a user