Files

225 lines
7.2 KiB
TypeScript

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',
);
};