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:
@@ -84,7 +84,7 @@ export default function App() {
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} />
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} />
|
||||
<ConnectionBanner status={status} />
|
||||
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-zinc-500"><div className="animate-pulse text-sm">Loading…</div></div>}>
|
||||
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onAbort={abort} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Menu, Sparkles, LogOut, Volume2, VolumeOff, Cpu, Bot } from 'lucide-react';
|
||||
import type { ConnectionStatus, Session } from '../types';
|
||||
import { useCallback } from 'react';
|
||||
import { Menu, Sparkles, LogOut, Volume2, VolumeOff, Cpu, Bot, Download } from 'lucide-react';
|
||||
import type { ConnectionStatus, Session, ChatMessage } from '../types';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { LanguageSelector } from './LanguageSelector';
|
||||
import { sessionDisplayName } from '../lib/sessionName';
|
||||
import { messagesToMarkdown, downloadFile } from '../lib/exportChat';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
@@ -12,12 +14,21 @@ interface Props {
|
||||
onLogout?: () => void;
|
||||
soundEnabled?: boolean;
|
||||
onToggleSound?: () => void;
|
||||
messages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound }: Props) {
|
||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages }: Props) {
|
||||
const t = useT();
|
||||
const sessionLabel = activeSessionData ? sessionDisplayName(activeSessionData) : (sessionKey.split(':').pop() || sessionKey);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (!messages || messages.length === 0) return;
|
||||
const md = messagesToMarkdown(messages, sessionLabel);
|
||||
const safeLabel = sessionLabel.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
downloadFile(md, `${safeLabel}_${date}.md`);
|
||||
}, [messages, sessionLabel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="h-14 border-b border-white/8 bg-[#232329]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0" role="banner">
|
||||
@@ -54,6 +65,16 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
||||
{soundEnabled ? <Volume2 size={16} /> : <VolumeOff size={16} />}
|
||||
</button>
|
||||
)}
|
||||
{messages && messages.length > 0 && (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
aria-label={t('header.export')}
|
||||
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
title={t('header.export')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)}
|
||||
<LanguageSelector />
|
||||
{status === 'connected' ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
|
||||
|
||||
79
src/lib/exportChat.ts
Normal file
79
src/lib/exportChat.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user