diff --git a/src/App.tsx b/src/App.tsx index a44f2cf..73b8d71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -84,7 +84,7 @@ export default function App() { onClose={() => setSidebarOpen(false)} />
-
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} /> +
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} />
Loading…
}> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 7534993..e71b6d7 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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 ( <>
@@ -54,6 +65,16 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, {soundEnabled ? : } )} + {messages && messages.length > 0 && ( + + )} {status === 'connected' ? (
diff --git a/src/lib/exportChat.ts b/src/lib/exportChat.ts new file mode 100644 index 0000000..3e747f6 --- /dev/null +++ b/src/lib/exportChat.ts @@ -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('
💭 Thinking', '', block.text, '', '
', ''); + 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(`
📋 ${label}`, ''); + lines.push('```', block.content.slice(0, 5000), '```', ''); + lines.push('
', ''); + } + 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); +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 3236841..4a29f03 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -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 = { @@ -172,6 +175,8 @@ const fr: Record = { 'error.reload': 'Recharger', 'shortcuts.navigationSection': 'Navigation', 'shortcuts.generalSection': 'Général', + + 'header.export': 'Exporter la conversation en Markdown', }; export type TranslationKey = keyof typeof en;