diff --git a/package-lock.json b/package-lock.json index 38525738..efb76740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "fflate": "0.8.3", "js-tiktoken": "^1.0.21", "js-yaml": "^4.1.0", + "marked": "^17.0.6", "openchemlib": "^9.24.0", "pdfjs-dist": "5.4.624", "tar": "^7.5.20", @@ -14852,7 +14853,8 @@ }, "node_modules/marked": { "version": "17.0.6", - "dev": true, + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", "license": "MIT", "bin": { "marked": "bin/marked.js" diff --git a/package.json b/package.json index 1d0f5288..49f6c4fb 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "fflate": "0.8.3", "js-tiktoken": "^1.0.21", "js-yaml": "^4.1.0", + "marked": "^17.0.6", "openchemlib": "^9.24.0", "pdfjs-dist": "5.4.624", "tar": "^7.5.20", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 49cbd26a..13b17336 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -86,6 +86,10 @@ import { loadSessionsAfterProjectRecovery, registerSessionPersistenceIpcHandlers } from './session-persistence/ipc' +import { + createConversationExportService, + registerConversationExportIpcHandler +} from './session-persistence/conversation-export' import { registerProjectFilesIpcHandlers } from './project-files/ipc' import { createManagedFileIndexRepository } from './project-files/repository' import { ProjectDeletionCoordinator } from './projects/deletion-coordinator' @@ -882,6 +886,11 @@ const registerIpcHandlers = async ({ notebookInputRegistry.readPreview(request) ) registerSessionPersistenceIpcHandlers(sessionPersistenceBackend, reviewRepository) + registerConversationExportIpcHandler( + createConversationExportService({ + loadSession: (projectId, sessionId) => sessionRepository.loadSession(projectId, sessionId) + }) + ) registerProjectFilesIpcHandlers( projectFilesRepository, sessionPersistenceCoordinator, diff --git a/src/main/session-persistence/conversation-export.test.ts b/src/main/session-persistence/conversation-export.test.ts new file mode 100644 index 00000000..369eb5ec --- /dev/null +++ b/src/main/session-persistence/conversation-export.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { PersistedChatSession } from '../../shared/session-persistence' + +const { fromWebContents, ipcHandlers } = vi.hoisted(() => ({ + fromWebContents: vi.fn(), + ipcHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn() }, + BrowserWindow: Object.assign(vi.fn(), { fromWebContents }), + dialog: { showSaveDialog: vi.fn() }, + ipcMain: { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => + ipcHandlers.set(channel, handler) + } +})) + +import { + createConversationExportService, + registerConversationExportIpcHandler +} from './conversation-export' + +const session: PersistedChatSession = { + id: 'session-1', + projectId: 'project-1', + title: 'Export test', + cwd: '/workspace', + status: 'idle', + messages: [ + { + id: 'message-1', + role: 'user', + content: 'Hello', + status: 'complete', + eventIds: [], + createdAt: 1, + updatedAt: 1 + } + ], + createdAt: 1, + updatedAt: 2 +} + +describe('conversation export service', () => { + const loadSession = vi.fn() + const showSaveDialog = vi.fn() + const writeExportFile = vi.fn() + const createTempDirectory = vi.fn() + const removeDirectory = vi.fn() + const executeJavaScript = vi.fn() + const printToPDF = vi.fn() + const loadFile = vi.fn() + const destroy = vi.fn() + const createPrintWindow = vi.fn(() => ({ + loadFile, + webContents: { executeJavaScript, printToPDF }, + destroy + })) + + beforeEach(() => { + vi.clearAllMocks() + loadSession.mockResolvedValue(session) + showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/downloads/export.md' }) + writeExportFile.mockResolvedValue(undefined) + createTempDirectory.mockResolvedValue('/tmp/open-science-conversation-export-test') + removeDirectory.mockResolvedValue(undefined) + loadFile.mockResolvedValue(undefined) + executeJavaScript.mockResolvedValue(true) + printToPDF.mockResolvedValue(Buffer.from('pdf')) + }) + + const createService = (): ReturnType => + createConversationExportService({ + loadSession, + showSaveDialog, + writeFile: writeExportFile, + createTempDirectory, + removeDirectory, + createPrintWindow, + getDownloadsPath: () => '/downloads', + getTempPath: () => '/tmp', + now: () => 3 + }) + + it('loads the durable session and saves normalized Markdown', async () => { + const result = await createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'markdown' + }) + + expect(loadSession).toHaveBeenCalledWith('project-1', 'session-1') + expect(showSaveDialog).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + defaultPath: '/downloads/Export test.md', + filters: [{ name: 'Markdown', extensions: ['md'] }] + }) + ) + expect(writeExportFile).toHaveBeenCalledWith( + '/downloads/export.md', + expect.stringContaining('# Export test') + ) + expect(createPrintWindow).not.toHaveBeenCalled() + expect(result).toEqual({ saved: true, filePath: '/downloads/export.md' }) + }) + + it('prints dedicated HTML to PDF and always destroys the hidden window', async () => { + showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/downloads/export.pdf' }) + + const result = await createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'pdf' + }) + + expect(createTempDirectory).toHaveBeenCalledWith('/tmp/open-science-conversation-export-') + expect(writeExportFile).toHaveBeenNthCalledWith( + 1, + '/tmp/open-science-conversation-export-test/conversation.html', + expect.stringContaining('') + ) + expect(loadFile).toHaveBeenCalledWith( + '/tmp/open-science-conversation-export-test/conversation.html' + ) + expect(executeJavaScript).toHaveBeenCalledOnce() + expect(printToPDF).toHaveBeenCalledWith( + expect.objectContaining({ pageSize: 'A4', printBackground: true }) + ) + expect(writeExportFile).toHaveBeenNthCalledWith(2, '/downloads/export.pdf', Buffer.from('pdf')) + expect(destroy).toHaveBeenCalledOnce() + expect(removeDirectory).toHaveBeenCalledWith('/tmp/open-science-conversation-export-test') + expect(result).toEqual({ saved: true, filePath: '/downloads/export.pdf' }) + }) + + it('destroys the print window when PDF generation fails', async () => { + showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/downloads/export.pdf' }) + printToPDF.mockRejectedValue(new Error('print failed')) + + await expect( + createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'pdf' + }) + ).rejects.toThrow('print failed') + expect(destroy).toHaveBeenCalledOnce() + expect(removeDirectory).toHaveBeenCalledWith('/tmp/open-science-conversation-export-test') + }) + + it('removes the temporary directory when writing the print document fails', async () => { + showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/downloads/export.pdf' }) + writeExportFile.mockRejectedValueOnce(new Error('temporary write failed')) + + await expect( + createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'pdf' + }) + ).rejects.toThrow('temporary write failed') + expect(createPrintWindow).not.toHaveBeenCalled() + expect(removeDirectory).toHaveBeenCalledWith('/tmp/open-science-conversation-export-test') + }) + + it('does no rendering or writing when Save As is canceled', async () => { + showSaveDialog.mockResolvedValue({ canceled: true }) + + await expect( + createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'pdf' + }) + ).resolves.toEqual({ saved: false }) + expect(createPrintWindow).not.toHaveBeenCalled() + expect(writeExportFile).not.toHaveBeenCalled() + }) + + it('rejects missing, empty, active and malformed conversations', async () => { + loadSession.mockResolvedValueOnce(undefined) + await expect( + createService().exportConversation({ + projectId: 'project-1', + sessionId: 'missing', + format: 'markdown' + }) + ).rejects.toThrow('Conversation not found.') + + loadSession.mockResolvedValueOnce({ ...session, messages: [] }) + await expect( + createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'markdown' + }) + ).rejects.toThrow('no messages') + + loadSession.mockResolvedValueOnce({ ...session, status: 'running' }) + await expect( + createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'pdf' + }) + ).rejects.toThrow('finish before exporting') + + loadSession.mockResolvedValueOnce({ ...session, status: 'waiting-permission' }) + await expect( + createService().exportConversation({ + projectId: 'project-1', + sessionId: 'session-1', + format: 'markdown' + }) + ).rejects.toThrow('finish before exporting') + + await expect( + createService().exportConversation({ + projectId: '', + sessionId: 'session-1', + format: 'markdown' + }) + ).rejects.toThrow('Invalid conversation export request.') + expect(showSaveDialog).not.toHaveBeenCalled() + }) +}) + +describe('conversation export IPC handler', () => { + beforeEach(() => { + ipcHandlers.clear() + fromWebContents.mockReset() + }) + + it('registers the export channel and forwards the request with its parent window', async () => { + const request = { + projectId: 'project-1', + sessionId: 'session-1', + format: 'pdf' as const + } + const sender = { id: 7 } + const parentWindow = { id: 8 } + const exportConversation = vi.fn().mockResolvedValue({ saved: false }) + fromWebContents.mockReturnValue(parentWindow) + + registerConversationExportIpcHandler({ exportConversation }) + + expect([...ipcHandlers.keys()]).toEqual(['sessions:export-conversation']) + await expect( + ipcHandlers.get('sessions:export-conversation')?.({ sender }, request) + ).resolves.toEqual({ saved: false }) + expect(fromWebContents).toHaveBeenCalledWith(sender) + expect(exportConversation).toHaveBeenCalledWith(request, parentWindow) + }) +}) diff --git a/src/main/session-persistence/conversation-export.ts b/src/main/session-persistence/conversation-export.ts new file mode 100644 index 00000000..dd4d0be1 --- /dev/null +++ b/src/main/session-persistence/conversation-export.ts @@ -0,0 +1,174 @@ +import { app, BrowserWindow, dialog, ipcMain, type SaveDialogOptions } from 'electron' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { + createConversationExportDocument, + renderConversationHtml, + renderConversationMarkdown, + sanitizeExportFilename, + type ExportConversationRequest, + type ExportConversationResult +} from '../../shared/conversation-export' +import type { PersistedChatSession } from '../../shared/session-persistence' + +type ConversationExportPrintWindow = { + loadFile(path: string): Promise + webContents: { + executeJavaScript(code: string): Promise + printToPDF(options: Electron.PrintToPDFOptions): Promise + } + destroy(): void +} + +type ConversationExportDependencies = { + loadSession(projectId: string, sessionId: string): Promise + showSaveDialog( + parentWindow: Electron.BrowserWindow | undefined, + options: SaveDialogOptions + ): Promise + writeFile(path: string, data: string | Buffer): Promise + createTempDirectory(prefix: string): Promise + removeDirectory(path: string): Promise + createPrintWindow(): ConversationExportPrintWindow + getDownloadsPath(): string + getTempPath(): string + now(): number +} + +type ConversationExportService = { + exportConversation( + request: ExportConversationRequest, + parentWindow?: Electron.BrowserWindow + ): Promise +} + +const assertExportConversationRequest = ( + request: ExportConversationRequest +): ExportConversationRequest => { + if ( + typeof request !== 'object' || + request === null || + typeof request.projectId !== 'string' || + request.projectId.length === 0 || + typeof request.sessionId !== 'string' || + request.sessionId.length === 0 || + (request.format !== 'markdown' && request.format !== 'pdf') + ) { + throw new Error('Invalid conversation export request.') + } + + return request +} + +const createDefaultPrintWindow = (): ConversationExportPrintWindow => + new BrowserWindow({ + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true + } + }) + +const defaultDependencies: Omit = { + showSaveDialog: (parentWindow, options) => + parentWindow ? dialog.showSaveDialog(parentWindow, options) : dialog.showSaveDialog(options), + writeFile, + createTempDirectory: mkdtemp, + removeDirectory: (path) => rm(path, { recursive: true, force: true }), + createPrintWindow: createDefaultPrintWindow, + getDownloadsPath: () => app.getPath('downloads'), + getTempPath: () => app.getPath('temp'), + now: Date.now +} + +const createConversationExportService = ( + dependencies: Pick & + Partial> +): ConversationExportService => { + const deps: ConversationExportDependencies = { ...defaultDependencies, ...dependencies } + + return { + exportConversation: async (rawRequest, parentWindow) => { + const request = assertExportConversationRequest(rawRequest) + const session = await deps.loadSession(request.projectId, request.sessionId) + if (!session) throw new Error('Conversation not found.') + if (session.status === 'running' || session.status === 'waiting-permission') { + throw new Error('Wait for the conversation to finish before exporting it.') + } + if (session.messages.length === 0) throw new Error('Conversation has no messages to export.') + + const document = createConversationExportDocument(session, deps.now()) + const extension = request.format === 'markdown' ? 'md' : 'pdf' + const defaultPath = join( + deps.getDownloadsPath(), + `${sanitizeExportFilename(document.title)}.${extension}` + ) + const dialogResult = await deps.showSaveDialog(parentWindow, { + title: 'Export conversation', + defaultPath, + filters: [ + request.format === 'markdown' + ? { name: 'Markdown', extensions: ['md'] } + : { name: 'PDF', extensions: ['pdf'] } + ] + }) + + if (dialogResult.canceled || !dialogResult.filePath) return { saved: false } + + if (request.format === 'markdown') { + await deps.writeFile(dialogResult.filePath, renderConversationMarkdown(document)) + return { saved: true, filePath: dialogResult.filePath } + } + + const tempDirectory = await deps.createTempDirectory( + join(deps.getTempPath(), 'open-science-conversation-export-') + ) + try { + const html = renderConversationHtml(document) + const htmlPath = join(tempDirectory, 'conversation.html') + await deps.writeFile(htmlPath, html) + + const printWindow = deps.createPrintWindow() + try { + await printWindow.loadFile(htmlPath) + await printWindow.webContents.executeJavaScript( + 'document.fonts ? document.fonts.ready.then(() => true) : true' + ) + const pdf = await printWindow.webContents.printToPDF({ + pageSize: 'A4', + printBackground: true, + margins: { + top: 0.2, + bottom: 0.2, + left: 0.2, + right: 0.2 + } + }) + await deps.writeFile(dialogResult.filePath, pdf) + return { saved: true, filePath: dialogResult.filePath } + } finally { + printWindow.destroy() + } + } finally { + await deps.removeDirectory(tempDirectory) + } + } + } +} + +const registerConversationExportIpcHandler = (service: ConversationExportService): void => { + ipcMain.handle( + 'sessions:export-conversation', + (event, request: ExportConversationRequest): Promise => + service.exportConversation(request, BrowserWindow.fromWebContents(event.sender) ?? undefined) + ) +} + +export { createConversationExportService, registerConversationExportIpcHandler } +export type { + ConversationExportDependencies, + ConversationExportPrintWindow, + ConversationExportService +} diff --git a/src/main/web-service/http-server.test.ts b/src/main/web-service/http-server.test.ts index 8494e068..0304b7e2 100644 --- a/src/main/web-service/http-server.test.ts +++ b/src/main/web-service/http-server.test.ts @@ -32,6 +32,7 @@ describe('startWebHttpServer', () => { const rpc = { channels: () => [ 'test:echo', + 'sessions:export-conversation', 'file:save-session-artifacts', 'uploads:stage-local-file', 'settings:list-agent-home-skills', @@ -107,6 +108,7 @@ describe('startWebHttpServer', () => { for (const channel of [ 'file:save-session-artifacts', 'window:close', + 'sessions:export-conversation', 'uploads:stage-local-file', 'settings:list-agent-home-skills', 'settings:import-agent-home-skills' diff --git a/src/main/web-service/http-server.ts b/src/main/web-service/http-server.ts index 2b98fd08..dc365d8c 100644 --- a/src/main/web-service/http-server.ts +++ b/src/main/web-service/http-server.ts @@ -23,6 +23,7 @@ const MAX_RPC_BODY_BYTES = 64 * 1024 * 1024 const WEB_UNAVAILABLE_RPC_CHANNELS = new Set([ 'file:save-blob', 'file:save-managed', + 'sessions:export-conversation', 'file:save-session-artifacts', 'uploads:stage-local-file', 'window:close', diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 8d1558ad..8d0f2cad 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -129,6 +129,10 @@ import type { SaveSessionOptions, SaveSessionManifestRequest } from '../shared/session-persistence' +import type { + ExportConversationRequest, + ExportConversationResult +} from '../shared/conversation-export' import type { ClaudeDetectResult, ClaudeInstallEvent, @@ -284,6 +288,7 @@ interface OpenScienceAPI { ): Promise deleteSession(request: DeleteSessionRequest): Promise saveManifest(request: SaveSessionManifestRequest): Promise + exportConversation(request: ExportConversationRequest): Promise onCreated(listener: AcpListener): RemoveListener onUpdated(listener: AcpListener): RemoveListener onDeleted(listener: AcpListener): RemoveListener diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 5aedc34e..4df8ecae 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -41,6 +41,7 @@ type PreloadApi = { saveSession: (session: unknown) => unknown deleteSession: (request: unknown) => unknown saveManifest: (request: unknown) => unknown + exportConversation: (request: unknown) => unknown } settings: { detectOpencode: () => unknown @@ -195,6 +196,11 @@ type ForwardingCase = { const sampleSession = { id: 's-1', projectId: 'p-1', title: 't' } const sampleDeleteSession = { projectId: 'p-1', sessionId: 's-1' } const sampleManifest = { projectId: 'p-1', sessionId: 's-1' } +const sampleConversationExport = { + projectId: 'p-1', + sessionId: 's-1', + format: 'markdown' +} const sampleInstall = { executablePath: '/usr/local/bin/opencode' } const sampleFramework = { framework: 'opencode' } const sampleResumeRequest = { sessionId: 's-1', cwd: '/workspace/project' } @@ -244,6 +250,12 @@ const cases: ForwardingCase[] = [ channel: 'sessions:save-manifest', args: [sampleManifest] }, + { + name: 'sessions.exportConversation โ†’ sessions:export-conversation', + invoke: (a) => a.sessions.exportConversation(sampleConversationExport), + channel: 'sessions:export-conversation', + args: [sampleConversationExport] + }, // agent-framework / opencode settings additions { name: 'settings.detectOpencode โ†’ settings:detect-opencode (no args)', diff --git a/src/preload/index.ts b/src/preload/index.ts index b5f740c0..92d939c5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -138,6 +138,10 @@ import type { SaveSessionOptions, SaveSessionManifestRequest } from '../shared/session-persistence' +import type { + ExportConversationRequest, + ExportConversationResult +} from '../shared/conversation-export' import type { ClaudeDetectResult, ClaudeInstallEvent, @@ -322,6 +326,7 @@ type OpenScienceAPI = { ) => Promise deleteSession: (request: DeleteSessionRequest) => Promise saveManifest: (request: SaveSessionManifestRequest) => Promise + exportConversation: (request: ExportConversationRequest) => Promise onCreated: (listener: AcpListener) => RemoveListener onUpdated: (listener: AcpListener) => RemoveListener onDeleted: (listener: AcpListener) => RemoveListener @@ -802,6 +807,12 @@ const api: OpenScienceAPI = { // Persists the last-open project/session pointer. saveManifest: (request) => ipcRenderer.invoke('sessions:save-manifest', request) as Promise, + // Exports the authoritative persisted active branch through a main-owned Save As flow. + exportConversation: (request) => + ipcRenderer.invoke( + 'sessions:export-conversation', + request + ) as Promise, onCreated: (listener) => onIpcMessage('session:created', listener), onUpdated: (listener) => onIpcMessage('session:updated', listener), onDeleted: (listener) => onIpcMessage('session:deleted', listener) diff --git a/src/renderer/src/pages/workspace/WorkspacePage.tsx b/src/renderer/src/pages/workspace/WorkspacePage.tsx index e9fb829e..97fac043 100644 --- a/src/renderer/src/pages/workspace/WorkspacePage.tsx +++ b/src/renderer/src/pages/workspace/WorkspacePage.tsx @@ -30,6 +30,7 @@ import { clearSuppressNextAutoReview } from '@/lib/acp/workspace-events' import type { UploadedAttachment } from '../../../../shared/uploads' +import type { ConversationExportFormat } from '../../../../shared/conversation-export' import { planComposerAttachmentIntake } from './composer-attachment-intake' import { stageComposerFile, type ComposerUploadTransfer } from './composer-upload-transfer' @@ -528,6 +529,7 @@ const WorkspacePage = ({ const attachmentTransferControllersRef = useRef>({}) const cancelledAttachmentTransfersRef = useRef(new Set()) const [attachmentError, setAttachmentError] = useState(null) + const [exportError, setExportError] = useState(null) const [notebookReferences, setNotebookReferences] = useState< Record >({}) @@ -706,7 +708,7 @@ const WorkspacePage = ({ : activeSession?.status === 'error' ? 'Resolve the current session error before compacting.' : 'Wait for the current agent activity to finish.' - const visibleActionError = attachmentError ?? (activeSession ? null : actionError) + const visibleActionError = attachmentError ?? exportError ?? (activeSession ? null : actionError) const compactActiveContext = useCallback((): void => { if (!activeSession || !canCompactContext) return @@ -1327,6 +1329,23 @@ const WorkspacePage = ({ setRenameDraft(session.title) } + // Main reloads the durable session and owns both normalization and the native Save As operation. + const exportConversation = (session: ChatSession, format: ConversationExportFormat): void => { + setExportError(null) + void window.api.sessions + .exportConversation({ + projectId: session.projectId, + sessionId: session.id, + format + }) + .catch((error: unknown) => setExportError(getErrorMessage(error))) + } + + const openSessionWithoutExportError = (sessionId: string): void => { + setExportError(null) + openSession(sessionId) + } + // Resets rename state from either cancel action or Radix open state changes. const closeRenameDialog = (): void => { setSessionToRename(undefined) @@ -1624,11 +1643,16 @@ const WorkspacePage = ({ onNewConversation={openNewConversation} isFilesOpen={activePreviewItemId === PROJECT_FILES_PREVIEW_ID} onOpenFiles={openFilesPreview} - onOpenSession={openSession} + onOpenSession={openSessionWithoutExportError} onRenameSession={openRenameDialog} canDownloadArtifacts={typeof window.api?.saveSessionArtifacts === 'function'} onDownloadArtifacts={setSessionToDownloadArtifacts} onViewNotebook={setSessionToViewNotebook} + onExportSession={ + typeof window.api.sessions?.exportConversation === 'function' + ? exportConversation + : undefined + } onTogglePin={(session) => { if (isSessionPersistenceReady) togglePinned(session.id) }} diff --git a/src/renderer/src/pages/workspace/WorkspaceSidebar.render.test.tsx b/src/renderer/src/pages/workspace/WorkspaceSidebar.render.test.tsx index e4c9d3e5..66c26d78 100644 --- a/src/renderer/src/pages/workspace/WorkspaceSidebar.render.test.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceSidebar.render.test.tsx @@ -19,6 +19,16 @@ const createSession = (overrides: Partial): ChatSession => ({ ...overrides }) +const createMessage = (): ChatSession['messages'][number] => ({ + id: 'message-1', + role: 'user', + content: 'Ready', + status: 'complete', + eventIds: [], + createdAt: 1, + updatedAt: 1 +}) + const renderSidebar = async (sessions: ChatSession[]): Promise => { const { WorkspaceSidebar } = await import('./WorkspaceSidebar') @@ -39,6 +49,7 @@ const renderSidebar = async (sessions: ChatSession[]): Promise => { canDownloadArtifacts onDownloadArtifacts={vi.fn()} onViewNotebook={vi.fn()} + onExportSession={vi.fn()} onTogglePin={vi.fn()} onDeleteSession={vi.fn()} onOpenSettings={vi.fn()} @@ -123,6 +134,7 @@ describe('WorkspaceSidebar accessible render', () => { const onRenameSession = vi.fn() const onDownloadArtifacts = vi.fn() const onDeleteSession = vi.fn() + const onExportSession = vi.fn() const tree = WorkspaceSidebar({ projectName: 'Example project', sessions, @@ -139,6 +151,7 @@ describe('WorkspaceSidebar accessible render', () => { canDownloadArtifacts: true, onDownloadArtifacts, onViewNotebook: vi.fn(), + onExportSession, onTogglePin: vi.fn(), onDeleteSession, onOpenSettings: vi.fn() @@ -155,6 +168,10 @@ describe('WorkspaceSidebar accessible render', () => { (element) => getTextContent(element).trim() === 'Download all artifacts' ) const deleteItems = elements.filter((element) => getTextContent(element).trim() === 'Delete') + const markdownItems = elements.filter( + (element) => getTextContent(element).trim() === 'Markdown' + ) + const pdfItems = elements.filter((element) => getTextContent(element).trim() === 'PDF') expect(notebookButton?.props.onClick).toBeTypeOf('function') ;(notebookButton?.props.onClick as () => void)() @@ -168,6 +185,14 @@ describe('WorkspaceSidebar accessible render', () => { ;(downloadItems[1]?.props.onSelect as () => void)() expect(onDownloadArtifacts).toHaveBeenCalledWith(sessions[1]) + expect(markdownItems[0]?.props.onSelect).toBeTypeOf('function') + ;(markdownItems[0]?.props.onSelect as () => void)() + expect(onExportSession).toHaveBeenCalledWith(sessions[0], 'markdown') + + expect(pdfItems[1]?.props.onSelect).toBeTypeOf('function') + ;(pdfItems[1]?.props.onSelect as () => void)() + expect(onExportSession).toHaveBeenCalledWith(sessions[1], 'pdf') + expect(deleteItems[0]?.props.onSelect).toBeTypeOf('function') ;(deleteItems[0]?.props.onSelect as () => void)() expect(onDeleteSession).toHaveBeenCalledWith(sessions[0]) @@ -192,6 +217,7 @@ describe('WorkspaceSidebar accessible render', () => { canDownloadArtifacts: true, onDownloadArtifacts: vi.fn(), onViewNotebook: vi.fn(), + onExportSession: vi.fn(), onTogglePin: vi.fn(), onDeleteSession: vi.fn(), onOpenSettings: vi.fn() @@ -235,6 +261,7 @@ describe('WorkspaceSidebar accessible render', () => { onTogglePin: vi.fn(), onDeleteSession: vi.fn(), onViewNotebook, + onExportSession: vi.fn(), onOpenSettings: vi.fn() }) const viewNotebookItems = collectElements(tree).filter( @@ -283,6 +310,7 @@ describe('WorkspaceSidebar accessible render', () => { canDownloadArtifacts: true, onDownloadArtifacts: vi.fn(), onViewNotebook: vi.fn(), + onExportSession: vi.fn(), onTogglePin, onDeleteSession: vi.fn(), onOpenSettings: vi.fn() @@ -335,6 +363,77 @@ describe('WorkspaceSidebar accessible render', () => { expect(deleteItem?.props.disabled).toBe(false) }) + it('disables conversation export for running, waiting-permission, or empty sessions', async () => { + const { WorkspaceSidebar } = await import('./WorkspaceSidebar') + const tree = WorkspaceSidebar({ + projectName: 'Example project', + sessions: [ + createSession({ id: 'running', status: 'running', messages: [createMessage()] }), + createSession({ + id: 'waiting', + status: 'waiting-permission', + messages: [createMessage()] + }), + createSession({ id: 'empty', status: 'idle', messages: [] }), + createSession({ id: 'ready', status: 'idle', messages: [createMessage()] }) + ], + activeSessionId: 'ready', + canCreateConversation: true, + canMutateConversations: true, + canDeleteConversations: true, + onGoHome: vi.fn(), + onNewConversation: vi.fn(), + isFilesOpen: false, + onOpenFiles: vi.fn(), + onOpenSession: vi.fn(), + onRenameSession: vi.fn(), + canDownloadArtifacts: false, + onDownloadArtifacts: vi.fn(), + onViewNotebook: vi.fn(), + onExportSession: vi.fn(), + onTogglePin: vi.fn(), + onDeleteSession: vi.fn(), + onOpenSettings: vi.fn() + }) + const exportTriggers = collectElements(tree).filter( + (element) => + getTextContent(element).trim() === 'Export conversation' && + typeof element.props.disabled === 'boolean' + ) + + expect(exportTriggers).toHaveLength(4) + expect(exportTriggers[0]?.props.disabled).toBe(true) + expect(exportTriggers[1]?.props.disabled).toBe(true) + expect(exportTriggers[2]?.props.disabled).toBe(true) + expect(exportTriggers[3]?.props.disabled).toBe(false) + }) + + it('hides conversation export when the runtime does not expose that capability', async () => { + const { WorkspaceSidebar } = await import('./WorkspaceSidebar') + const tree = WorkspaceSidebar({ + projectName: 'Example project', + sessions: [createSession({ status: 'idle', messages: [createMessage()] })], + activeSessionId: 'session-1', + canCreateConversation: true, + canMutateConversations: true, + canDeleteConversations: true, + onGoHome: vi.fn(), + onNewConversation: vi.fn(), + isFilesOpen: false, + onOpenFiles: vi.fn(), + onOpenSession: vi.fn(), + onRenameSession: vi.fn(), + canDownloadArtifacts: false, + onDownloadArtifacts: vi.fn(), + onViewNotebook: vi.fn(), + onTogglePin: vi.fn(), + onDeleteSession: vi.fn(), + onOpenSettings: vi.fn() + }) + + expect(getTextContent(tree)).not.toContain('Export conversation') + }) + it('hides artifact downloads when the runtime does not provide the desktop save capability', async () => { const { WorkspaceSidebar } = await import('./WorkspaceSidebar') const session = createSession({ id: 'session-a', title: 'Notebook review' }) diff --git a/src/renderer/src/pages/workspace/WorkspaceSidebar.tsx b/src/renderer/src/pages/workspace/WorkspaceSidebar.tsx index bb7320f7..40b8b70d 100644 --- a/src/renderer/src/pages/workspace/WorkspaceSidebar.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceSidebar.tsx @@ -2,6 +2,9 @@ import { Archive, BookOpen, ChevronLeft, + Download, + FileText, + FileType2, Files, MoreVertical, Pencil, @@ -16,6 +19,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -23,6 +29,7 @@ import { cn } from '@/lib/utils' import { GitHubStarBadge } from '@/components/GitHubStarBadge' import { UpdateCapsule } from '@/components/UpdateCapsule' import type { ChatSession, SessionStatus } from '@/stores/session-store' +import type { ConversationExportFormat } from '../../../../shared/conversation-export' type WorkspaceSidebarProps = { projectName: string @@ -40,6 +47,7 @@ type WorkspaceSidebarProps = { canDownloadArtifacts: boolean onDownloadArtifacts: (session: ChatSession) => void onViewNotebook: (session: ChatSession) => void + onExportSession?: (session: ChatSession, format: ConversationExportFormat) => void onTogglePin: (session: ChatSession) => void onDeleteSession: (session: ChatSession) => void onOpenSettings: () => void @@ -90,6 +98,7 @@ const WorkspaceSidebar = ({ canDownloadArtifacts, onDownloadArtifacts, onViewNotebook, + onExportSession, onTogglePin, onDeleteSession, onOpenSettings @@ -183,6 +192,10 @@ const WorkspaceSidebar = ({ {section.items.map((session) => { const isActive = session.id === activeSessionId + const isExportDisabled = + session.messages.length === 0 || + session.status === 'running' || + session.status === 'waiting-permission' return (
View notebook + {onExportSession ? ( + + + + + Export conversation + + + onExportSession(session, 'markdown')} + > + + + Markdown + + onExportSession(session, 'pdf')} + > + + + PDF + + + + ) : null} {/* Delete uses the project's danger token pair for light surfaces. */} ({ + id: 'session-internal-id', + projectId: 'project-internal-id', + title: 'Protein ', + cwd: '/secret/workspace', + status: 'idle', + agentBackendId: 'private-backend', + agentModel: 'private-model', + messages: [ + { + id: 'message-user-id', + role: 'user', + content: '# Question\n\nUse **Markdown**.', + status: 'complete', + eventIds: ['event-private'], + artifactIds: ['artifact-1'], + uploads: [ + { + id: 'upload-private', + sessionId: 'session-internal-id', + name: 'safe-name.csv', + originalName: 'measurements.csv', + mimeType: 'text/csv', + size: 42, + path: '/secret/measurements.csv' + } + ], + createdAt: 1_710_000_000_000, + updatedAt: 1_710_000_000_000 + }, + { + id: 'message-agent-id', + role: 'agent', + content: + '\nprivate chain of thought\n\n\nAnswer.\n\nmore private thought', + status: 'complete', + eventIds: [], + createdAt: 1_710_000_001_000, + updatedAt: 1_710_000_001_000 + } + ], + artifacts: [ + { + id: 'artifact-1', + kind: 'managed-file', + path: '/secret/generated/report.pdf', + name: 'report.pdf', + mimeType: 'application/pdf' + } + ], + createdAt: 1_710_000_000_000, + updatedAt: 1_710_000_002_000 +}) + +describe('conversation export projection', () => { + it('removes complete provider think blocks while preserving ordinary Markdown', () => { + expect( + sanitizeExportMarkdown( + '\nfirst\n\n\n# Result\n\n**kept**\nsecond' + ) + ).toBe('# Result\n\n**kept**') + expect(sanitizeExportMarkdown('before unfinished')).toBe('before unfinished') + }) + + it('projects only user-facing active messages and attachment names', () => { + const document = createConversationExportDocument(createSession(), 1_710_000_003_000) + + expect(document).toMatchObject({ + version: 1, + title: 'Protein ', + messages: [ + { + role: 'user', + markdown: '# Question\n\nUse **Markdown**.', + attachments: [ + { name: 'measurements.csv', mimeType: 'text/csv' }, + { name: 'report.pdf', mimeType: 'application/pdf' } + ], + images: [] + }, + { + role: 'assistant', + markdown: 'Answer.', + attachments: [], + images: [] + } + ] + }) + expect(JSON.stringify(document)).not.toContain('session-internal-id') + expect(JSON.stringify(document)).not.toContain('/secret/') + expect(JSON.stringify(document)).not.toContain('private-model') + expect(JSON.stringify(document)).not.toContain('private chain of thought') + }) + + it('preserves think tags supplied by the user while removing assistant reasoning blocks', () => { + const session = createSession() + session.messages[0].content = 'Explain `` and preserve this example.' + + const document = createConversationExportDocument(session, 1_710_000_003_000) + + expect(document.messages[0].markdown).toContain('this example') + expect(document.messages[1].markdown).toBe('Answer.') + }) + + it('reduces attachment display names to basenames before export', () => { + const session = createSession() + session.messages[0].uploads![0].originalName = '/Users/researcher/private/measurements.csv' + session.artifacts![0].name = String.raw`C:\Users\researcher\private\report.pdf` + + const document = createConversationExportDocument(session, 1_710_000_003_000) + + expect(document.messages[0].attachments).toEqual([ + { name: 'measurements.csv', mimeType: 'text/csv' }, + { name: 'report.pdf', mimeType: 'application/pdf' } + ]) + expect(JSON.stringify(document)).not.toContain('researcher') + }) + + it('replaces legacy truncated automatic titles with a complete prompt-derived title', () => { + const session = createSession() + const prompt = + 'Create a detailed reproducible research note comparing three open-science data management approaches. Include:\n- tables\n- code' + session.messages[0].content = prompt + session.title = `${prompt.replace(/\s+/g, ' ').slice(0, 48)}...` + + expect(createConversationExportDocument(session, 1_710_000_003_000).title).toBe( + 'Create a detailed reproducible research note comparing three open-science data management approaches.' + ) + }) + + it('marks prompt-derived title truncation and preserves complete Unicode characters', () => { + const session = createSession() + const prompt = `๐Ÿ˜€${'x'.repeat(300)}` + session.messages[0].content = prompt + session.title = `${prompt.replace(/\s+/g, ' ').slice(0, 48)}...` + + const title = createConversationExportDocument(session, 1_710_000_003_000).title + + expect(Array.from(title)).toHaveLength(243) + expect(title.startsWith('๐Ÿ˜€')).toBe(true) + expect(title.endsWith('...')).toBe(true) + expect(title).not.toContain('\uFFFD') + }) + + it('preserves manually assigned titles even when they end in an ellipsis', () => { + const session = createSession() + session.title = 'Lab notes...' + session.messages[0].content = + 'Create a detailed reproducible research note comparing three approaches.' + + expect(createConversationExportDocument(session, 1_710_000_003_000).title).toBe('Lab notes...') + }) + + it('renders a normalized Markdown document without internal metadata', () => { + const session = createSession() + session.title = 'Protein\n' + session.messages[0].uploads![0].originalName = 'measurements_[final].csv' + const markdown = renderConversationMarkdown( + createConversationExportDocument(session, 1_710_000_003_000) + ) + + expect(markdown).toContain('title: "Protein "') + expect(markdown).toContain('# Protein \\') + expect(markdown).toContain('## User') + expect(markdown).toContain('## Assistant') + expect(markdown).toContain('- measurements\\_\\[final\\].csv (text/csv)') + expect(markdown).not.toContain('/secret/') + expect(markdown).not.toContain('') + }) + + it('renders safe PDF HTML with Markdown formatting and no active embedded content', () => { + const session = createSession() + session.messages[0].content = + '\n\n[unsafe](javascript:alert(1))\n\n![remote](https://example.com/a.png)' + const html = renderConversationHtml(createConversationExportDocument(session, Date.now())) + + expect(html).toContain('<script>alert(1)</script>') + expect(html).not.toContain('href="javascript:') + expect(html).not.toContain(' { + const session = createSession() + session.messages[1].images = [ + { + id: 'image-1', + mimeType: 'image/png', + data: 'AQID', + byteLength: 3 + } + ] + const document = createConversationExportDocument(session, 1_710_000_003_000) + + expect(renderConversationHtml(document)).toContain('src="data:image/png;base64,AQID"') + expect(renderConversationMarkdown(document)).not.toContain('AQID') + expect(renderConversationMarkdown(document)).toContain('- Image 1 (image/png)') + }) + + it('creates portable filenames and only truncates near filesystem byte limits', () => { + expect(sanitizeExportFilename(' Results: alpha/beta? ')).toBe('Results alpha beta') + expect(sanitizeExportFilename('...')).toBe('conversation') + expect( + sanitizeExportFilename( + 'Create a detailed reproducible research note comparing three open-science data management approaches.' + ) + ).toBe( + 'Create a detailed reproducible research note comparing three open-science data management approaches' + ) + expect(sanitizeExportFilename('x'.repeat(300))).toBe(`${'x'.repeat(237)}...`) + expect(sanitizeExportFilename('็•Œ'.repeat(80))).toBe('็•Œ'.repeat(80)) + expect(sanitizeExportFilename(`${'็•Œ'.repeat(81)}tail`)).toBe(`${'็•Œ'.repeat(79)}...`) + }) +}) diff --git a/src/shared/conversation-export.ts b/src/shared/conversation-export.ts new file mode 100644 index 00000000..c0878d1a --- /dev/null +++ b/src/shared/conversation-export.ts @@ -0,0 +1,436 @@ +import { Marked, Renderer } from 'marked' + +import type { + PersistedArtifact, + PersistedChatMessage, + PersistedChatSession, + PersistedMessageImage +} from './session-persistence' + +export type ConversationExportFormat = 'markdown' | 'pdf' + +export type ExportConversationRequest = { + projectId: string + sessionId: string + format: ConversationExportFormat +} + +export type ExportConversationResult = { + saved: boolean + filePath?: string +} + +export type ConversationExportAttachment = { + name: string + mimeType?: string +} + +export type ConversationExportImage = Pick + +export type ConversationExportMessage = { + role: 'user' | 'assistant' + createdAt: number + markdown: string + attachments: ConversationExportAttachment[] + images: ConversationExportImage[] +} + +export type ConversationExportDocument = { + version: 1 + title: string + createdAt: number + updatedAt: number + exportedAt: number + messages: ConversationExportMessage[] +} + +const THINK_BLOCK_PATTERN = /]*>[\s\S]*?<\/think\s*>/gi +const UNSAFE_FILENAME_CHARACTERS = /[<>:"/\\|?*]+/g +const LEGACY_AUTO_TITLE_MAX_LENGTH = 48 +const EXPORT_TITLE_MAX_LENGTH = 240 +const EXPORT_FILENAME_MAX_BYTES = 240 + +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + +const normalizeInlineText = (value: string): string => value.replace(/\s+/g, ' ').trim() + +const truncateText = (value: string, maxLength: number): string => { + const characters = Array.from(value) + if (characters.length <= maxLength) return value + return `${characters.slice(0, maxLength).join('').trimEnd()}...` +} + +const createLegacyAutoTitle = (content: string): string => { + const normalizedTitle = normalizeInlineText(content) + return normalizedTitle.length > LEGACY_AUTO_TITLE_MAX_LENGTH + ? `${normalizedTitle.slice(0, LEGACY_AUTO_TITLE_MAX_LENGTH)}...` + : normalizedTitle +} + +const deriveTitleFromPrompt = (content: string): string => { + const firstLine = + content + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? '' + const normalizedLine = normalizeInlineText(firstLine).replace(/^#{1,6}\s+/, '') + const sentence = normalizedLine.match(/^(.+?[.!?ใ€‚๏ผ๏ผŸ])(?:\s|$)/)?.[1] ?? normalizedLine + + return truncateText(sentence, EXPORT_TITLE_MAX_LENGTH).trim() +} + +const resolveConversationExportTitle = (session: PersistedChatSession): string => { + const storedTitle = normalizeInlineText(session.title) + const firstUserMessage = session.messages.find((message) => message.role === 'user') + const prompt = firstUserMessage?.content ?? '' + + if ( + prompt && + storedTitle === createLegacyAutoTitle(prompt) && + normalizeInlineText(prompt).length > LEGACY_AUTO_TITLE_MAX_LENGTH + ) { + return deriveTitleFromPrompt(prompt) || storedTitle + } + + return storedTitle || deriveTitleFromPrompt(prompt) || 'Untitled conversation' +} + +const removeControlCharacters = (value: string): string => + Array.from(value, (character) => { + const code = character.charCodeAt(0) + return code < 32 || code === 127 ? ' ' : character + }).join('') + +const escapeMarkdownInline = (value: string): string => + normalizeInlineText(value).replace(/([\\`*_[\]<>])/g, '\\$1') + +const toIsoDate = (timestamp: number): string => new Date(timestamp).toISOString() + +const getPathBaseName = (path: string): string => path.split(/[\\/]/).filter(Boolean).at(-1) ?? '' + +const getAttachmentDisplayName = (...candidates: Array): string => { + for (const candidate of candidates) { + const name = getPathBaseName(normalizeInlineText(candidate ?? '')) + if (name) return name + } + return '' +} + +const getArtifactAttachment = ( + artifact: PersistedArtifact | undefined +): ConversationExportAttachment | undefined => { + if (!artifact) return undefined + const name = getAttachmentDisplayName(artifact.name, artifact.path) + if (!name) return undefined + + return { + name, + ...(artifact.mimeType ? { mimeType: artifact.mimeType } : {}) + } +} + +const getMessageAttachments = ( + message: PersistedChatMessage, + artifactsById: ReadonlyMap +): ConversationExportAttachment[] => { + const attachments: ConversationExportAttachment[] = [] + + for (const upload of message.uploads ?? []) { + const name = getAttachmentDisplayName(upload.originalName, upload.name) + if (!name) continue + attachments.push({ + name, + ...(upload.mimeType ? { mimeType: upload.mimeType } : {}) + }) + } + + for (const artifactId of message.artifactIds ?? []) { + const attachment = getArtifactAttachment(artifactsById.get(artifactId)) + if (attachment) attachments.push(attachment) + } + + for (const [index, image] of (message.images ?? []).entries()) { + attachments.push({ + name: `Image ${index + 1}`, + mimeType: image.mimeType + }) + } + + return attachments +} + +const normalizeExportMarkdown = (content: string): string => + content.replace(/\n{3,}/g, '\n\n').trim() + +// Removes provider-private reasoning blocks while preserving ordinary Markdown and HTML. +export const sanitizeExportMarkdown = (content: string): string => + normalizeExportMarkdown(content.replace(THINK_BLOCK_PATTERN, '')) + +export const sanitizeExportFilename = (title: string): string => { + const sanitized = removeControlCharacters(title) + .replace(UNSAFE_FILENAME_CHARACTERS, ' ') + .replace(/\s+/g, ' ') + .replace(/[. ]+$/g, '') + .trim() + + if (!sanitized) return 'conversation' + + const byteLength = (character: string): number => { + const codePoint = character.codePointAt(0) ?? 0 + if (codePoint <= 0x7f) return 1 + if (codePoint <= 0x7ff) return 2 + if (codePoint <= 0xffff) return 3 + return 4 + } + + const characters = Array.from(sanitized) + const totalBytes = characters.reduce((sum, character) => sum + byteLength(character), 0) + if (totalBytes <= EXPORT_FILENAME_MAX_BYTES) return sanitized + + const suffix = '...' + const contentByteLimit = EXPORT_FILENAME_MAX_BYTES - suffix.length + let truncated = '' + let truncatedBytes = 0 + + for (const character of characters) { + const characterBytes = byteLength(character) + if (truncatedBytes + characterBytes > contentByteLimit) break + truncated += character + truncatedBytes += characterBytes + } + + return `${truncated.trimEnd()}${suffix}` +} + +// Projects only the active-branch message view. Internal ids, runtime metadata, paths and tool +// payloads never enter this stable export contract. +export const createConversationExportDocument = ( + session: PersistedChatSession, + exportedAt: number +): ConversationExportDocument => { + const artifactsById = new Map( + (session.artifacts ?? []).map((artifact) => [artifact.id, artifact]) + ) + + return { + version: 1, + title: resolveConversationExportTitle(session), + createdAt: session.createdAt, + updatedAt: session.updatedAt, + exportedAt, + messages: session.messages.map((message) => ({ + role: message.role === 'agent' ? 'assistant' : 'user', + createdAt: message.createdAt, + markdown: + message.role === 'agent' + ? sanitizeExportMarkdown(message.content) + : normalizeExportMarkdown(message.content), + attachments: getMessageAttachments(message, artifactsById), + images: (message.images ?? []).map(({ mimeType, data }) => ({ mimeType, data })) + })) + } +} + +const renderAttachmentListMarkdown = (attachments: ConversationExportAttachment[]): string => { + if (attachments.length === 0) return '' + + return [ + '**Attachments**', + '', + ...attachments.map( + (attachment) => + `- ${escapeMarkdownInline(attachment.name)}${ + attachment.mimeType ? ` (${escapeMarkdownInline(attachment.mimeType)})` : '' + }` + ), + '' + ].join('\n') +} + +export const renderConversationMarkdown = (document: ConversationExportDocument): string => { + const frontmatter = [ + '---', + `title: ${JSON.stringify(document.title)}`, + `created: ${JSON.stringify(toIsoDate(document.createdAt))}`, + `updated: ${JSON.stringify(toIsoDate(document.updatedAt))}`, + `exported: ${JSON.stringify(toIsoDate(document.exportedAt))}`, + '---' + ].join('\n') + const messages = document.messages.map((message) => { + const role = message.role === 'assistant' ? 'Assistant' : 'User' + const content = message.markdown || '_No visible message content._' + const attachments = renderAttachmentListMarkdown(message.attachments) + + return [`## ${role}`, '', `_${toIsoDate(message.createdAt)}_`, '', content, '', attachments] + .filter((part, index, parts) => part !== '' || parts[index - 1] !== '') + .join('\n') + .trim() + }) + + return [ + frontmatter, + '', + `# ${escapeMarkdownInline(document.title)}`, + '', + ...messages.flatMap((message, index) => [...(index > 0 ? ['---', ''] : []), message, '']) + ] + .join('\n') + .trimEnd() + .concat('\n') +} + +const safeLinkHref = (href: string): string | undefined => { + try { + const url = new URL(href) + return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? href : undefined + } catch { + return undefined + } +} + +const markdownRenderer = new Renderer() +markdownRenderer.html = ({ text }) => escapeHtml(text) +markdownRenderer.image = ({ text }) => + `[Image: ${escapeHtml(text || 'image')}]` +markdownRenderer.link = function ({ href, title, tokens }) { + const text = this.parser.parseInline(tokens) + const safeHref = safeLinkHref(href) + if (!safeHref) return text + + return `${text}` +} + +const markdownParser = new Marked({ + async: false, + gfm: true, + renderer: markdownRenderer +}) + +const renderMarkdownHtml = (markdown: string): string => { + if (!markdown) return '

No visible message content.

' + const rendered = markdownParser.parse(markdown) + if (typeof rendered !== 'string') throw new Error('Markdown export renderer became asynchronous.') + return rendered +} + +const renderAttachmentListHtml = (attachments: ConversationExportAttachment[]): string => { + if (attachments.length === 0) return '' + + return `

Attachments

    ${attachments + .map( + (attachment) => + `
  • ${escapeHtml(attachment.name)}${ + attachment.mimeType ? ` (${escapeHtml(attachment.mimeType)})` : '' + }
  • ` + ) + .join('')}
` +} + +const renderMessageImagesHtml = (images: ConversationExportImage[]): string => { + if (images.length === 0) return '' + + return `
${images + .map( + (image, index) => + `
Conversation image ${index + 1}
` + ) + .join('')}
` +} + +export const renderConversationHtml = (document: ConversationExportDocument): string => { + const messages = document.messages + .map((message) => { + const role = message.role === 'assistant' ? 'Assistant' : 'User' + return `
+

${role}

+
${renderMarkdownHtml(message.markdown)}
+ ${renderMessageImagesHtml(message.images)} + ${renderAttachmentListHtml(message.attachments)} +
` + }) + .join('\n') + + return ` + + + + + ${escapeHtml(document.title)} + + + +
+

${escapeHtml(document.title)}

+ +
+
${messages}
+ +` +}