From 28ef0c8a078db4c09748a3836a3ed23da8cfde07 Mon Sep 17 00:00:00 2001 From: Zoe Date: Thu, 16 Jul 2026 14:29:07 -0500 Subject: [PATCH] feat: add export to topics dropdown --- app/components/Sidenav/NavAgent.vue | 115 +++++++++++++- app/plugins/clickOutside.ts | 2 +- utils/export.ts | 224 ++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 utils/export.ts diff --git a/app/components/Sidenav/NavAgent.vue b/app/components/Sidenav/NavAgent.vue index 2d718a6..a948406 100644 --- a/app/components/Sidenav/NavAgent.vue +++ b/app/components/Sidenav/NavAgent.vue @@ -2,12 +2,17 @@ import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue'; import { DialogType } from '~/composables/useDialog'; +import { exportTopicToJson, exportTopicToMarkdown } from '~~/utils/export'; const { openDialog } = useDialog(); const dropdownOpen = ref(false); const dropdownTrigger = ref(null); const dropdownContent = ref(null); const activeMenuTopicId = ref(null); +const exportSubmenuOpen = ref(false); +const exportTrigger = ref(null); +const exportContent = ref(null); +const exportInProgress = ref(false); const route = useRoute(); const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents(); @@ -34,16 +39,51 @@ const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigge transform: false, }); +const { + floatingStyles: exportFloatingStyles, + placement: exportPlacement, + middlewareData: exportMiddlewareData, +} = useFloating(exportTrigger, exportContent, { + placement: 'right-start', + whileElementsMounted: autoUpdate, + middleware: [offset(6), flip({ fallbackPlacements: ['left-start', 'right-end', 'left-end'] }), shift({ padding: 10 }), hide()], + transform: false, +}); + const transformOrigin = computed(() => placement.value.startsWith('top') ? 'transform-origin-bottom-center' : 'transform-origin-top-center' ); +const exportTransformOrigin = computed(() => { + switch (exportPlacement.value.split('-')[0]) { + case 'left': + return 'transform-origin-right-center'; + case 'right': + return 'transform-origin-left-center'; + case 'top': + return 'transform-origin-bottom-center'; + case 'bottom': + return 'transform-origin-top-center'; + default: + return 'transform-origin-left-center'; + } +}); + +const closeExportSubmenu = () => { + exportSubmenuOpen.value = false; +}; + const closeDropdown = () => { dropdownOpen.value = false; activeMenuTopicId.value = null; dropdownTrigger.value = null; + closeExportSubmenu(); +}; + +const openExportSubmenu = () => { + exportSubmenuOpen.value = true; }; // Computed to get the topic data for the currently open menu @@ -51,6 +91,35 @@ const menuTopic = computed(() => topics.value.find(t => t.id === activeMenuTopicId.value) ); +const handleExport = async (format: 'json' | 'markdown') => { + if (!menuTopic.value || exportInProgress.value) return; + + exportInProgress.value = true; + try { + if (format === 'json') { + await exportTopicToJson(menuTopic.value.id); + } else { + await exportTopicToMarkdown(menuTopic.value.id); + } + } finally { + exportInProgress.value = false; + closeDropdown(); + } +}; + +const handleDropdownClickOutside = (event?: Event) => { + const target = event?.target as Node | null; + if (target) { + if (exportContent.value && (exportContent.value as HTMLElement).contains(target)) { + return; + } + if (exportTrigger.value && exportTrigger.value.contains(target)) { + return; + } + } + closeDropdown(); +}; + const topicsOpen = ref(true); const autoRenameTopic = async (topicId: string) => { @@ -135,6 +204,7 @@ const handleNavClick = (e: MouseEvent) => { activeMenuTopicId.value = topicId; dropdownTrigger.value = trigger; dropdownOpen.value = true; + closeExportSubmenu(); break; } } @@ -211,36 +281,49 @@ onMounted(() => { leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1"> -
-
- \ No newline at end of file diff --git a/app/plugins/clickOutside.ts b/app/plugins/clickOutside.ts index fcd250b..f7a1644 100644 --- a/app/plugins/clickOutside.ts +++ b/app/plugins/clickOutside.ts @@ -3,7 +3,7 @@ export default defineNuxtPlugin((nuxtApp) => { mounted(el: any, binding: any) { el.clickOutsideEvent = (event: Event) => { if (!el.contains(event.target as Node)) { - binding.value(); + binding.value(event); } }; diff --git a/utils/export.ts b/utils/export.ts new file mode 100644 index 0000000..17ed95f --- /dev/null +++ b/utils/export.ts @@ -0,0 +1,224 @@ +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', + ); +};