From a04f0c3595c4096796b249d15470187ab87a903c Mon Sep 17 00:00:00 2001 From: swear01 Date: Fri, 5 Jun 2026 02:34:32 +0000 Subject: [PATCH 1/4] test: reproduce issue #793 --- hub/src/web/routes/sessions.test.ts | 86 ++++++++++++++++ web/src/lib/sessionExport/markdown.test.ts | 114 +++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 web/src/lib/sessionExport/markdown.test.ts diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index d9887d00f..49c9cc6af 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -53,6 +53,7 @@ function createSession(overrides?: Partial): Session { function createApp(session: Session, opts?: { resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }> listSlashCommands?: SyncEngine['listSlashCommands'] + getSessionExport?: (sessionId: string, session: Session) => unknown }) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { @@ -88,6 +89,15 @@ function createApp(session: Session, opts?: { listCursorModelsForSession, listOpencodeModelsForSession, resumeSession, + getSessionExport: opts?.getSessionExport ?? (() => ({ + type: 'success', + payload: { + schemaVersion: 1, + exportedAt: 1_762_000_000_000, + session, + messages: [] + } + })), listSlashCommands: opts?.listSlashCommands ?? (async () => ({ success: true, commands: [] @@ -105,6 +115,82 @@ function createApp(session: Session, opts?: { } describe('sessions routes', () => { + it('exports an empty session conversation payload', async () => { + const session = createSession() + const { app } = createApp(session) + + const response = await app.request('/api/sessions/session-1/export') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + schemaVersion: 1, + exportedAt: 1_762_000_000_000, + session, + messages: [] + }) + }) + + it('exports visible messages in chronological order', async () => { + const session = createSession() + const messages = [ + { + id: 'msg-1', + seq: 1, + localId: null, + content: { role: 'user', content: 'Hello' }, + createdAt: 1000, + invokedAt: 1001, + scheduledAt: null + }, + { + id: 'msg-2', + seq: 2, + localId: null, + content: { role: 'agent', content: 'Hi there' }, + createdAt: 1002, + invokedAt: 1002, + scheduledAt: null + } + ] + const { app } = createApp(session, { + getSessionExport: () => ({ + type: 'success', + payload: { + schemaVersion: 1, + exportedAt: 1_762_000_000_000, + session, + messages + } + }) + }) + + const response = await app.request('/api/sessions/session-1/export') + + expect(response.status).toBe(200) + const body = await response.json() as { messages: Array<{ id: string }> } + expect(body.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2']) + }) + + it('returns 413 when the export exceeds the hard message cap', async () => { + const session = createSession() + const { app } = createApp(session, { + getSessionExport: () => ({ + type: 'too-large', + count: 20_001, + limit: 20_000 + }) + }) + + const response = await app.request('/api/sessions/session-1/export') + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: 'Session export too large', + count: 20_001, + limit: 20_000 + }) + }) + it('rejects collaboration mode changes for local Codex sessions', async () => { const session = createSession({ agentState: { diff --git a/web/src/lib/sessionExport/markdown.test.ts b/web/src/lib/sessionExport/markdown.test.ts new file mode 100644 index 000000000..161955aae --- /dev/null +++ b/web/src/lib/sessionExport/markdown.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { serializeSessionMarkdown } from './markdown' +import type { HapiSessionExport } from '@hapi/protocol/sessionExport' + +function makeExport(messages: HapiSessionExport['messages']): HapiSessionExport { + return { + schemaVersion: 1, + exportedAt: Date.UTC(2026, 5, 5, 12, 0, 0), + session: { + id: 'session-abcdef123456', + namespace: 'default', + seq: 1, + createdAt: Date.UTC(2026, 5, 5, 10, 0, 0), + updatedAt: Date.UTC(2026, 5, 5, 11, 0, 0), + active: false, + activeAt: Date.UTC(2026, 5, 5, 11, 0, 0), + metadata: { + path: '/tmp/project', + host: 'workstation', + name: 'Export Demo', + flavor: 'codex' + }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + thinking: false, + thinkingAt: Date.UTC(2026, 5, 5, 11, 0, 0), + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: 'default', + collaborationMode: 'default' + }, + messages + } +} + +describe('serializeSessionMarkdown', () => { + it('serializes user and assistant messages from one export payload', () => { + const markdown = serializeSessionMarkdown(makeExport([ + { + id: 'msg-1', + seq: 1, + localId: null, + createdAt: Date.UTC(2026, 5, 5, 10, 1, 0), + invokedAt: Date.UTC(2026, 5, 5, 10, 1, 1), + scheduledAt: null, + content: { role: 'user', content: 'Hello **HAPI**' } + }, + { + id: 'msg-2', + seq: 2, + localId: null, + createdAt: Date.UTC(2026, 5, 5, 10, 2, 0), + invokedAt: Date.UTC(2026, 5, 5, 10, 2, 0), + scheduledAt: null, + content: { role: 'agent', content: 'Hi there' } + } + ])) + + expect(markdown).toContain('title: "Export Demo"') + expect(markdown).toContain('# Export Demo') + expect(markdown).toContain('## User') + expect(markdown).toContain('Hello **HAPI**') + expect(markdown).toContain('## Assistant') + expect(markdown).toContain('Hi there') + }) + + it('skips messages that normalize to null and summarizes tool calls', () => { + const markdown = serializeSessionMarkdown(makeExport([ + { + id: 'skip-1', + seq: 1, + localId: null, + createdAt: 1, + invokedAt: 1, + scheduledAt: null, + content: { + role: 'agent', + content: { + type: 'output', + data: { type: 'system', subtype: 'init', uuid: 'sys-init' } + } + } + }, + { + id: 'tool-1', + seq: 2, + localId: null, + createdAt: 2, + invokedAt: 2, + scheduledAt: null, + content: { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: 'assistant-1', + message: { + content: [ + { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'bun test' } } + ] + } + } + } + } + } + ])) + + expect(markdown).not.toContain('sys-init') + expect(markdown).toContain('- Tool: Bash') + }) +}) From 3f79daa553118459e7c3ae48af5e2cbb8a929421 Mon Sep 17 00:00:00 2001 From: swear01 Date: Fri, 5 Jun 2026 02:45:26 +0000 Subject: [PATCH 2/4] fix: add session conversation export (closes #793) --- hub/src/sync/messageService.test.ts | 71 ++++++++- hub/src/sync/messageService.ts | 73 +++++++++- hub/src/sync/syncEngine.ts | 5 + hub/src/web/routes/sessions.ts | 23 +++ shared/package.json | 1 + shared/src/index.ts | 1 + shared/src/sessionExport.ts | 18 +++ web/src/api/client.ts | 8 ++ web/src/components/SessionActionMenu.tsx | 40 ++++++ web/src/components/SessionExportDialog.tsx | 160 +++++++++++++++++++++ web/src/components/SessionHeader.tsx | 10 ++ web/src/lib/locales/en.ts | 16 +++ web/src/lib/locales/zh-CN.ts | 16 +++ web/src/lib/sessionExport/download.ts | 77 ++++++++++ web/src/lib/sessionExport/markdown.ts | 129 +++++++++++++++++ web/src/types/api.ts | 2 + 16 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 shared/src/sessionExport.ts create mode 100644 web/src/components/SessionExportDialog.tsx create mode 100644 web/src/lib/sessionExport/download.ts create mode 100644 web/src/lib/sessionExport/markdown.ts diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 4e4f21d67..0a81a9101 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -14,7 +14,7 @@ import { join } from 'node:path' import { MessageService } from './messageService' import { Store } from '../store' import type { Server } from 'socket.io' -import type { SyncEvent } from '@hapi/protocol/types' +import type { Session, SyncEvent } from '@hapi/protocol/types' // --------------------------------------------------------------------------- // Test helpers @@ -28,6 +28,32 @@ function makeSession(store: Store, tag: string) { return store.sessions.getOrCreateSession(tag, { path: `/tmp/${tag}` }, null, 'default') } +function toProtocolSession(session: ReturnType): Session { + return { + id: session.id, + namespace: session.namespace, + seq: session.seq, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + active: session.active, + activeAt: session.activeAt ?? session.updatedAt, + metadata: { + path: `/tmp/${session.tag ?? session.id}`, + host: 'localhost' + }, + metadataVersion: session.metadataVersion, + agentState: null, + agentStateVersion: session.agentStateVersion, + thinking: false, + thinkingAt: session.updatedAt, + model: session.model, + modelReasoningEffort: session.modelReasoningEffort, + effort: session.effort, + permissionMode: 'default', + collaborationMode: 'default' + } +} + type AckCallback = (err: Error | null, responses: Array<{ removed: boolean }>) => void function makeIo(onEmit: (ack: AckCallback) => void, socketCount = 1): Server { @@ -94,6 +120,49 @@ describe('MessageService goal status filtering', () => { ]) }) + it('exports chronological visible messages and omits queued user rows', () => { + const store = makeStore() + const session = makeSession(store, 'session-export-visible') + + const first = store.messages.addMessage(session.id, { role: 'user', content: 'Hello' }) + const queued = store.messages.addMessage(session.id, { role: 'user', content: 'Queued' }, 'local-queued') + store.messages.addMessage(session.id, redundantGoalStatusContent('Goal active')) + const hiddenSystem = store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { type: 'system', subtype: 'init', uuid: 'sys-init' } + } + }) + const second = store.messages.addMessage(session.id, { role: 'agent', content: 'Hi' }) + + const service = new MessageService(store, makeIo(() => {}), makePublisher() as any) + const result = service.getSessionExport(session.id, toProtocolSession(session)) + + expect(result.type).toBe('success') + if (result.type !== 'success') throw new Error('Expected success export') + expect(result.payload.messages.map((message) => message.id)).toEqual([first.id, second.id]) + expect(result.payload.messages.some((message) => message.id === queued.id)).toBe(false) + expect(result.payload.messages.some((message) => message.id === hiddenSystem.id)).toBe(false) + }) + + it('returns too-large instead of truncating an export over the cap', () => { + const store = makeStore() + const session = makeSession(store, 'session-export-cap') + + store.messages.addMessage(session.id, { role: 'user', content: 'One' }) + store.messages.addMessage(session.id, { role: 'agent', content: 'Two' }) + + const service = new MessageService(store, makeIo(() => {}), makePublisher() as any) + const result = service.getSessionExport(session.id, toProtocolSession(session), 1) + + expect(result).toEqual({ + type: 'too-large', + count: 2, + limit: 1 + }) + }) + it('pages past hidden-only goal status rows', () => { const store = makeStore() const session = makeSession(store, 'goal-status-pagination') diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 33322807f..5ccd8c851 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -1,5 +1,15 @@ -import type { AttachmentMetadata, DecryptedMessage } from '@hapi/protocol/types' -import { isRedundantGoalStatusEventContent } from '@hapi/protocol/messages' +import { + HAPI_SESSION_EXPORT_SCHEMA_VERSION, + SESSION_EXPORT_MESSAGE_LIMIT, + type HapiSessionExportResult +} from '@hapi/protocol/sessionExport' +import type { AttachmentMetadata, DecryptedMessage, Session } from '@hapi/protocol/types' +import { + isClaudeChatVisibleMessage, + isRedundantGoalStatusEventContent, + unwrapRoleWrappedRecordEnvelope +} from '@hapi/protocol/messages' +import { isObject } from '@hapi/protocol' import type { Server } from 'socket.io' import { randomUUID } from 'node:crypto' import type { Store, CancelQueuedMessageResult } from '../store' @@ -27,6 +37,37 @@ function toVisibleDecryptedMessages(messages: StoredMessageForDelivery[]): Decry return messages.filter(isWebVisibleStoredMessage).map(toDecryptedMessage) } +function isQueuedUserMessage(message: StoredMessageForDelivery): boolean { + const record = unwrapRoleWrappedRecordEnvelope(message.content) + return record?.role === 'user' && message.invokedAt === null +} + +function isExportVisibleStoredMessage(message: StoredMessageForDelivery): boolean { + if (!isWebVisibleStoredMessage(message) || isQueuedUserMessage(message)) { + return false + } + + const record = unwrapRoleWrappedRecordEnvelope(message.content) + if (record?.role !== 'agent') { + return true + } + + if (!isObject(record.content) || record.content.type !== 'output') { + return true + } + + const data = isObject(record.content.data) ? record.content.data : null + if (!data) { + return true + } + + if (Boolean(data.isMeta) || Boolean(data.isCompactSummary)) { + return false + } + + return isClaudeChatVisibleMessage({ type: data.type, subtype: data.subtype }) +} + export class MessageService { /** One scheduled-matured SSE per localId per hub process (cleared on cancel/consume paths here). */ private readonly scheduledMatureNotifiedLocalIds = new Set() @@ -50,6 +91,34 @@ export class MessageService { return toVisibleDecryptedMessages(stored) } + getSessionExport( + sessionId: string, + session: Session, + limit: number = SESSION_EXPORT_MESSAGE_LIMIT + ): HapiSessionExportResult { + const messages = this.store.messages.getAllMessages(sessionId) + .filter(isExportVisibleStoredMessage) + .map(toDecryptedMessage) + + if (messages.length > limit) { + return { + type: 'too-large', + count: messages.length, + limit + } + } + + return { + type: 'success', + payload: { + schemaVersion: HAPI_SESSION_EXPORT_SCHEMA_VERSION, + exportedAt: Date.now(), + session, + messages + } + } + } + getMessagesPage( sessionId: string, options: { limit: number; before?: { at: number; seq: number } | null } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 814c7bdb4..52164fb38 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -13,6 +13,7 @@ import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionM import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' import type { Store, CancelQueuedMessageResult } from '../store' +import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport' import type { RpcRegistry } from '../socket/rpcRegistry' import type { SSEManager } from '../sse/sseManager' import { EventPublisher, type SyncEventListener } from './eventPublisher' @@ -243,6 +244,10 @@ export class SyncEngine { return this.messageService.getMessagesPage(sessionId, options) } + getSessionExport(sessionId: string, session: Session): HapiSessionExportResult { + return this.messageService.getSessionExport(sessionId, session) + } + getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] { return this.messageService.getDeliverableMessagesAfter(sessionId, options) } diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index a99a496c5..55271efd3 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -91,6 +91,29 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ sessions }) }) + app.get('/sessions/:id/export', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const result = engine.getSessionExport(sessionResult.sessionId, sessionResult.session) + if (result.type === 'too-large') { + return c.json({ + error: 'Session export too large', + count: result.count, + limit: result.limit + }, 413) + } + + return c.json(result.payload) + }) + app.get('/sessions/:id', (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/package.json b/shared/package.json index f19d0bc08..e05df72bb 100644 --- a/shared/package.json +++ b/shared/package.json @@ -14,6 +14,7 @@ "./modes": "./src/modes.ts", "./rpcMethods": "./src/rpcMethods.ts", "./schemas": "./src/schemas.ts", + "./sessionExport": "./src/sessionExport.ts", "./types": "./src/types.ts", "./voice": "./src/voice.ts" }, diff --git a/shared/src/index.ts b/shared/src/index.ts index 30ed86207..d95373fa4 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -9,6 +9,7 @@ export * from './resume' export * from './rpcMethods' export * from './socket' export * from './sessionSummary' +export * from './sessionExport' export * from './slashCommands' export * from './utils' export * from './version' diff --git a/shared/src/sessionExport.ts b/shared/src/sessionExport.ts new file mode 100644 index 000000000..34a93af1a --- /dev/null +++ b/shared/src/sessionExport.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { DecryptedMessageSchema, SessionSchema } from './schemas' + +export const HAPI_SESSION_EXPORT_SCHEMA_VERSION = 1 +export const SESSION_EXPORT_MESSAGE_LIMIT = 20_000 + +export const HapiSessionExportSchema = z.object({ + schemaVersion: z.literal(HAPI_SESSION_EXPORT_SCHEMA_VERSION), + exportedAt: z.number().int().nonnegative(), + session: SessionSchema, + messages: z.array(DecryptedMessageSchema) +}) + +export type HapiSessionExport = z.infer + +export type HapiSessionExportResult = + | { type: 'success'; payload: HapiSessionExport } + | { type: 'too-large'; count: number; limit: number } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index de953ad7e..7b0131e0c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -19,6 +19,7 @@ import type { SkillsResponse, SpawnResponse, VisibilityPayload, + HapiSessionExport, SessionResponse, SessionsResponse } from '@/types/api' @@ -241,6 +242,13 @@ export class ApiClient { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } + async getSessionExport(sessionId: string, options?: { signal?: AbortSignal }): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/export`, + { signal: options?.signal } + ) + } + async getMessages( sessionId: string, options: { diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 88d6ab97c..60068b9dc 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -14,6 +14,7 @@ type SessionActionMenuProps = { onClose: () => void sessionActive: boolean onRename: () => void + onExport?: () => void onArchive: () => void onDelete: () => void anchorPoint: { x: number; y: number } @@ -61,6 +62,27 @@ function ArchiveIcon(props: { className?: string }) { ) } +function DownloadIcon(props: { className?: string }) { + return ( + + + + + + ) +} + function TrashIcon(props: { className?: string }) { return ( { + onClose() + onExport?.() + } + const handleDelete = () => { onClose() onDelete() @@ -239,6 +267,18 @@ export function SessionActionMenu(props: SessionActionMenuProps) { {t('session.action.rename')} + {onExport ? ( + + ) : null} + {sessionActive ? (
+ + +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+ + + ) +} diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 67071d618..407b23d4c 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -4,6 +4,7 @@ import type { ApiClient } from '@/api/client' import { isTelegramApp } from '@/hooks/useTelegram' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' +import { SessionExportDialog } from '@/components/SessionExportDialog' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { getSessionModelLabel } from '@/lib/sessionModelLabel' @@ -104,6 +105,7 @@ export function SessionHeader(props: { const menuId = useId() const menuAnchorRef = useRef(null) const [renameOpen, setRenameOpen] = useState(false) + const [exportOpen, setExportOpen] = useState(false) const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) @@ -221,6 +223,7 @@ export function SessionHeader(props: { onClose={() => setMenuOpen(false)} sessionActive={session.active} onRename={() => setRenameOpen(true)} + onExport={() => setExportOpen(true)} onArchive={() => setArchiveOpen(true)} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} @@ -235,6 +238,13 @@ export function SessionHeader(props: { isPending={isPending} /> + setExportOpen(false)} + session={session} + api={api} + /> + setArchiveOpen(false)} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 70966908d..5cc5b1705 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -127,6 +127,7 @@ export default { // Session actions 'session.action.rename': 'Rename', + 'session.action.export': 'Export conversation', 'session.action.archive': 'Archive', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', @@ -151,6 +152,21 @@ export default { 'dialog.delete.confirming': 'Deleting…', 'dialog.error.default': 'Operation failed. Please try again.', + // Session export + 'session.export.title': 'Export conversation', + 'session.export.description': 'Choose a format, then download the full visible conversation.', + 'session.export.format.json': 'JSON', + 'session.export.format.json.description': 'Lossless payload with session metadata and messages.', + 'session.export.format.markdown': 'Markdown', + 'session.export.format.markdown.description': 'Readable view generated from the same export payload.', + 'session.export.download': 'Download', + 'session.export.downloading': 'Exporting…', + 'session.export.error.noApi': 'Not connected to server', + 'session.export.error.default': 'Failed to export conversation', + 'session.export.toast.success.title': 'Conversation exported', + 'session.export.toast.success.body': 'Downloaded {filename}', + 'session.export.toast.error.title': 'Export failed', + // Common buttons 'button.cancel': 'Cancel', 'button.save': 'Save', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 41dab7a4a..70c34182f 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -127,6 +127,7 @@ export default { // Session actions 'session.action.rename': '重命名', + 'session.action.export': '导出对话', 'session.action.archive': '归档', 'session.action.delete': '删除', 'session.action.copy': '复制', @@ -153,6 +154,21 @@ export default { 'dialog.delete.confirming': '删除中…', 'dialog.error.default': '操作失败,请重试。', + // Session export + 'session.export.title': '导出对话', + 'session.export.description': '选择格式,然后下载完整可见对话。', + 'session.export.format.json': 'JSON', + 'session.export.format.json.description': '保留会话元数据和消息的无损载荷。', + 'session.export.format.markdown': 'Markdown', + 'session.export.format.markdown.description': '从同一份导出载荷生成的可读视图。', + 'session.export.download': '下载', + 'session.export.downloading': '导出中…', + 'session.export.error.noApi': '未连接到服务器', + 'session.export.error.default': '导出对话失败', + 'session.export.toast.success.title': '对话已导出', + 'session.export.toast.success.body': '已下载 {filename}', + 'session.export.toast.error.title': '导出失败', + // Common buttons 'button.cancel': '取消', 'button.save': '保存', diff --git a/web/src/lib/sessionExport/download.ts b/web/src/lib/sessionExport/download.ts new file mode 100644 index 000000000..16bace68f --- /dev/null +++ b/web/src/lib/sessionExport/download.ts @@ -0,0 +1,77 @@ +import type { ApiClient } from '@/api/client' +import type { HapiSessionExport } from '@/types/api' +import { serializeSessionMarkdown } from './markdown' + +export type SessionExportFormat = 'json' | 'markdown' + +export const SESSION_EXPORT_FORMAT_STORAGE_KEY = 'hapi.sessionExportFormat' + +export function readSessionExportFormat(): SessionExportFormat { + if (typeof window === 'undefined') return 'json' + const value = window.localStorage.getItem(SESSION_EXPORT_FORMAT_STORAGE_KEY) + return value === 'markdown' ? 'markdown' : 'json' +} + +export function writeSessionExportFormat(format: SessionExportFormat): void { + if (typeof window === 'undefined') return + window.localStorage.setItem(SESSION_EXPORT_FORMAT_STORAGE_KEY, format) +} + +function getSessionTitle(payload: HapiSessionExport): string { + const metadata = payload.session.metadata + return metadata?.name + ?? metadata?.summary?.text + ?? metadata?.path?.split('/').filter(Boolean).at(-1) + ?? payload.session.id.slice(0, 8) +} + +function slugify(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff]+/gi, '-') + .replace(/^-+|-+$/g, '') + return slug || 'session' +} + +function formatDate(value: number): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 10) + return date.toISOString().slice(0, 10) +} + +export function buildSessionExportFilename(payload: HapiSessionExport, format: SessionExportFormat): string { + const extension = format === 'json' ? 'json' : 'md' + const slug = slugify(getSessionTitle(payload)).slice(0, 80) + const shortId = payload.session.id.slice(0, 8) + return `${slug}-${shortId}-${formatDate(payload.exportedAt)}.${extension}` +} + +function downloadTextFile(filename: string, text: string, mimeType: string): void { + const blob = new Blob([text], { type: mimeType }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.rel = 'noopener' + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 0) +} + +export async function downloadSessionExport( + api: ApiClient, + sessionId: string, + format: SessionExportFormat, + options?: { signal?: AbortSignal } +): Promise<{ filename: string; messageCount: number }> { + const payload = await api.getSessionExport(sessionId, { signal: options?.signal }) + const filename = buildSessionExportFilename(payload, format) + if (format === 'json') { + downloadTextFile(filename, `${JSON.stringify(payload, null, 2)}\n`, 'application/json;charset=utf-8') + } else { + downloadTextFile(filename, serializeSessionMarkdown(payload), 'text/markdown;charset=utf-8') + } + return { filename, messageCount: payload.messages.length } +} diff --git a/web/src/lib/sessionExport/markdown.ts b/web/src/lib/sessionExport/markdown.ts new file mode 100644 index 000000000..98c21cc20 --- /dev/null +++ b/web/src/lib/sessionExport/markdown.ts @@ -0,0 +1,129 @@ +import { safeStringify } from '@hapi/protocol' +import { normalizeDecryptedMessage } from '@/chat/normalize' +import { renderEventLabel } from '@/chat/presentation' +import type { NormalizedAgentContent, NormalizedMessage } from '@/chat/types' +import type { HapiSessionExport } from '@/types/api' + +function getSessionTitle(payload: HapiSessionExport): string { + const metadata = payload.session.metadata + if (metadata?.name) return metadata.name + if (metadata?.summary?.text) return metadata.summary.text + if (metadata?.path) { + const parts = metadata.path.split('/').filter(Boolean) + return parts.at(-1) ?? metadata.path + } + return payload.session.id.slice(0, 8) +} + +function escapeYamlString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +function formatTimestamp(value: number): string { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? String(value) : date.toISOString() +} + +function formatFrontMatter(payload: HapiSessionExport, title: string): string { + const metadata = payload.session.metadata + const lines = [ + '---', + `title: "${escapeYamlString(title)}"`, + `sessionId: "${escapeYamlString(payload.session.id)}"`, + `exportedAt: "${formatTimestamp(payload.exportedAt)}"`, + `messageCount: ${payload.messages.length}` + ] + if (metadata?.path) { + lines.push(`path: "${escapeYamlString(metadata.path)}"`) + } + if (metadata?.host) { + lines.push(`host: "${escapeYamlString(metadata.host)}"`) + } + if (metadata?.flavor) { + lines.push(`agent: "${escapeYamlString(metadata.flavor)}"`) + } + lines.push('---') + return lines.join('\n') +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value + return `${value.slice(0, maxLength - 1)}…` +} + +function formatToolInput(input: unknown): string { + if (input == null) return '' + const text = safeStringify(input).trim() + return text ? ` — ${truncate(text.replace(/\s+/g, ' '), 160)}` : '' +} + +function formatAgentContentBlock(block: NormalizedAgentContent): string | null { + switch (block.type) { + case 'text': + return block.text + case 'reasoning': + return `> Reasoning: ${block.text}` + case 'tool-call': + return `- Tool: ${block.name}${formatToolInput(block.input)}` + case 'tool-result': { + const label = block.is_error ? 'Tool error' : 'Tool result' + const content = safeStringify(block.content).trim() + return content ? `- ${label}: ${truncate(content.replace(/\s+/g, ' '), 240)}` : `- ${label}` + } + case 'generated-image': + return `- Generated image: ${block.fileName}` + case 'codex-review': + return `- Codex review: ${block.review.overallCorrectness ?? 'review'} (${block.review.findings.length} findings)` + case 'summary': + return `> Summary: ${block.summary}` + case 'sidechain': + return null + default: { + const _exhaustive: never = block + return safeStringify(_exhaustive) + } + } +} + +function formatNormalizedMessage(message: NormalizedMessage): string | null { + const timestamp = formatTimestamp(message.createdAt) + if (message.role === 'user') { + const attachments = message.content.attachments?.length + ? `\n\n${message.content.attachments.map((attachment) => `- Attachment: ${attachment.filename} (${attachment.mimeType}, ${attachment.size} bytes)`).join('\n')}` + : '' + return `## User\n\n_Time: ${timestamp}_\n\n${message.content.text}${attachments}` + } + + if (message.role === 'event') { + return `## Event\n\n_Time: ${timestamp}_\n\n${renderEventLabel(message.content)}` + } + + const parts = message.content + .map(formatAgentContentBlock) + .filter((part): part is string => Boolean(part && part.trim())) + + if (parts.length === 0) { + return null + } + + return `## Assistant\n\n_Time: ${timestamp}_\n\n${parts.join('\n\n')}` +} + +export function serializeSessionMarkdown(payload: HapiSessionExport): string { + const title = getSessionTitle(payload) + const sections: string[] = [ + formatFrontMatter(payload, title), + `# ${title}`, + `Session: \`${payload.session.id}\``, + `Exported: ${formatTimestamp(payload.exportedAt)}` + ] + + for (const message of payload.messages) { + const normalized = normalizeDecryptedMessage(message) + if (!normalized) continue + const section = formatNormalizedMessage(normalized) + if (section) sections.push(section) + } + + return `${sections.join('\n\n')}\n` +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 171cc6144..f1b7c5fc1 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -57,6 +57,8 @@ export type { WorktreeMetadata } from '@hapi/protocol/types' +export type { HapiSessionExport } from '@hapi/protocol/sessionExport' + export type SessionMetadataSummary = { path: string host: string From 331bb15d50f53bf21765a3c4ac50cfb9534533ef Mon Sep 17 00:00:00 2001 From: swear01 Date: Fri, 5 Jun 2026 08:14:37 +0000 Subject: [PATCH 3/4] fix(hub): sort session export by display time for invoked scheduled messages Export now uses COALESCE(invoked_at, created_at) ordering so JSON/Markdown exports match the visible chat chronology after scheduled messages are invoked. Co-authored-by: Cursor --- hub/src/sync/messageService.test.ts | 26 ++++++++++++++++++++++++++ hub/src/sync/messageService.ts | 5 +++++ 2 files changed, 31 insertions(+) diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 0a81a9101..f345e412e 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -146,6 +146,32 @@ describe('MessageService goal status filtering', () => { expect(result.payload.messages.some((message) => message.id === hiddenSystem.id)).toBe(false) }) + it('orders invoked scheduled messages by display time, not insertion seq', () => { + const store = makeStore() + const session = makeSession(store, 'session-export-scheduled-order') + + const scheduled = store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'Scheduled' } }, + 'local-scheduled', + Date.now() + 60_000 + ) + const normal = store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'Normal' } }, + 'local-normal' + ) + store.messages.markMessagesInvoked(session.id, ['local-normal'], 2_000) + store.messages.markMessagesInvoked(session.id, ['local-scheduled'], 3_000) + + const service = new MessageService(store, makeIo(() => {}), makePublisher() as any) + const result = service.getSessionExport(session.id, toProtocolSession(session)) + + expect(result.type).toBe('success') + if (result.type !== 'success') throw new Error('Expected success export') + expect(result.payload.messages.map((message) => message.id)).toEqual([normal.id, scheduled.id]) + }) + it('returns too-large instead of truncating an export over the cap', () => { const store = makeStore() const session = makeSession(store, 'session-export-cap') diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 5ccd8c851..a740f12d7 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -98,6 +98,11 @@ export class MessageService { ): HapiSessionExportResult { const messages = this.store.messages.getAllMessages(sessionId) .filter(isExportVisibleStoredMessage) + .sort((a, b) => { + const aAt = a.invokedAt ?? a.createdAt + const bAt = b.invokedAt ?? b.createdAt + return aAt !== bAt ? aAt - bAt : a.seq - b.seq + }) .map(toDecryptedMessage) if (messages.length > limit) { From 7e788db7c6549e270a940de9db3e5011e4c86791 Mon Sep 17 00:00:00 2001 From: swear01 Date: Fri, 5 Jun 2026 08:23:42 +0000 Subject: [PATCH 4/4] fix(web): escape newlines in session export YAML front matter Prevent session metadata containing newlines or quotes from breaking Markdown export front matter. Co-authored-by: Cursor --- web/src/lib/sessionExport/markdown.test.ts | 19 +++++++++++++++++++ web/src/lib/sessionExport/markdown.ts | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/lib/sessionExport/markdown.test.ts b/web/src/lib/sessionExport/markdown.test.ts index 161955aae..a86278244 100644 --- a/web/src/lib/sessionExport/markdown.test.ts +++ b/web/src/lib/sessionExport/markdown.test.ts @@ -66,6 +66,25 @@ describe('serializeSessionMarkdown', () => { expect(markdown).toContain('Hi there') }) + it('escapes newlines and quotes in YAML front matter metadata', () => { + const markdown = serializeSessionMarkdown({ + ...makeExport([]), + session: { + ...makeExport([]).session, + metadata: { + path: '/tmp/line\nbreak', + host: 'host"quote', + name: 'Title\nwith"newline' + } + } + }) + + expect(markdown).toContain('title: "Title\\nwith\\"newline"') + expect(markdown).toContain('path: "/tmp/line\\nbreak"') + expect(markdown).toContain('host: "host\\"quote"') + expect(markdown).toMatch(/^---\n[\s\S]*\n---\n/) + }) + it('skips messages that normalize to null and summarizes tool calls', () => { const markdown = serializeSessionMarkdown(makeExport([ { diff --git a/web/src/lib/sessionExport/markdown.ts b/web/src/lib/sessionExport/markdown.ts index 98c21cc20..3e2fef043 100644 --- a/web/src/lib/sessionExport/markdown.ts +++ b/web/src/lib/sessionExport/markdown.ts @@ -16,7 +16,11 @@ function getSessionTitle(payload: HapiSessionExport): string { } function escapeYamlString(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') } function formatTimestamp(value: number): string {