Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/conversation-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 3 additions & 1 deletion docs/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
| `/model <name\|off>` | Set or clear the model label |
| `/save [path]` | Save versioned JSON |
| `/load <path>` | 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 <relay\|mono>` | 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.
6 changes: 5 additions & 1 deletion src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export const commandCatalog: readonly CommandDescription[] = [
{name: 'model', usage: '/model <name|off>', description: 'Set or clear the model label.'},
{name: 'save', usage: '/save [path]', description: 'Save the conversation as JSON.'},
{name: 'load', usage: '/load <path>', 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 <relay|mono>', description: 'Change the terminal palette.'},
Expand Down
18 changes: 16 additions & 2 deletions src/persistence/export-file.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
await mkdir(dirname(path), {recursive: true});
await writeFile(path, conversationToMarkdown(conversation), 'utf8');
await writeFile(path, renderers[exportFormatFor(path)](conversation), 'utf8');
};
67 changes: 67 additions & 0 deletions src/persistence/export-text.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');

export const conversationToHtml = (conversation: Conversation): string => {
const title = escapeHtml(conversation.title);
const sections: string[] = [];
if (conversation.systemPrompt !== undefined && conversation.systemPrompt !== '') {
sections.push(
`<section class="system"><h2>System prompt</h2><pre>${escapeHtml(conversation.systemPrompt)}</pre></section>`,
);
}
for (const message of conversation.messages) {
const error =
message.error === undefined ? '' : `<p class="error">${escapeHtml(message.error)}</p>`;
sections.push(
`<section class="${message.role}"><h2>${escapeHtml(speaker(message))}</h2><pre>${escapeHtml(body(message))}</pre>${error}</section>`,
);
}
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title}</title>
<style>
body{font-family:system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;line-height:1.5}
pre{white-space:pre-wrap;font:inherit;margin:0}
section{border-left:3px solid #888;padding:0 0 0 1rem;margin:1.5rem 0}
.user{border-color:#3b82f6}.assistant{border-color:#10b981}.error{color:#b91c1c}
h2{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;margin:0 0 .25rem}
</style>
</head>
<body>
<h1>${title}</h1>
${sections.join('\n')}
</body>
</html>
`;
};
3 changes: 2 additions & 1 deletion src/persistence/index.ts
Original file line number Diff line number Diff line change
@@ -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';
33 changes: 33 additions & 0 deletions tests/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <b>'}),
messages: [
createMessage('user', 'is 1 < 2?', {id: 'm1'}),
createMessage('assistant', 'yes', {id: 'm2', model: 'local'}),
],
};
expect(conversationToText(conversation)).toBe(
'Tags <b>\n========\n\nYou:\nis 1 < 2?\n\nlocal:\nyes\n',
);
const html = conversationToHtml(conversation);
expect(html).toContain('<title>Tags &lt;b&gt;</title>');
expect(html).toContain('<pre>is 1 &lt; 2?</pre>');
expect(html).not.toContain('<b>');

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),
);
});
});
Loading