diff --git a/renderer/src/features/chat/components/chat-interface.tsx b/renderer/src/features/chat/components/chat-interface.tsx
index 7e6b34ae0..c64b454ed 100644
--- a/renderer/src/features/chat/components/chat-interface.tsx
+++ b/renderer/src/features/chat/components/chat-interface.tsx
@@ -6,6 +6,10 @@ import {
MessageCircleMore,
ChevronDown,
Trash2,
+ Download,
+ FileText,
+ FileJson,
+ File,
} from 'lucide-react'
import { ChatMessage } from './chat-message'
import { DialogApiKeys } from './dialog-api-keys'
@@ -15,6 +19,14 @@ import { ChatInputPrompt } from './chat-input-prompt'
import { Separator } from '@/common/components/ui/separator'
import { useConfirm } from '@/common/hooks/use-confirm'
import { TitlePage } from '@/common/components/title-page'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/common/components/ui/dropdown-menu'
+import { exportChat, type ExportFormat } from '../lib/export-utils'
+import { toast } from 'sonner'
export function ChatInterface() {
const {
@@ -98,18 +110,73 @@ export function ChatInterface() {
clearMessages()
}, [clearMessages, confirm])
+ const handleExport = useCallback(
+ (format: ExportFormat) => {
+ try {
+ exportChat(messages, format)
+ const formatLabels = {
+ markdown: 'Markdown',
+ json: 'JSON',
+ text: 'Text',
+ }
+ toast(`Chat exported successfully as ${formatLabels[format]}`)
+ } catch (error) {
+ toast.error('Failed to export chat')
+ console.error('Export failed:', error)
+ }
+ },
+ [messages]
+ )
+
return (
<>
{hasMessages && (
-
+
+
+
+
+
+
+ handleExport('markdown')}
+ className="cursor-pointer"
+ >
+
+ Export as Markdown
+
+ handleExport('json')}
+ className="cursor-pointer"
+ >
+
+ Export as JSON
+
+ handleExport('text')}
+ className="cursor-pointer"
+ >
+
+ Export as Text
+
+
+
+
+
)}
diff --git a/renderer/src/features/chat/lib/export-utils.ts b/renderer/src/features/chat/lib/export-utils.ts
new file mode 100644
index 000000000..eb98c0f37
--- /dev/null
+++ b/renderer/src/features/chat/lib/export-utils.ts
@@ -0,0 +1,259 @@
+import type { ChatUIMessage } from '../types'
+
+export type ExportFormat = 'markdown' | 'json' | 'text'
+
+/**
+ * Formats tool output for text display
+ */
+function formatToolOutput(output: unknown): string {
+ if (!output) return 'No output'
+
+ // Handle MCP server response format
+ if (
+ typeof output === 'object' &&
+ output !== null &&
+ 'content' in output &&
+ Array.isArray((output as Record).content)
+ ) {
+ const content = (output as Record).content as Array<
+ Record
+ >
+ return content
+ .map((item) => {
+ if (item.type === 'text') {
+ return String(item.text || '')
+ }
+ return `[${String(item.type)}]`
+ })
+ .join('\n')
+ }
+
+ // Fallback to JSON
+ return JSON.stringify(output, null, 2)
+}
+
+/**
+ * Converts a chat message to plain text format
+ */
+function messageToText(message: ChatUIMessage): string {
+ const role = message.role === 'user' ? 'User' : 'Assistant'
+ const timestamp = message.metadata?.createdAt
+ ? new Date(message.metadata.createdAt).toLocaleString()
+ : new Date().toLocaleString()
+
+ let text = `[${timestamp}] ${role}:\n`
+
+ // Process parts in order to maintain conversation flow
+ for (const part of message.parts) {
+ if (part.type === 'text' && 'text' in part) {
+ text += `${part.text || ''}\n`
+ } else if (part.type === 'dynamic-tool' || part.type.startsWith('tool-')) {
+ // Extract tool call info
+ const toolName =
+ part.type === 'dynamic-tool'
+ ? 'toolName' in part
+ ? String(part.toolName)
+ : 'Unknown Tool'
+ : part.type.replace('tool-', '')
+
+ const input = 'input' in part ? part.input : undefined
+ const output = 'output' in part ? part.output : undefined
+
+ text += `\n[Tool Call: ${toolName}]\n`
+ if (input) {
+ text += `Input: ${JSON.stringify(input)}\n`
+ }
+ if (output) {
+ text += `Output:\n${formatToolOutput(output)}\n`
+ }
+ text += '\n'
+ }
+ }
+
+ return text + '\n'
+}
+
+/**
+ * Converts a chat message to markdown format
+ */
+function messageToMarkdown(message: ChatUIMessage): string {
+ const role = message.role === 'user' ? '👤 User' : '🤖 Assistant'
+ const timestamp = message.metadata?.createdAt
+ ? new Date(message.metadata.createdAt).toLocaleString()
+ : new Date().toLocaleString()
+
+ let markdown = `### ${role}\n\n`
+ markdown += `*${timestamp}*\n\n`
+
+ if (message.metadata?.model) {
+ markdown += `**Model:** ${message.metadata.model}\n\n`
+ }
+
+ // Process parts in order to maintain conversation flow
+ for (const part of message.parts) {
+ if (part.type === 'text' && 'text' in part) {
+ markdown += `${part.text || ''}\n\n`
+ } else if (part.type === 'dynamic-tool' || part.type.startsWith('tool-')) {
+ // Extract tool call info
+ const toolName =
+ part.type === 'dynamic-tool'
+ ? 'toolName' in part
+ ? String(part.toolName)
+ : 'Unknown Tool'
+ : part.type.replace('tool-', '')
+
+ const input = 'input' in part ? part.input : undefined
+ const output = 'output' in part ? part.output : undefined
+ const state = 'state' in part ? String(part.state) : undefined
+
+ markdown += `#### 🔧 Tool Call: ${toolName}\n\n`
+ markdown += `\nDetails
\n\n`
+
+ if (input) {
+ markdown += `**Input Parameters:**\n\`\`\`json\n${JSON.stringify(input, null, 2)}\n\`\`\`\n\n`
+ }
+
+ if (output) {
+ markdown += `**Output:**\n\`\`\`\n${formatToolOutput(output)}\n\`\`\`\n\n`
+ }
+
+ if (state) {
+ markdown += `**Status:** ${state}\n\n`
+ }
+
+ markdown += ` \n\n`
+ }
+ }
+
+ // Add token usage for assistant messages
+ if (message.role === 'assistant' && message.metadata?.totalUsage) {
+ const usage = message.metadata.totalUsage
+ markdown += `\nToken Usage
\n\n`
+ if (usage.inputTokens) markdown += `- Input: ${usage.inputTokens}\n`
+ if (usage.outputTokens) markdown += `- Output: ${usage.outputTokens}\n`
+ if (usage.totalTokens) markdown += `- Total: ${usage.totalTokens}\n`
+ if (message.metadata.responseTime) {
+ markdown += `- Response Time: ${(message.metadata.responseTime / 1000).toFixed(2)}s\n`
+ }
+ markdown += ` \n\n`
+ }
+
+ markdown += `---\n\n`
+ return markdown
+}
+
+/**
+ * Exports chat messages to the specified format
+ */
+export function exportChat(
+ messages: ChatUIMessage[],
+ format: ExportFormat
+): void {
+ if (messages.length === 0) {
+ throw new Error('No messages to export')
+ }
+
+ let content: string
+ let filename: string
+ let mimeType: string
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
+
+ switch (format) {
+ case 'markdown':
+ content = `# Chat Export\n\n`
+ content += `*Exported on ${new Date().toLocaleString()}*\n\n`
+ content += `---\n\n`
+ content += messages.map((msg) => messageToMarkdown(msg)).join('')
+ filename = `chat-export-${timestamp}.md`
+ mimeType = 'text/markdown'
+ break
+
+ case 'json':
+ content = JSON.stringify(
+ {
+ exportedAt: new Date().toISOString(),
+ messageCount: messages.length,
+ messages: messages.map((msg) => ({
+ id: msg.id,
+ role: msg.role,
+ content: msg.parts
+ .filter((p) => p.type === 'text' && 'text' in p)
+ .map((p) => ('text' in p ? p.text : ''))
+ .join(''),
+ parts: msg.parts.map((part) => {
+ if (part.type === 'text' && 'text' in part) {
+ return {
+ type: 'text',
+ content: part.text || '',
+ }
+ } else if (
+ part.type === 'dynamic-tool' ||
+ part.type.startsWith('tool-')
+ ) {
+ const toolName =
+ part.type === 'dynamic-tool'
+ ? 'toolName' in part
+ ? String(part.toolName)
+ : 'Unknown Tool'
+ : part.type.replace('tool-', '')
+
+ return {
+ type: 'tool-call',
+ toolName,
+ toolCallId:
+ 'toolCallId' in part ? String(part.toolCallId) : undefined,
+ input: 'input' in part ? part.input : undefined,
+ output: 'output' in part ? part.output : undefined,
+ state: 'state' in part ? String(part.state) : undefined,
+ }
+ }
+ return {
+ type: part.type,
+ }
+ }),
+ metadata: msg.metadata,
+ timestamp: msg.metadata?.createdAt
+ ? new Date(msg.metadata.createdAt).toISOString()
+ : new Date().toISOString(),
+ })),
+ },
+ null,
+ 2
+ )
+ filename = `chat-export-${timestamp}.json`
+ mimeType = 'application/json'
+ break
+
+ case 'text':
+ content = `Chat Export\n`
+ content += `Exported on ${new Date().toLocaleString()}\n`
+ content += `${'='.repeat(60)}\n\n`
+ content += messages.map((msg) => messageToText(msg)).join('\n')
+ filename = `chat-export-${timestamp}.txt`
+ mimeType = 'text/plain'
+ break
+
+ default:
+ throw new Error(`Unsupported export format: ${format}`)
+ }
+
+ // Create and trigger download
+ downloadFile(content, filename, mimeType)
+}
+
+/**
+ * Creates a downloadable file and triggers the browser download
+ */
+function downloadFile(content: string, filename: string, mimeType: string) {
+ const blob = new Blob([content], { type: mimeType })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ // Delay URL revocation to ensure download starts successfully
+ setTimeout(() => URL.revokeObjectURL(url), 100)
+}