feat: export conversation as Markdown download
This commit is contained in:
@@ -3,12 +3,13 @@ import { ChatMessageComponent } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||
import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown, Sparkles, Bookmark } from 'lucide-react';
|
||||
import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown, Sparkles, Bookmark, Download } from 'lucide-react';
|
||||
import { MessageSearch } from './MessageSearch';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { getLocale, type TranslationKey } from '../lib/i18n';
|
||||
import { useToolCollapse } from '../hooks/useToolCollapse';
|
||||
import { useBookmarks } from '../hooks/useBookmarks';
|
||||
import { exportAsMarkdown, downloadFile } from '../lib/exportConversation';
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
@@ -208,6 +209,13 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
const hasToolCalls = useMemo(() => messages.some(m => m.blocks.some(b => b.type === 'tool_use' || b.type === 'tool_result')), [messages]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const label = sessionKey?.replace(/^agent:[^:]+:/, '') || 'conversation';
|
||||
const md = exportAsMarkdown(messages, label);
|
||||
const safeLabel = label.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40);
|
||||
downloadFile(md, `${safeLabel}-${new Date().toISOString().slice(0, 10)}.md`);
|
||||
}, [messages, sessionKey]);
|
||||
|
||||
// Message search
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -358,7 +366,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
</div>
|
||||
)}
|
||||
{/* Floating action buttons — sticky to bottom of scroll area */}
|
||||
{(hasToolCalls || showScrollBtn || newMessageCount > 0) && (
|
||||
{(hasToolCalls || messages.length > 0 || showScrollBtn || newMessageCount > 0) && (
|
||||
<div className="sticky bottom-3 z-10 flex justify-center pointer-events-none pb-1">
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{hasToolCalls && (
|
||||
@@ -382,6 +390,16 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
<span className="text-[10px] tabular-nums text-pc-text-muted">{sessionBookmarks.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
aria-label={t('chat.export')}
|
||||
title={t('chat.export')}
|
||||
className="flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-cyan-500/10"
|
||||
>
|
||||
<Download size={14} className="text-pc-accent-light" />
|
||||
</button>
|
||||
)}
|
||||
{(showScrollBtn || newMessageCount > 0) && (
|
||||
<button
|
||||
onClick={() => { scrollToBottom('smooth'); setNewMessageCount(0); }}
|
||||
|
||||
79
src/lib/exportConversation.ts
Normal file
79
src/lib/exportConversation.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
/**
|
||||
* Export conversation messages as a Markdown string.
|
||||
*/
|
||||
export function exportAsMarkdown(messages: ChatMessage[], sessionLabel?: string): string {
|
||||
const lines: string[] = [];
|
||||
const title = sessionLabel || 'Conversation';
|
||||
lines.push(`# ${title}`);
|
||||
lines.push(`> Exported from PinchChat on ${new Date().toLocaleString()}`);
|
||||
lines.push('');
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.isCompactionSeparator) {
|
||||
lines.push('---');
|
||||
lines.push('*Context compacted*');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleString() : '';
|
||||
const roleLabel = msg.role === 'user' ? '👤 User' : msg.role === 'assistant' ? '🤖 Assistant' : '⚙️ System';
|
||||
const header = ts ? `### ${roleLabel} — ${ts}` : `### ${roleLabel}`;
|
||||
lines.push(header);
|
||||
lines.push('');
|
||||
|
||||
// Render blocks
|
||||
for (const block of msg.blocks) {
|
||||
if (block.type === 'text') {
|
||||
lines.push(block.text);
|
||||
lines.push('');
|
||||
} else if (block.type === 'thinking') {
|
||||
lines.push('<details>');
|
||||
lines.push('<summary>💭 Thinking</summary>');
|
||||
lines.push('');
|
||||
lines.push(block.text);
|
||||
lines.push('</details>');
|
||||
lines.push('');
|
||||
} else if (block.type === 'tool_use') {
|
||||
const input = typeof block.input === 'string' ? block.input : JSON.stringify(block.input, null, 2);
|
||||
lines.push(`**🔧 Tool: \`${block.name}\`**`);
|
||||
lines.push('```json');
|
||||
lines.push(input);
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
} else if (block.type === 'tool_result') {
|
||||
lines.push('**📋 Result:**');
|
||||
lines.push('```');
|
||||
lines.push(block.content.slice(0, 2000) + (block.content.length > 2000 ? '\n...(truncated)' : ''));
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to content if no blocks
|
||||
if (msg.blocks.length === 0 && msg.content) {
|
||||
lines.push(msg.content);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download text as a file.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -147,6 +147,7 @@ const en = {
|
||||
'message.bookmark': 'Bookmark message',
|
||||
'message.removeBookmark': 'Remove bookmark',
|
||||
'chat.bookmarks': 'Bookmarks',
|
||||
'chat.export': 'Export conversation',
|
||||
'chat.contextCompacted': 'Context compacted — older messages cached locally',
|
||||
} as const;
|
||||
|
||||
@@ -274,6 +275,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'message.bookmark': 'Marquer le message',
|
||||
'message.removeBookmark': 'Retirer le marque-page',
|
||||
'chat.bookmarks': 'Marque-pages',
|
||||
'chat.export': 'Exporter la conversation',
|
||||
'chat.contextCompacted': 'Contexte compacté — anciens messages en cache local',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user