feat: add session export with full and compressed modes (#507)

Add export functionality that allows users to download session data
as JSON or plain text, with optional LLM-based context compression
for long conversations. Includes UI controls in chat panel, session
list, and history view, plus i18n strings for all 8 locales.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-07 13:49:57 +08:00
committed by GitHub
parent c0ad8c907b
commit 173307ef28
18 changed files with 554 additions and 14 deletions
+19 -1
View File
@@ -1,4 +1,4 @@
import { request } from '../client'
import { request, getApiKey, getBaseUrlValue } from '../client'
export interface SessionSummary {
id: string
@@ -147,6 +147,24 @@ export async function setSessionWorkspace(id: string, workspace: string | null):
}
}
export async function exportSession(id: string, mode: 'full' | 'compressed' = 'full', ext: 'json' | 'txt' = 'json'): Promise<void> {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const url = `${baseUrl}/api/hermes/sessions/${id}/export?mode=${mode}&ext=${ext}&token=${encodeURIComponent(token)}`
const res = await fetch(url)
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const contentDisposition = res.headers.get('Content-Disposition') || ''
let filename = `session_${id}.${ext}`
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\n]+)/i)
if (match) filename = decodeURIComponent(match[1].replace(/"/g, ''))
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
}
export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { renameSession, setSessionWorkspace, batchDeleteSessions } from "@/api/hermes/sessions";
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession } from "@/api/hermes/sessions";
import { useChatStore, type Session } from "@/stores/hermes/chat";
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import {
@@ -303,6 +303,28 @@ const contextMenuOptions = computed(() => [
},
{ label: t("chat.rename"), key: "rename" },
{ label: t("chat.setWorkspace"), key: "workspace" },
{
label: t("chat.export"),
key: "export",
children: [
{
label: t("chat.exportFull"),
key: "export-full",
children: [
{ label: "JSON", key: "export-full-json" },
{ label: "TXT", key: "export-full-txt" },
],
},
{
label: t("chat.exportCompressed"),
key: "export-compressed",
children: [
{ label: "JSON", key: "export-compressed-json" },
{ label: "TXT", key: "export-compressed-txt" },
],
},
],
},
{ label: t("chat.copySessionId"), key: "copy-id" },
]);
@@ -318,7 +340,15 @@ const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
function handleContextMenuSelect(key: string) {
function parseExportKey(key: string): { mode: 'full' | 'compressed'; ext: 'json' | 'txt' } | null {
if (key === 'export-full-json') return { mode: 'full', ext: 'json' }
if (key === 'export-full-txt') return { mode: 'full', ext: 'txt' }
if (key === 'export-compressed-json') return { mode: 'compressed', ext: 'json' }
if (key === 'export-compressed-txt') return { mode: 'compressed', ext: 'txt' }
return null
}
async function handleContextMenuSelect(key: string) {
showContextMenu.value = false;
if (!contextSessionId.value) return;
if (key === "pin") {
@@ -327,6 +357,17 @@ function handleContextMenuSelect(key: string) {
}
if (key === "copy-id") {
copySessionId(contextSessionId.value);
} else if (parseExportKey(key)) {
const { mode, ext } = parseExportKey(key)!;
const loadingMsg = mode === "compressed" ? message.loading(t("chat.exportCompressing"), { duration: 0 }) : null;
try {
await exportSession(contextSessionId.value, mode, ext);
loadingMsg?.destroy();
message.success(t("chat.exportSuccess"));
} catch {
loadingMsg?.destroy();
message.error(t("chat.exportFailed"));
}
} else if (key === "workspace") {
const session = chatStore.sessions.find(
(s) => s.id === contextSessionId.value,
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NPopconfirm, NCheckbox } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { Session } from '@/stores/hermes/chat'
@@ -22,6 +23,49 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
let longPressTimer: ReturnType<typeof setTimeout> | null = null
const longPressTriggered = ref(false)
function onTouchStart(e: TouchEvent) {
longPressTriggered.value = false
longPressTimer = setTimeout(() => {
longPressTriggered.value = true
const touch = e.touches[0]
const syntheticEvent = new MouseEvent('contextmenu', {
clientX: touch.clientX,
clientY: touch.clientY,
bubbles: true,
})
emit('contextmenu', syntheticEvent)
}, 500)
}
function onTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function onTouchMove() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function onClick() {
if (longPressTriggered.value) {
longPressTriggered.value = false
return
}
emit('select')
}
onUnmounted(() => {
if (longPressTimer) clearTimeout(longPressTimer)
})
</script>
<template>
@@ -29,8 +73,11 @@ const { t } = useI18n()
class="session-item"
:class="{ active, 'batch-mode': selectable }"
:aria-current="active ? 'page' : undefined"
@click="emit('select')"
@click="onClick"
@contextmenu="emit('contextmenu', $event)"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchmove="onTouchMove"
>
<div v-if="selectable" class="session-item-checkbox">
<NCheckbox :checked="selected" @click.stop="emit('toggle-select')" />
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Benutzer',
monitorRoleAssistant: 'Assistent',
copySessionId: 'Sitzungs-ID kopieren',
export: 'Exportieren',
exportFull: 'Vollständiger Export (JSON)',
exportCompressed: 'Komprimierter Export (TXT)',
exportCompressing: 'Komprimiere Kontext, bitte warten...',
exportSuccess: 'Sitzung exportiert',
exportFailed: 'Export fehlgeschlagen',
renamed: 'Umbenannt',
renameFailed: 'Umbenennung fehlgeschlagen',
renameSession: 'Sitzung umbenennen',
+6
View File
@@ -174,6 +174,12 @@ export default {
monitorRoleUser: 'User',
monitorRoleAssistant: 'Assistant',
copySessionId: 'Copy Session ID',
export: 'Export',
exportFull: 'Full Export (JSON)',
exportCompressed: 'Compressed Export (TXT)',
exportCompressing: 'Compressing context, please wait...',
exportSuccess: 'Session exported',
exportFailed: 'Export failed',
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameSession: 'Rename Session',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Usuario',
monitorRoleAssistant: 'Asistente',
copySessionId: 'Copiar ID de sesión',
export: 'Exportar',
exportFull: 'Exportación completa (JSON)',
exportCompressed: 'Exportación comprimida (TXT)',
exportCompressing: 'Comprimiendo contexto, espere...',
exportSuccess: 'Sesión exportada',
exportFailed: 'Error al exportar',
renamed: 'Renombrada',
renameFailed: 'Error al renombrar',
renameSession: 'Renombrar sesion',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Utilisateur',
monitorRoleAssistant: 'Assistant',
copySessionId: "Copier l'ID de session",
export: 'Exporter',
exportFull: 'Export complet (JSON)',
exportCompressed: 'Export compressé (TXT)',
exportCompressing: 'Compression du contexte, veuillez patienter...',
exportSuccess: 'Session exportée',
exportFailed: "Échec de l'export",
renamed: 'Renomme',
renameFailed: 'Echec du renommage',
renameSession: 'Renommer la session',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'ユーザー',
monitorRoleAssistant: 'アシスタント',
copySessionId: 'セッション ID をコピー',
export: 'エクスポート',
exportFull: 'フルエクスポート (JSON)',
exportCompressed: '圧縮エクスポート (TXT)',
exportCompressing: 'コンテキストを圧縮中、お待ちください...',
exportSuccess: 'セッションをエクスポートしました',
exportFailed: 'エクスポートに失敗しました',
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameSession: 'セッション名の変更',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: '사용자',
monitorRoleAssistant: '어시스턴트',
copySessionId: '세션 ID 복사',
export: '내보내기',
exportFull: '전체 내보내기 (JSON)',
exportCompressed: '압축 내보내기 (TXT)',
exportCompressing: '컨텍스트 압축 중, 잠시 기다려주세요...',
exportSuccess: '세션을 내보냈습니다',
exportFailed: '내보내기 실패',
renamed: '이름이 변경되었습니다',
renameFailed: '이름 변경 실패',
renameSession: '세션 이름 변경',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Usuário',
monitorRoleAssistant: 'Assistente',
copySessionId: 'Copiar ID da sessão',
export: 'Exportar',
exportFull: 'Exportação completa (JSON)',
exportCompressed: 'Exportação comprimida (TXT)',
exportCompressing: 'Comprimindo contexto, aguarde...',
exportSuccess: 'Sessão exportada',
exportFailed: 'Falha ao exportar',
renamed: 'Renomeado',
renameFailed: 'Falha ao renomear',
renameSession: 'Renomear sessao',
+6
View File
@@ -174,6 +174,12 @@ export default {
monitorRoleUser: '用户',
monitorRoleAssistant: '助手',
copySessionId: '复制会话 ID',
export: '导出',
exportFull: '全量导出 (JSON)',
exportCompressed: '压缩导出 (TXT)',
exportCompressing: '正在压缩上下文,请稍候...',
exportSuccess: '会话已导出',
exportFailed: '导出失败',
renamed: '已重命名',
renameFailed: '重命名失败',
renameSession: '重命名会话',
@@ -11,7 +11,7 @@ import { copyToClipboard } from '@/utils/clipboard'
import FolderPicker from '@/components/hermes/chat/FolderPicker.vue'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
import { renameSession, setSessionWorkspace, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
import { renameSession, setSessionWorkspace, fetchHermesSessions, fetchHermesSession, exportSession, type SessionSummary } from '@/api/hermes/sessions'
const chatStore = useChatStore()
const appStore = useAppStore()
@@ -281,6 +281,28 @@ const contextMenuOptions = computed(() => [
{ label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' },
{ label: t('chat.rename'), key: 'rename' },
{ label: t('chat.setWorkspace'), key: 'workspace' },
{
label: t('chat.export'),
key: 'export',
children: [
{
label: t('chat.exportFull'),
key: 'export-full',
children: [
{ label: 'JSON', key: 'export-full-json' },
{ label: 'TXT', key: 'export-full-txt' },
],
},
{
label: t('chat.exportCompressed'),
key: 'export-compressed',
children: [
{ label: 'JSON', key: 'export-compressed-json' },
{ label: 'TXT', key: 'export-compressed-txt' },
],
},
],
},
{ label: t('chat.copySessionId'), key: 'copy-id' },
])
@@ -296,7 +318,15 @@ const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
function handleContextMenuSelect(key: string) {
function parseExportKey(key: string): { mode: 'full' | 'compressed'; ext: 'json' | 'txt' } | null {
if (key === 'export-full-json') return { mode: 'full', ext: 'json' }
if (key === 'export-full-txt') return { mode: 'full', ext: 'txt' }
if (key === 'export-compressed-json') return { mode: 'compressed', ext: 'json' }
if (key === 'export-compressed-txt') return { mode: 'compressed', ext: 'txt' }
return null
}
async function handleContextMenuSelect(key: string) {
showContextMenu.value = false
if (!contextSessionId.value) return
if (key === 'pin') {
@@ -305,6 +335,17 @@ function handleContextMenuSelect(key: string) {
}
if (key === 'copy-id') {
copySessionId(contextSessionId.value)
} else if (parseExportKey(key)) {
const { mode, ext } = parseExportKey(key)!
const loadingMsg = mode === 'compressed' ? message.loading(t('chat.exportCompressing'), { duration: 0 }) : null
try {
await exportSession(contextSessionId.value, mode, ext)
loadingMsg?.destroy()
message.success(t('chat.exportSuccess'))
} catch {
loadingMsg?.destroy()
message.error(t('chat.exportFailed'))
}
} else if (key === 'workspace') {
const session = historySessions.value.find(s => s.id === contextSessionId.value)
workspaceSessionId.value = contextSessionId.value
@@ -10,6 +10,8 @@ import {
renameSession as localRenameSession,
useLocalSessionStore,
} from '../../db/hermes/session-store'
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { deleteUsage, getUsage, getUsageBatch, getLocalUsageStats } from '../../db/hermes/usage-store'
import type { LocalUsageStats, UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
import { getModelContextLength } from '../../services/hermes/model-context'
@@ -539,6 +541,90 @@ export async function listWorkspaceFolders(ctx: any) {
}
}
const exportCompressor = new ExportCompressor()
export async function exportSession(ctx: any) {
let session: any = null
if (useLocalSessionStore()) {
session = localGetSessionDetail(ctx.params.id)
} else {
try {
session = await getSessionDetailFromDb(ctx.params.id)
} catch (err) {
logger.warn(err, 'Hermes Session DB: export detail query failed, falling back to CLI')
}
if (!session) {
session = await hermesCli.getSession(ctx.params.id)
}
}
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
const mode = (ctx.query.mode as string) || 'full'
const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json')
const title = session.title || 'session'
const safeName = title.replace(/[^a-zA-Z0-9一-鿿_-]/g, '_').slice(0, 50)
const filename = `${safeName}_${ctx.params.id.slice(0, 8)}.${ext}`
if (mode === 'compressed') {
const result = await compressSession(session)
if (ext === 'json') {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'application/json')
ctx.body = JSON.stringify({ id: session.id, title: session.title, ...result.meta, messages: result.messages }, null, 2)
} else {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'text/plain; charset=utf-8')
ctx.body = serializeAsText(session.title, result.messages)
}
} else {
if (ext === 'txt') {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'text/plain; charset=utf-8')
ctx.body = serializeAsText(session.title, session.messages || [])
} else {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'application/json')
ctx.body = JSON.stringify(session, null, 2)
}
}
}
async function compressSession(session: any) {
const mgr = getGatewayManagerInstance()
const profile = getActiveProfileName()
const upstream = mgr ? mgr.getUpstream(profile).replace(/\/$/, '') : ''
const apiKey = mgr ? mgr.getApiKey(profile) || undefined : undefined
const messages = (session.messages || []).map((m: any) => ({
role: m.role,
content: m.content || '',
tool_calls: m.tool_calls,
tool_call_id: m.tool_call_id,
name: m.tool_name,
reasoning_content: m.reasoning,
}))
return exportCompressor.compress(messages, upstream, apiKey, session.id, profile)
}
function serializeAsText(title: string | null, messages: any[]): string {
const lines: string[] = [`# ${title || 'Untitled'}`, '']
for (const msg of messages) {
const role = msg.role || 'unknown'
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
const ts = msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : ''
lines.push(`[${role}]${ts ? ' ' + ts : ''}`)
lines.push(content || '')
lines.push('')
}
return lines.join('\n')
}
export async function getConversationMessagesPaginated(ctx: any) {
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
@@ -0,0 +1,149 @@
/**
* Export Compressor
*
* Compresses session context for export purposes.
* Reuses the LLM summarization logic from ChatContextCompressor
* but does NOT read or write compression snapshots.
* Always forces LLM compression regardless of token count.
* No tail reservation — all messages are compressed.
*/
import { logger } from '../../services/logger'
import {
type ChatMessage,
type CompressionConfig,
type CompressedResult,
DEFAULT_COMPRESSION_CONFIG,
countTokens,
serializeForSummary,
buildFullPrompt,
buildIncrementalPrompt,
buildConversationHistory,
callSummarizer,
} from './index'
import { getCompressionSnapshot } from '../../db/hermes/compression-snapshot'
export class ExportCompressor {
private config: CompressionConfig
constructor(opts?: { config?: Partial<CompressionConfig> }) {
this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts?.config }
}
async compress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
sessionId?: string,
profile?: string,
): Promise<CompressedResult> {
const total = messages.length
const meta: CompressedResult['meta'] = {
totalMessages: total,
compressed: false,
llmCompressed: false,
summaryTokenEstimate: 0,
verbatimCount: 0,
compressedStartIndex: -1,
}
// Read snapshot for incremental context, but never write
const snapshot = sessionId ? getCompressionSnapshot(sessionId) : null
if (snapshot) {
logger.info(
'[export-compressor] session=%s: incremental compress with existing snapshot at index %d',
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, meta, profile,
)
}
logger.info(
'[export-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, meta, profile)
}
private async incrementalCompress(
messages: ChatMessage[],
snapshot: { summary: string; lastMessageIndex: number },
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const newMessages = messages.slice(lastMessageIndex + 1)
let summary: string | null = null
try {
const contentToSummarize = serializeForSummary(newMessages)
const prompt = buildIncrementalPrompt(previousSummary, contentToSummarize, this.config.summaryBudget)
const history = buildConversationHistory(newMessages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
logger.info('[export-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] incremental-llm failed: %s — reusing previous summary', err.message)
summary = previousSummary
}
const summaryText = summary || previousSummary
return {
messages: [{ role: 'user', content: summaryText }],
meta: {
...meta,
compressed: true,
llmCompressed: true,
summaryTokenEstimate: countTokens(summaryText),
verbatimCount: 0,
},
}
}
private async fullCompress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
): Promise<CompressedResult> {
if (messages.length === 0) {
return { messages: [], meta }
}
let summary: string | null = null
try {
const contentToSummarize = serializeForSummary(messages)
const prompt = buildFullPrompt(contentToSummarize, this.config.summaryBudget)
const history = buildConversationHistory(messages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
logger.info('[export-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] full-llm failed: %s', err.message)
}
if (!summary) {
return { messages, meta }
}
return {
messages: [{ role: 'user', content: summary }],
meta: {
...meta,
compressed: true,
llmCompressed: true,
summaryTokenEstimate: countTokens(summary),
verbatimCount: 0,
},
}
}
}
@@ -172,7 +172,7 @@ Be specific with file paths, commands, line numbers, and results.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]`
function buildFullPrompt(contentToSummarize: string, summaryBudget: number): string {
export function buildFullPrompt(contentToSummarize: string, summaryBudget: number): string {
return `You are a summarization agent creating a context checkpoint.
Your output will be injected as reference material for a DIFFERENT
assistant that continues the conversation.
@@ -194,7 +194,7 @@ Target ~${summaryBudget} tokens. Be CONCRETE — include file paths, command out
Write only the summary body. Do not include any preamble or prefix.`
}
function buildIncrementalPrompt(previousSummary: string, contentToSummarize: string, summaryBudget: number): string {
export function buildIncrementalPrompt(previousSummary: string, contentToSummarize: string, summaryBudget: number): string {
return `You are a summarization agent creating a context checkpoint.
Your output will be injected as reference material for a DIFFERENT
assistant that continues the conversation.
@@ -229,7 +229,7 @@ Write only the summary body. Do not include any preamble or prefix.`
// ─── Pre-cleaning ───────────────────────────────────────
function serializeForSummary(messages: ChatMessage[]): string {
export function serializeForSummary(messages: ChatMessage[]): string {
const parts: string[] = []
function contentToString(content: string | ContentBlock[]): string {
@@ -272,13 +272,13 @@ function serializeForSummary(messages: ChatMessage[]): string {
* Convert messages to conversation history format for LLM API.
* Tool calls are converted to text format within assistant messages.
*/
function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string; content: string }> {
export function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string; content: string }> {
const result: Array<{ role: string; content: string }> = []
for (const msg of messages) {
if (msg.role === 'tool') {
// Convert tool result to text and append to previous assistant message
const toolText = `[Tool result: ${msg.name || 'unknown'}]\n${(msg.content || '').slice(0, 500)}${msg.content && msg.content.length > 500 ? '...' : ''}`
const toolText = `[Tool result: ${msg.name || 'unknown'}]\n${(msg.content || '').slice(0, 4000)}${msg.content && msg.content.length > 4000 ? '...' : ''}`
// Find the last assistant message and append to it
const lastAssistant = result.findLast(m => m.role === 'assistant')
if (lastAssistant) {
@@ -291,7 +291,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
// Include tool calls in assistant message
const toolsInfo = msg.tool_calls.map(tc => {
let args = tc.function.arguments
if (args.length > 1000) args = args.slice(0, 1000) + '...'
if (args.length > 4000) args = args.slice(0, 4000) + '...'
return `[Calling tool: ${tc.function.name} with arguments: ${args}]`
}).join('\n')
const content = msg.content ? `${msg.content}\n\n${toolsInfo}` : toolsInfo
@@ -313,6 +313,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
}
}
}
if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...'
result.push({ role: 'user', content: contentStr })
} else if (msg.role === 'assistant' || msg.role === 'system') {
let contentStr = ''
@@ -330,6 +331,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
}
}
}
if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...'
result.push({ role: msg.role, content: contentStr })
}
// Skip other roles
@@ -338,7 +340,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
return result
}
function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] {
export function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] {
if (messages.length <= keepRecentCount) return messages
const tail = messages.slice(-keepRecentCount)
@@ -365,7 +367,7 @@ function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number):
// ─── LLM Summarization ──────────────────────────────────
async function callSummarizer(
export async function callSummarizer(
upstream: string,
apiKey: string | undefined,
prompt: string,
@@ -15,6 +15,7 @@ sessionRoutes.get('/api/hermes/sessions/usage', ctrl.usageBatch)
sessionRoutes.get('/api/hermes/usage/stats', ctrl.usageStats)
sessionRoutes.get('/api/hermes/sessions/context-length', ctrl.contextLength)
sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
sessionRoutes.get('/api/hermes/sessions/:id/export', ctrl.exportSession)
sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle)
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
sessionRoutes.post('/api/hermes/sessions/batch-delete', ctrl.batchRemove)
+93
View File
@@ -11,6 +11,7 @@ const getGroupChatServerMock = vi.fn()
const getLocalUsageStatsMock = vi.fn()
const getActiveProfileNameMock = vi.fn()
const loggerWarnMock = vi.fn()
const getCompressionSnapshotMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
@@ -67,6 +68,25 @@ vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: getActiveProfileNameMock,
}))
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
getCompressionSnapshot: getCompressionSnapshotMock,
}))
vi.mock('../../packages/server/src/lib/context-compressor/export-compressor', () => ({
ExportCompressor: class {
async compress(messages: any[]) {
return {
messages,
meta: { totalMessages: messages.length, compressed: true, llmCompressed: true, summaryTokenEstimate: 100, verbatimCount: 0, compressedStartIndex: -1 },
}
}
},
}))
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
getGatewayManagerInstance: () => null,
}))
describe('session conversations controller', () => {
beforeEach(() => {
vi.resetModules()
@@ -83,6 +103,7 @@ describe('session conversations controller', () => {
getActiveProfileNameMock.mockReset()
getActiveProfileNameMock.mockReturnValue('default')
loggerWarnMock.mockReset()
getCompressionSnapshotMock.mockReset()
})
it('prefers the DB-backed conversations summary path', async () => {
@@ -198,4 +219,76 @@ describe('session conversations controller', () => {
cost: 0.02,
})
})
describe('exportSession', () => {
it('returns session as JSON download with correct headers (full mode)', async () => {
const sessionData = { id: 'abc-123', title: 'Test Session', messages: [{ id: 1, role: 'user', content: 'hello' }] }
getSessionDetailFromDbMock.mockResolvedValue(sessionData)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const setMock = vi.fn()
const ctx: any = { params: { id: 'abc-123' }, query: {}, set: setMock, body: null }
await mod.exportSession(ctx)
expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('abc-123')
expect(setMock).toHaveBeenCalledWith('Content-Disposition', expect.stringContaining('abc-123'))
expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/json')
expect(ctx.status).toBeUndefined()
expect(JSON.parse(ctx.body)).toMatchObject({ id: 'abc-123', title: 'Test Session' })
})
it('returns full TXT export', async () => {
const sessionData = {
id: 'txt-123',
title: 'Text Export',
messages: [
{ id: 1, role: 'user', content: 'hello', timestamp: 1700000000 },
{ id: 2, role: 'assistant', content: 'hi', timestamp: 1700000001 },
],
}
getSessionDetailFromDbMock.mockResolvedValue(sessionData)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const setMock = vi.fn()
const ctx: any = { params: { id: 'txt-123' }, query: { mode: 'full', ext: 'txt' }, set: setMock, body: null }
await mod.exportSession(ctx)
expect(setMock).toHaveBeenCalledWith('Content-Type', 'text/plain; charset=utf-8')
expect(ctx.body).toContain('# Text Export')
expect(ctx.body).toContain('[user]')
expect(ctx.body).toContain('hello')
expect(ctx.body).toContain('[assistant]')
expect(ctx.body).toContain('hi')
})
it('returns 404 when session not found', async () => {
getSessionDetailFromDbMock.mockResolvedValue(null)
getSessionMock.mockResolvedValue(null)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'not-found' }, query: {}, set: vi.fn(), body: null }
await mod.exportSession(ctx)
expect(ctx.status).toBe(404)
expect(ctx.body).toEqual({ error: 'Session not found' })
})
it('falls back to CLI when DB query fails', async () => {
const sessionData = { id: 'cli-123', title: 'CLI Session', messages: [] }
getSessionDetailFromDbMock.mockRejectedValue(new Error('db unavailable'))
getSessionMock.mockResolvedValue(sessionData)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const setMock = vi.fn()
const ctx: any = { params: { id: 'cli-123' }, query: {}, set: setMock, body: null }
await mod.exportSession(ctx)
expect(getSessionMock).toHaveBeenCalledWith('cli-123')
expect(JSON.parse(ctx.body)).toMatchObject({ id: 'cli-123' })
})
})
})
+14
View File
@@ -17,6 +17,7 @@ const usageSingleMock = vi.fn(async (ctx: any) => { ctx.body = { input_tokens: 0
const usageStatsMock = vi.fn(async (ctx: any) => { ctx.body = { total_input_tokens: 0, total_output_tokens: 0 } })
const contextLengthMock = vi.fn(async (ctx: any) => { ctx.body = { context_length: 200000 } })
const batchRemoveMock = vi.fn(async (ctx: any) => { ctx.body = { deleted: 1, failed: 0, errors: [] } })
const exportSessionMock = vi.fn(async (ctx: any) => { ctx.body = JSON.stringify({ id: ctx.params.id }) })
vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
listConversations: listConversationsMock,
@@ -36,6 +37,7 @@ vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
usageSingle: usageSingleMock,
usageStats: usageStatsMock,
contextLength: contextLengthMock,
exportSession: exportSessionMock,
}))
describe('session routes', () => {
@@ -66,6 +68,7 @@ describe('session routes', () => {
'/api/hermes/usage/stats',
'/api/hermes/sessions/context-length',
'/api/hermes/sessions/:id',
'/api/hermes/sessions/:id/export',
'/api/hermes/sessions/:id/usage',
'/api/hermes/sessions/:id/rename',
]))
@@ -110,4 +113,15 @@ describe('session routes', () => {
expect(getConversationMessagesMock).toHaveBeenCalledWith(detailCtx)
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
})
it('delegates session export to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/export')
const handler = layer.stack[0]
const ctx: any = { params: { id: 'session-abc' }, query: {}, body: null, set: vi.fn() }
await handler(ctx)
expect(exportSessionMock).toHaveBeenCalledWith(ctx)
})
})