feat: add export to topics dropdown
This commit is contained in:
@@ -2,12 +2,17 @@
|
|||||||
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
|
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
|
||||||
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
|
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
|
||||||
import { DialogType } from '~/composables/useDialog';
|
import { DialogType } from '~/composables/useDialog';
|
||||||
|
import { exportTopicToJson, exportTopicToMarkdown } from '~~/utils/export';
|
||||||
|
|
||||||
const { openDialog } = useDialog();
|
const { openDialog } = useDialog();
|
||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
const dropdownTrigger = ref<HTMLElement | null>(null);
|
const dropdownTrigger = ref<HTMLElement | null>(null);
|
||||||
const dropdownContent = ref(null);
|
const dropdownContent = ref(null);
|
||||||
const activeMenuTopicId = ref<string | null>(null);
|
const activeMenuTopicId = ref<string | null>(null);
|
||||||
|
const exportSubmenuOpen = ref(false);
|
||||||
|
const exportTrigger = ref<HTMLElement | null>(null);
|
||||||
|
const exportContent = ref(null);
|
||||||
|
const exportInProgress = ref(false);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
|
const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
|
||||||
@@ -34,16 +39,51 @@ const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigge
|
|||||||
transform: false,
|
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(() =>
|
const transformOrigin = computed(() =>
|
||||||
placement.value.startsWith('top')
|
placement.value.startsWith('top')
|
||||||
? 'transform-origin-bottom-center'
|
? 'transform-origin-bottom-center'
|
||||||
: 'transform-origin-top-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 = () => {
|
const closeDropdown = () => {
|
||||||
dropdownOpen.value = false;
|
dropdownOpen.value = false;
|
||||||
activeMenuTopicId.value = null;
|
activeMenuTopicId.value = null;
|
||||||
dropdownTrigger.value = null;
|
dropdownTrigger.value = null;
|
||||||
|
closeExportSubmenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openExportSubmenu = () => {
|
||||||
|
exportSubmenuOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Computed to get the topic data for the currently open menu
|
// 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)
|
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 topicsOpen = ref(true);
|
||||||
|
|
||||||
const autoRenameTopic = async (topicId: string) => {
|
const autoRenameTopic = async (topicId: string) => {
|
||||||
@@ -135,6 +204,7 @@ const handleNavClick = (e: MouseEvent) => {
|
|||||||
activeMenuTopicId.value = topicId;
|
activeMenuTopicId.value = topicId;
|
||||||
dropdownTrigger.value = trigger;
|
dropdownTrigger.value = trigger;
|
||||||
dropdownOpen.value = true;
|
dropdownOpen.value = true;
|
||||||
|
closeExportSubmenu();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,36 +281,49 @@ onMounted(() => {
|
|||||||
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
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-from-class="opacity-100 scale-100 translate-y-0"
|
||||||
leave-to-class="opacity-0 scale-95 translate-y-1">
|
leave-to-class="opacity-0 scale-95 translate-y-1">
|
||||||
<div v-if="dropdownOpen" ref="dropdownContent" :style="{
|
<div v-if="dropdownOpen" ref="dropdownContent" v-click-outside="handleDropdownClickOutside" :style="{
|
||||||
...floatingStyles,
|
...floatingStyles,
|
||||||
visibility: middlewareData.hide?.referenceHidden
|
visibility: middlewareData.hide?.referenceHidden
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: 'visible',
|
: 'visible',
|
||||||
}" class="fixed z-20" :class="transformOrigin">
|
}" class="fixed z-20" :class="transformOrigin">
|
||||||
<div v-click-outside="closeDropdown"
|
<div
|
||||||
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
|
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
|
||||||
|
|
||||||
|
|
||||||
<!-- Dynamic content based on menuTopic -->
|
<!-- Dynamic content based on menuTopic -->
|
||||||
<template v-if="menuTopic">
|
<template v-if="menuTopic">
|
||||||
<button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()"
|
<button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()"
|
||||||
|
@mouseenter="closeExportSubmenu"
|
||||||
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
||||||
Cancel Auto Rename
|
Cancel Auto Rename
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()"
|
<button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()"
|
||||||
|
@mouseenter="closeExportSubmenu"
|
||||||
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
||||||
Auto Rename
|
Auto Rename
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button :disabled="menuTopic.renaming ?? false"
|
<button :disabled="menuTopic.renaming ?? false"
|
||||||
@click="startRename(menuTopic.id, menuTopic.name); closeDropdown()"
|
@click="startRename(menuTopic.id, menuTopic.name); closeDropdown()"
|
||||||
|
@mouseenter="closeExportSubmenu"
|
||||||
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button ref="exportTrigger" @click.stop="exportSubmenuOpen = !exportSubmenuOpen"
|
||||||
|
@mouseenter="openExportSubmenu"
|
||||||
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 flex items-center justify-between gap-3"
|
||||||
|
:class="{ 'bg-[var(--color-hover)]': exportSubmenuOpen }"
|
||||||
|
:disabled="exportInProgress">
|
||||||
|
<span>Export</span>
|
||||||
|
<span class="i-mynaui-chevron-right text-4 shrink-0"
|
||||||
|
:class="exportPlacement.startsWith('left') ? 'rotate-180' : ''"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="h-px bg-[var(--color-border)] my-1" />
|
<div class="h-px bg-[var(--color-border)] my-1" />
|
||||||
|
|
||||||
<button @click="deleteTopic(menuTopic.id); closeDropdown()"
|
<button @click="deleteTopic(menuTopic.id); closeDropdown()"
|
||||||
|
@mouseenter="closeExportSubmenu"
|
||||||
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -248,6 +331,32 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
|
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
|
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||||
|
<div v-if="dropdownOpen && exportSubmenuOpen" ref="exportContent" :style="{
|
||||||
|
...exportFloatingStyles,
|
||||||
|
visibility: exportMiddlewareData.hide?.referenceHidden
|
||||||
|
? 'hidden'
|
||||||
|
: 'visible',
|
||||||
|
}" class="fixed z-30" :class="exportTransformOrigin"
|
||||||
|
@mouseenter="openExportSubmenu">
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-44">
|
||||||
|
<button :disabled="exportInProgress" @click="handleExport('json')"
|
||||||
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 whitespace-nowrap">
|
||||||
|
Export to JSON
|
||||||
|
</button>
|
||||||
|
<button :disabled="exportInProgress" @click="handleExport('markdown')"
|
||||||
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 whitespace-nowrap">
|
||||||
|
Export to Markdown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -3,7 +3,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
mounted(el: any, binding: any) {
|
mounted(el: any, binding: any) {
|
||||||
el.clickOutsideEvent = (event: Event) => {
|
el.clickOutsideEvent = (event: Event) => {
|
||||||
if (!el.contains(event.target as Node)) {
|
if (!el.contains(event.target as Node)) {
|
||||||
binding.value();
|
binding.value(event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+224
@@ -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<typeof serializeMessageEntity> & {
|
||||||
|
children: ReturnType<typeof serializeMessageEntity>[];
|
||||||
|
} => ({
|
||||||
|
...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(`<details>\n<summary>Reasoning</summary>\n\n${part.content}\n</details>`);
|
||||||
|
}
|
||||||
|
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<TopicExportSource | null> => {
|
||||||
|
try {
|
||||||
|
const topic = await $fetch<TopicExportSource>(`/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',
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user