import type { Message, MessageEntity, MessagePart } from '~/composables/useChat'; import { buildFocusedMessageTree, buildMessageTree } from '~~/utils/message'; type TopicExportSource = { id: string; name: string; agentId: string; createdAt: Date | string; messages: MessageEntity[]; }; const sanitizeFilename = (name: string): string => { const cleaned = name .trim() .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '') .replace(/\s+/g, ' ') .slice(0, 80) .trim(); return cleaned.length > 0 ? cleaned : 'topic'; }; const downloadBlob = (content: string, filename: string, mimeType: string) => { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); }; const serializePart = (part: MessagePart) => ({ id: part.id, type: part.type, content: part.content, finished: part.finished, createdAt: part.createdAt, lastUpdatedAt: part.lastUpdatedAt, toolCall: part.toolCall ? { id: part.toolCall.id, toolName: part.toolCall.toolName, status: part.toolCall.status, input: part.toolCall.input, output: part.toolCall.output, error: part.toolCall.error, createdAt: part.toolCall.createdAt, } : null, }); const serializeMessageEntity = (message: MessageEntity) => ({ id: message.id, role: message.role, content: message.content, parentMessageId: message.parentMessageId, generationId: message.generationId, activeChildId: message.activeChildId, deleted: message.deleted, createdAt: message.createdAt, updatedAt: message.updatedAt, parts: (message.parts || []).map(serializePart), generation: message.generation ? { id: message.generation.id, modelId: message.generation.modelId, status: message.generation.status, tokens: message.generation.tokens, error: message.generation.error, } : null, attachments: (message.attachments || []).map((attachment) => ({ id: attachment.id, fileId: attachment.fileId, createdAt: attachment.createdAt, file: { id: attachment.file.id, name: attachment.file.name, mimeType: attachment.file.mimeType, size: attachment.file.size, url: attachment.file.url, }, })), }); const serializeMessageTree = (message: Message): ReturnType & { children: ReturnType[]; } => ({ ...serializeMessageEntity(message), children: (message.children || []) .filter((child): child is MessageEntity => child !== undefined) .map((child) => serializeMessageTree(child as Message)), }); const formatMessageAsMarkdown = (message: MessageEntity): string => { const roleLabel = message.role === 'user' ? 'User' : 'Assistant'; const sections: string[] = [`### ${roleLabel}`]; if (message.role === 'user') { if (message.content) { sections.push(message.content); } if ((message.attachments || []).length > 0) { const attachmentLines = message.attachments.map((attachment) => { return `- Attachment: ${attachment.file.name} (${attachment.file.mimeType})`; }); sections.push(attachmentLines.join('\n')); } } else { const partSections: string[] = []; for (const part of message.parts || []) { switch (part.type) { case 'text': { if (part.content) { partSections.push(part.content); } break; } case 'reasoning': { if (part.content) { partSections.push(`
\nReasoning\n\n${part.content}\n
`); } break; } case 'tool-call': { const toolName = part.toolCall?.toolName ?? 'tool'; const status = part.toolCall?.status ?? 'unknown'; const input = part.toolCall?.input ? JSON.stringify(part.toolCall.input, null, 2) : ''; const output = part.toolCall?.output ? JSON.stringify(part.toolCall.output, null, 2) : part.toolCall?.error ? JSON.stringify(part.toolCall.error, null, 2) : ''; partSections.push([ `#### Tool: ${toolName} (${status})`, input ? `\`\`\`json\n${input}\n\`\`\`` : '', output ? `\`\`\`json\n${output}\n\`\`\`` : '', ].filter(Boolean).join('\n\n')); break; } } } if (partSections.length > 0) { sections.push(partSections.join('\n\n')); } else if (message.content) { sections.push(message.content); } } return sections.join('\n\n'); }; const fetchTopicForExport = async (topicId: string): Promise => { try { const topic = await $fetch(`/api/topic/${topicId}`); return topic; } catch (error) { console.error('Failed to fetch topic for export:', error); return null; } }; export const exportTopicToJson = async (topicId: string) => { const topic = await fetchTopicForExport(topicId); if (!topic) return; const messageTree = buildMessageTree(topic.messages || []); const payload = { version: 1, exportedAt: new Date().toISOString(), format: 'json' as const, includesRegenerations: true, topic: { id: topic.id, name: topic.name, agentId: topic.agentId, createdAt: topic.createdAt, }, messages: messageTree.map(serializeMessageTree), }; downloadBlob( JSON.stringify(payload, null, 2), `${sanitizeFilename(topic.name)}.json`, 'application/json', ); }; export const exportTopicToMarkdown = async (topicId: string) => { const topic = await fetchTopicForExport(topicId); if (!topic) return; const messageTree = buildMessageTree(topic.messages || []); const focusedMessages = buildFocusedMessageTree(messageTree); const body = focusedMessages .filter((message) => message.deleted !== true) .map(formatMessageAsMarkdown) .join('\n\n---\n\n'); const markdown = [ `# ${topic.name}`, '', `> Exported from Veridian on ${new Date().toISOString()}`, `> Focused conversation only (regenerations excluded)`, '', body || '_No messages in focused conversation._', '', ].join('\n'); downloadBlob( markdown, `${sanitizeFilename(topic.name)}.md`, 'text/markdown', ); };