feat: add export to topics dropdown
This commit is contained in:
+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