page history session messages (#1099)

This commit is contained in:
ekko
2026-05-28 19:47:24 +08:00
committed by GitHub
parent 9d2e82cd06
commit 932c913d63
4 changed files with 246 additions and 61 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } 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 VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue"; import MessageItem from "./MessageItem.vue";
@@ -9,6 +9,7 @@ import type { Session } from "@/stores/hermes/chat";
const props = defineProps<{ const props = defineProps<{
session?: Session | null; // Optional: use this session instead of chatStore.activeSession session?: Session | null; // Optional: use this session instead of chatStore.activeSession
loadOlder?: (sessionId: string) => Promise<boolean>;
}>(); }>();
const chatStore = useChatStore(); const chatStore = useChatStore();
@@ -45,6 +46,16 @@ function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId); listRef.value?.scrollToAnchor(messageId, anchorId);
} }
async function handleTopReach() {
const session = activeSession.value;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages || !props.loadOlder) return;
const snapshot = listRef.value?.captureScrollPosition() ?? null;
const loaded = await props.loadOlder(session.id);
if (!loaded) return;
await nextTick();
listRef.value?.restoreScrollPosition(snapshot);
}
// Scroll to bottom on session switch // Scroll to bottom on session switch
watch( watch(
() => activeSession.value?.id, () => activeSession.value?.id,
@@ -97,6 +108,7 @@ defineExpose({
<VirtualMessageList <VirtualMessageList
ref="listRef" ref="listRef"
:messages="displayMessages" :messages="displayMessages"
@top-reach="handleTopReach"
> >
<template #empty> <template #empty>
<div class="empty-state"> <div class="empty-state">
@@ -104,6 +116,14 @@ defineExpose({
<p>{{ t("chat.emptyState") }}</p> <p>{{ t("chat.emptyState") }}</p>
</div> </div>
</template> </template>
<template #before>
<div
v-if="activeSession?.hasMoreBefore || activeSession?.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }"> <template #item="{ message: msg }">
<MessageItem <MessageItem
:message="msg" :message="msg"
@@ -136,6 +156,34 @@ defineExpose({
} }
} }
.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);
}
}
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.4s ease; transition: opacity 0.4s ease;
+123 -47
View File
@@ -12,7 +12,7 @@ import { copyToClipboard } from '@/utils/clipboard'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue' import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue' import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue' import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
import { batchDeleteSessions, deleteSession, fetchHermesSessions, fetchHermesSession, importHermesSession, type SessionSummary } from '@/api/hermes/sessions' import { batchDeleteSessions, deleteSession, fetchHermesSessions, fetchHermesSession, fetchSessionMessagesPage, importHermesSession, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
const appStore = useAppStore() const appStore = useAppStore()
const profilesStore = useProfilesStore() const profilesStore = useProfilesStore()
@@ -53,6 +53,8 @@ const contextMenuX = ref(0)
const contextMenuY = ref(0) const contextMenuY = ref(0)
let hermesSessionsRequestId = 0 let hermesSessionsRequestId = 0
const HISTORY_PAGE_SIZE = 300
function handleOutlineNavigate(target: { messageId: string; anchorId: string }) { function handleOutlineNavigate(target: { messageId: string; anchorId: string }) {
historyMessageListRef.value?.scrollToAnchor(target.messageId, target.anchorId) historyMessageListRef.value?.scrollToAnchor(target.messageId, target.anchorId)
} }
@@ -107,56 +109,98 @@ const contextMenuOptions = computed<DropdownOption[]>(() => {
return options return options
}) })
function mapHistoryMessages(messages: HermesMessage[]): Session['messages'] {
return messages.map(m => {
const msg: Session['messages'][number] = {
id: String(m.id),
role: m.role,
content: m.content || '',
timestamp: m.timestamp * 1000,
reasoning: m.reasoning || undefined,
systemType: m.role === 'command' ? 'command' : undefined,
}
if (m.role === 'tool') {
msg.toolName = m.tool_name || undefined
msg.toolCallId = m.tool_call_id || undefined
msg.toolArgs = m.tool_calls?.[0]?.function?.arguments
? JSON.stringify(m.tool_calls[0].function.arguments)
: undefined
msg.toolStatus = 'done'
msg.toolResult = m.content || undefined
msg.content = ''
}
return msg
})
}
function sessionFromSummary(summary: SessionSummary, messages: Session['messages'] = []): Session {
return {
id: summary.id,
profile: summary.profile || undefined,
title: summary.title || '',
source: summary.source,
createdAt: summary.started_at * 1000,
updatedAt: (summary.last_active || summary.ended_at || summary.started_at) * 1000,
model: summary.model,
provider: summary.provider,
messageCount: summary.message_count,
messageTotal: summary.message_count,
loadedMessageCount: messages.length,
hasMoreBefore: false,
inputTokens: summary.input_tokens,
outputTokens: summary.output_tokens,
endedAt: summary.ended_at ? summary.ended_at * 1000 : undefined,
lastActiveAt: summary.last_active ? summary.last_active * 1000 : undefined,
workspace: summary.workspace || undefined,
messages,
}
}
async function loadHistorySession(sessionId: string, profile?: string | null) { async function loadHistorySession(sessionId: string, profile?: string | null) {
const summary = findHistorySession(sessionId) const summary = findHistorySession(sessionId)
const sessionProfile = profile || summary?.profile || null const sessionProfile = profile || summary?.profile || null
// First, fetch the Hermes session detail const page = await fetchSessionMessagesPage(sessionId, 0, HISTORY_PAGE_SIZE, sessionProfile)
const sessionDetail = await fetchHermesSession(sessionId, sessionProfile) let sessionData: Session | null = null
if (!sessionDetail) {
message.error(t('chat.sessionNotFound'))
return
}
// Convert SessionDetail to Session format and add to chatStore if (page) {
const sessionData: Session = { const base = summary || page.session
id: sessionDetail.id, sessionData = sessionFromSummary(base, mapHistoryMessages(page.messages))
profile: sessionDetail.profile || sessionProfile || undefined, sessionData.profile = summary?.profile || sessionProfile || undefined
title: sessionDetail.title || '', sessionData.messageCount = page.total
source: sessionDetail.source, sessionData.messageTotal = page.total
createdAt: sessionDetail.started_at * 1000, sessionData.loadedMessageCount = page.messages.length
updatedAt: (sessionDetail.last_active || sessionDetail.started_at) * 1000, sessionData.hasMoreBefore = page.hasMore
model: sessionDetail.model, } else {
messageCount: sessionDetail.message_count, // Some imported/legacy Hermes sessions may only exist in Hermes state.db.
inputTokens: sessionDetail.input_tokens, // Keep the old full-detail path as a compatibility fallback.
outputTokens: sessionDetail.output_tokens, const sessionDetail = await fetchHermesSession(sessionId, sessionProfile)
endedAt: sessionDetail.ended_at ? sessionDetail.ended_at * 1000 : undefined, if (!sessionDetail) {
lastActiveAt: sessionDetail.last_active ? sessionDetail.last_active * 1000 : undefined, message.error(t('chat.sessionNotFound'))
workspace: sessionDetail.workspace || undefined, return
messages: sessionDetail.messages.map(m => { }
const msg: any = {
id: String(m.id),
sessionId: m.session_id,
role: m.role,
content: m.content || '',
timestamp: m.timestamp * 1000,
}
// Preserve tool-related fields sessionData = {
if (m.role === 'tool') { id: sessionDetail.id,
msg.toolName = m.tool_name profile: sessionDetail.profile || sessionProfile || undefined,
msg.toolArgs = m.tool_calls?.[0]?.function?.arguments title: sessionDetail.title || '',
? JSON.stringify(m.tool_calls[0].function.arguments) source: sessionDetail.source,
: undefined createdAt: sessionDetail.started_at * 1000,
msg.toolStatus = 'done' updatedAt: (sessionDetail.last_active || sessionDetail.started_at) * 1000,
} model: sessionDetail.model,
provider: sessionDetail.provider,
// Preserve reasoning field messageCount: sessionDetail.message_count,
if (m.reasoning) { messageTotal: sessionDetail.message_count,
msg.reasoning = m.reasoning loadedMessageCount: sessionDetail.messages.length,
} hasMoreBefore: false,
inputTokens: sessionDetail.input_tokens,
return msg outputTokens: sessionDetail.output_tokens,
}), endedAt: sessionDetail.ended_at ? sessionDetail.ended_at * 1000 : undefined,
lastActiveAt: sessionDetail.last_active ? sessionDetail.last_active * 1000 : undefined,
workspace: sessionDetail.workspace || undefined,
messages: mapHistoryMessages(sessionDetail.messages),
}
} }
// Set history page's own session state (independent from chatStore) // Set history page's own session state (independent from chatStore)
@@ -166,6 +210,34 @@ async function loadHistorySession(sessionId: string, profile?: string | null) {
if (mobileQuery?.matches) showSessions.value = false if (mobileQuery?.matches) showSessions.value = false
} }
async function loadOlderHistoryMessages(sessionId: string): Promise<boolean> {
const target = historySession.value
if (!target || target.id !== sessionId || target.isLoadingOlderMessages || !target.hasMoreBefore) return false
const offset = target.loadedMessageCount || 0
target.isLoadingOlderMessages = true
try {
const page = await fetchSessionMessagesPage(sessionId, offset, HISTORY_PAGE_SIZE, 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 = mapHistoryMessages(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 history messages:', err)
return false
} finally {
target.isLoadingOlderMessages = false
}
}
async function handleSessionClick(sessionId: string, profile?: string | null) { async function handleSessionClick(sessionId: string, profile?: string | null) {
await router.push({ await router.push({
name: 'hermes.historySession', name: 'hermes.historySession',
@@ -764,7 +836,11 @@ function handleBatchDeleteConfirm() {
<div class="history-content-wrapper"> <div class="history-content-wrapper">
<div class="history-main-content"> <div class="history-main-content">
<HistoryMessageList ref="historyMessageListRef" :session="historySession" /> <HistoryMessageList
ref="historyMessageListRef"
:session="historySession"
:load-older="loadOlderHistoryMessages"
/>
</div> </div>
<OutlinePanel <OutlinePanel
v-if="showOutline && historySession" v-if="showOutline && historySession"
@@ -1,5 +1,5 @@
import * as hermesCli from '../../services/hermes/hermes-cli' import * as hermesCli from '../../services/hermes/hermes-cli'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db' import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getSessionDetailPaginatedFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
import { import {
listSessions as localListSessions, listSessions as localListSessions,
searchSessions as localSearchSessions, searchSessions as localSearchSessions,
@@ -872,29 +872,35 @@ function serializeAsText(title: string | null, messages: any[]): string {
export async function getConversationMessagesPaginated(ctx: any) { export async function getConversationMessagesPaginated(ctx: any) {
const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0 const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50 const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50
const profile = requestedProfile(ctx)
const { getSessionDetailPaginated } = await import('../../db/hermes/session-store') const { getSessionDetailPaginated } = await import('../../db/hermes/session-store')
const result = getSessionDetailPaginated(ctx.params.id, offset, limit) const localResult = getSessionDetailPaginated(ctx.params.id, offset, limit)
const result = localResult && (!profile || localResult.session.profile === profile)
? localResult
: await getSessionDetailPaginatedFromDbWithProfile(ctx.params.id, profile || 'default', offset, limit)
if (!result) { if (!result) {
ctx.status = 404 ctx.status = 404
ctx.body = { error: 'Conversation not found' } ctx.body = { error: 'Conversation not found' }
return return
} }
if (denySessionAccess(ctx, result.session)) return const session = { ...result.session, profile: (result.session as any).profile || profile || 'default' }
if (denySessionAccess(ctx, session)) return
ctx.body = { ctx.body = {
session: { session: {
id: result.session.id, id: session.id,
source: result.session.source, profile: session.profile,
model: result.session.model, source: session.source,
title: result.session.title, model: session.model,
started_at: result.session.started_at, title: session.title,
ended_at: result.session.ended_at, started_at: session.started_at,
last_active: result.session.last_active, ended_at: session.ended_at,
message_count: result.session.message_count, last_active: session.last_active,
input_tokens: result.session.input_tokens, message_count: session.message_count,
output_tokens: result.session.output_tokens, input_tokens: session.input_tokens,
output_tokens: session.output_tokens,
}, },
messages: result.messages, messages: result.messages,
total: result.total, total: result.total,
@@ -62,6 +62,15 @@ export interface HermesSessionDetailRow extends HermesSessionRow {
thread_session_count: number thread_session_count: number
} }
export interface PaginatedHermesSessionDetailResult {
session: HermesSessionDetailRow
messages: HermesMessageRow[]
total: number
offset: number
limit: number
hasMore: boolean
}
interface HermesSessionInternalRow extends HermesSessionRow { interface HermesSessionInternalRow extends HermesSessionRow {
parent_session_id: string | null parent_session_id: string | null
} }
@@ -669,6 +678,52 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
} }
} }
export async function getSessionDetailPaginatedFromDbWithProfile(
sessionId: string,
profile: string,
offset = 0,
limit = 300,
): Promise<PaginatedHermesSessionDetailResult | null> {
const db = await openSessionDb(profile)
try {
const idx = loadAllSessions(db)
const requested = idx.byId.get(sessionId) || null
if (!requested) return null
const chain = collectSessionChainForMatchedSession(requested, idx)
if (!chain.length) return null
const ids = chain.map(session => session.id)
const placeholders = ids.map(() => '?').join(', ')
const orderSql = chainOrderSql(ids)
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM messages
WHERE session_id IN (${placeholders})
`).get(...ids) as { total: number } | undefined
const total = Number(totalRow?.total || 0)
const messageRows = db.prepare(`
SELECT * FROM messages
WHERE session_id IN (${placeholders})
ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END DESC, id DESC
LIMIT ? OFFSET ?
`).all(...ids, ...ids, limit, offset) as Record<string, unknown>[]
const messages = messageRows.map(mapMessageRow).reverse()
return {
session: aggregateSessionDetail(chain, messages, sessionId),
messages,
total,
offset,
limit,
hasMore: offset + messages.length < total,
}
} finally {
db.close()
}
}
export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> { export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
const { DatabaseSync } = await import('node:sqlite') const { DatabaseSync } = await import('node:sqlite')
const dbPath = join(getProfileDir(profile), 'state.db') const dbPath = join(getProfileDir(profile), 'state.db')