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:
@@ -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')" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -151,6 +151,12 @@ export default {
|
||||
monitorRoleUser: 'ユーザー',
|
||||
monitorRoleAssistant: 'アシスタント',
|
||||
copySessionId: 'セッション ID をコピー',
|
||||
export: 'エクスポート',
|
||||
exportFull: 'フルエクスポート (JSON)',
|
||||
exportCompressed: '圧縮エクスポート (TXT)',
|
||||
exportCompressing: 'コンテキストを圧縮中、お待ちください...',
|
||||
exportSuccess: 'セッションをエクスポートしました',
|
||||
exportFailed: 'エクスポートに失敗しました',
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
renameSession: 'セッション名の変更',
|
||||
|
||||
@@ -151,6 +151,12 @@ export default {
|
||||
monitorRoleUser: '사용자',
|
||||
monitorRoleAssistant: '어시스턴트',
|
||||
copySessionId: '세션 ID 복사',
|
||||
export: '내보내기',
|
||||
exportFull: '전체 내보내기 (JSON)',
|
||||
exportCompressed: '압축 내보내기 (TXT)',
|
||||
exportCompressing: '컨텍스트 압축 중, 잠시 기다려주세요...',
|
||||
exportSuccess: '세션을 내보냈습니다',
|
||||
exportFailed: '내보내기 실패',
|
||||
renamed: '이름이 변경되었습니다',
|
||||
renameFailed: '이름 변경 실패',
|
||||
renameSession: '세션 이름 변경',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user