diff --git a/docs/conversation-files.md b/docs/conversation-files.md index c3d1368..87cd0bc 100644 --- a/docs/conversation-files.md +++ b/docs/conversation-files.md @@ -6,4 +6,4 @@ Writes are atomic: Relay Chat writes a private temporary file beside the destina Conversation JSON can contain prompts, replies, and system instructions. Keep these files out of public repositories when their contents are sensitive. The default `conversations/` directory is ignored by Git. -Markdown exports omit internal identifiers while retaining message order, model labels, errors, and the system prompt. +Exports omit internal identifiers while retaining message order, model labels, errors, and the system prompt. `/export` chooses the format from the file extension: Markdown by default, plain text for `.txt`, and a standalone page with escaped content for `.html`. diff --git a/docs/slash-commands.md b/docs/slash-commands.md index c7d60e0..2999946 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -10,11 +10,13 @@ | `/model ` | Set or clear the model label | | `/save [path]` | Save versioned JSON | | `/load ` | Load versioned JSON | -| `/export [path]` | Write a Markdown transcript | +| `/export [path]` | Write a readable transcript | | `/retry` | Retry a failed or cancelled reply | | `/regenerate` | Replace the latest assistant reply | | `/theme ` | Change the terminal palette | | `/stats` | Show message and token estimates | | `/quit` | Close the interface | +`/export` writes Markdown by default. A path ending in `.txt` writes plain text, and a path ending in `.html` writes a standalone web page. + Paths containing spaces may be wrapped in single or double quotes. diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 414a202..faa48ed 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -13,7 +13,11 @@ export const commandCatalog: readonly CommandDescription[] = [ {name: 'model', usage: '/model ', description: 'Set or clear the model label.'}, {name: 'save', usage: '/save [path]', description: 'Save the conversation as JSON.'}, {name: 'load', usage: '/load ', description: 'Load a conversation JSON file.'}, - {name: 'export', usage: '/export [path]', description: 'Export the conversation as Markdown.'}, + { + name: 'export', + usage: '/export [path]', + description: 'Export as Markdown, or text or HTML by extension.', + }, {name: 'retry', usage: '/retry', description: 'Retry the last failed or cancelled reply.'}, {name: 'regenerate', usage: '/regenerate', description: 'Replace the latest assistant reply.'}, {name: 'theme', usage: '/theme ', description: 'Change the terminal palette.'}, diff --git a/src/persistence/export-file.ts b/src/persistence/export-file.ts index 9cd5237..ce69bda 100644 --- a/src/persistence/export-file.ts +++ b/src/persistence/export-file.ts @@ -1,12 +1,26 @@ import {mkdir, writeFile} from 'node:fs/promises'; -import {dirname} from 'node:path'; +import {dirname, extname} from 'node:path'; import type {Conversation} from '../model/index.js'; import {conversationToMarkdown} from './export-markdown.js'; +import {conversationToHtml, conversationToText} from './export-text.js'; + +export const exportFormatFor = (path: string): 'markdown' | 'text' | 'html' => { + const extension = extname(path).toLowerCase(); + if (extension === '.txt') return 'text'; + if (extension === '.html' || extension === '.htm') return 'html'; + return 'markdown'; +}; + +const renderers = { + markdown: conversationToMarkdown, + text: conversationToText, + html: conversationToHtml, +}; export const exportConversation = async ( path: string, conversation: Conversation, ): Promise => { await mkdir(dirname(path), {recursive: true}); - await writeFile(path, conversationToMarkdown(conversation), 'utf8'); + await writeFile(path, renderers[exportFormatFor(path)](conversation), 'utf8'); }; diff --git a/src/persistence/export-text.ts b/src/persistence/export-text.ts new file mode 100644 index 0000000..e61e3cf --- /dev/null +++ b/src/persistence/export-text.ts @@ -0,0 +1,67 @@ +import type {ChatMessage, Conversation} from '../model/index.js'; + +const speaker = (message: ChatMessage): string => { + if (message.role === 'user') return 'You'; + if (message.role === 'assistant') + return message.model === undefined ? 'Assistant' : message.model; + return 'System'; +}; + +const body = (message: ChatMessage): string => message.content || `(${message.status})`; + +export const conversationToText = (conversation: Conversation): string => { + const lines = [conversation.title, '='.repeat(Math.max(3, conversation.title.length)), '']; + if (conversation.systemPrompt !== undefined && conversation.systemPrompt !== '') { + lines.push('System prompt:', conversation.systemPrompt, ''); + } + for (const message of conversation.messages) { + lines.push(`${speaker(message)}:`, body(message), ''); + if (message.error !== undefined) lines.push(`Error: ${message.error}`, ''); + } + return `${lines.join('\n').trimEnd()}\n`; +}; + +const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +export const conversationToHtml = (conversation: Conversation): string => { + const title = escapeHtml(conversation.title); + const sections: string[] = []; + if (conversation.systemPrompt !== undefined && conversation.systemPrompt !== '') { + sections.push( + `

System prompt

${escapeHtml(conversation.systemPrompt)}
`, + ); + } + for (const message of conversation.messages) { + const error = + message.error === undefined ? '' : `

${escapeHtml(message.error)}

`; + sections.push( + `

${escapeHtml(speaker(message))}

${escapeHtml(body(message))}
${error}
`, + ); + } + return ` + + + + +${title} + + + +

${title}

+${sections.join('\n')} + + +`; +}; diff --git a/src/persistence/index.ts b/src/persistence/index.ts index 6ce0149..2d3998b 100644 --- a/src/persistence/index.ts +++ b/src/persistence/index.ts @@ -1,6 +1,7 @@ export {enableAutosave, type AutosaveOptions} from './autosave.js'; export {conversationToMarkdown} from './export-markdown.js'; -export {exportConversation} from './export-file.js'; +export {exportConversation, exportFormatFor} from './export-file.js'; +export {conversationToHtml, conversationToText} from './export-text.js'; export {loadConversation, saveConversation} from './file-store.js'; export {parseConversation, serializeConversation} from './json.js'; export {defaultConversationPath, defaultExportPath, safeFilename} from './paths.js'; diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index 9844c4a..b1e379d 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -4,7 +4,10 @@ import {join} from 'node:path'; import {afterEach, describe, expect, it} from 'vitest'; import {createConversation, createMessage} from '../src/model/index.js'; import { + conversationToHtml, conversationToMarkdown, + conversationToText, + exportConversation, enableAutosave, loadConversation, saveConversation, @@ -48,4 +51,34 @@ describe('conversation persistence', () => { }; expect(conversationToMarkdown(conversation)).toContain('# Test\n\n## You\n\nhello'); }); + + it('exports plain text and escaped HTML by file extension', async () => { + const directory = await mkdtemp(join(tmpdir(), 'relay-chat-')); + directories.push(directory); + const conversation = { + ...createConversation({id: 'c1', title: 'Tags '}), + messages: [ + createMessage('user', 'is 1 < 2?', {id: 'm1'}), + createMessage('assistant', 'yes', {id: 'm2', model: 'local'}), + ], + }; + expect(conversationToText(conversation)).toBe( + 'Tags \n========\n\nYou:\nis 1 < 2?\n\nlocal:\nyes\n', + ); + const html = conversationToHtml(conversation); + expect(html).toContain('Tags <b>'); + expect(html).toContain('
is 1 < 2?
'); + expect(html).not.toContain(''); + + await exportConversation(join(directory, 'chat.txt'), conversation); + await exportConversation(join(directory, 'chat.html'), conversation); + await exportConversation(join(directory, 'chat.md'), conversation); + expect(await readFile(join(directory, 'chat.txt'), 'utf8')).toBe( + conversationToText(conversation), + ); + expect(await readFile(join(directory, 'chat.html'), 'utf8')).toBe(html); + expect(await readFile(join(directory, 'chat.md'), 'utf8')).toBe( + conversationToMarkdown(conversation), + ); + }); });