feat: export conversation as Markdown file

Add a download button in the header that exports the current session's
messages as a well-formatted Markdown file. Includes:
- User/assistant/system event labels with timestamps
- Thinking blocks as collapsible <details>
- Tool calls with JSON parameters
- Tool results as collapsible sections
- Image placeholders
- Session label and export date in header

i18n: EN + FR translations for the export tooltip.
This commit is contained in:
Nicolas Varrot
2026-02-12 18:38:54 +00:00
parent d6449773c3
commit 8d4b606482
4 changed files with 109 additions and 4 deletions

79
src/lib/exportChat.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ChatMessage } from '../types';
/**
* Convert a list of chat messages into a Markdown string suitable for export.
*/
export function messagesToMarkdown(messages: ChatMessage[], sessionLabel?: string): string {
const lines: string[] = [];
if (sessionLabel) {
lines.push(`# ${sessionLabel}`, '');
}
const exportDate = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
lines.push(`> Exported from PinchChat on ${exportDate}`, '');
for (const msg of messages) {
const time = new Date(msg.timestamp).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
});
const roleLabel = msg.role === 'user'
? (msg.isSystemEvent ? '⚙️ System Event' : '👤 User')
: '🤖 Assistant';
lines.push(`## ${roleLabel}${time}`, '');
if (msg.blocks && msg.blocks.length > 0) {
for (const block of msg.blocks) {
switch (block.type) {
case 'text':
lines.push(block.text, '');
break;
case 'thinking':
lines.push('<details><summary>💭 Thinking</summary>', '', block.text, '', '</details>', '');
break;
case 'tool_use':
lines.push(`**🔧 Tool: \`${block.name}\`**`, '');
if (block.input && Object.keys(block.input).length > 0) {
lines.push('```json', JSON.stringify(block.input, null, 2), '```', '');
}
break;
case 'tool_result':
if (block.content) {
const label = block.name ? `Result (${block.name})` : 'Result';
lines.push(`<details><summary>📋 ${label}</summary>`, '');
lines.push('```', block.content.slice(0, 5000), '```', '');
lines.push('</details>', '');
}
break;
case 'image':
lines.push('*[Image]*', '');
break;
}
}
} else if (msg.content) {
lines.push(msg.content, '');
}
lines.push('---', '');
}
return lines.join('\n');
}
/**
* Trigger a browser file download with the given content.
*/
export function downloadFile(content: string, filename: string, mimeType = 'text/markdown') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -94,6 +94,9 @@ const en = {
'error.reload': 'Reload page',
'shortcuts.navigationSection': 'Navigation',
'shortcuts.generalSection': 'General',
// Export
'header.export': 'Export conversation as Markdown',
} as const;
const fr: Record<keyof typeof en, string> = {
@@ -172,6 +175,8 @@ const fr: Record<keyof typeof en, string> = {
'error.reload': 'Recharger',
'shortcuts.navigationSection': 'Navigation',
'shortcuts.generalSection': 'Général',
'header.export': 'Exporter la conversation en Markdown',
};
export type TranslationKey = keyof typeof en;