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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user