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:
@@ -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