import DOMPurify from "dompurify"; import { Marked } from "marked"; import { initials } from "./context"; export interface Message { id: string; role: "user" | "assistant" | "tool"; text: string; timestamp: string; partial: boolean; interrupted?: boolean; toolName?: string; toolArguments?: string; } function escapeHtml(text: string) { return text.replace( /[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character] ?? character, ); } const markdown = new Marked({ gfm: true, breaks: true, renderer: { html: ({ text }) => escapeHtml(text), checkbox: ({ checked }) => (checked ? "☑ " : "☐ ") }, }); /** Only passive, sanitized Markdown is allowed inside an assistant message. */ export function renderMarkdown(text: string): string { if (!DOMPurify.isSupported) { return escapeHtml(text); } try { const fragment = DOMPurify.sanitize(markdown.parse(text, { async: false }), { ALLOWED_TAGS: [ "p", "br", "strong", "em", "del", "a", "img", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "code", "hr", "table", "thead", "tbody", "tr", "th", "td", ], ALLOWED_ATTR: ["href", "title", "src", "alt", "start", "align"], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false, RETURN_DOM_FRAGMENT: true, }); for (const link of fragment.querySelectorAll("a")) { if (!link.hasAttribute("href")) { link.replaceWith(...link.childNodes); } else { link.target = "_blank"; link.rel = "noopener noreferrer"; } } for (const image of fragment.querySelectorAll("img")) { image.loading = "lazy"; } for (const table of fragment.querySelectorAll("table")) { const wrapper = document.createElement("div"); wrapper.className = "markdown-table"; wrapper.tabIndex = 0; wrapper.setAttribute("role", "region"); wrapper.setAttribute("aria-label", "Tabla de la respuesta"); table.replaceWith(wrapper); wrapper.append(table); } for (const code of fragment.querySelectorAll("pre")) { code.tabIndex = 0; } const container = document.createElement("div"); container.append(fragment); return container.innerHTML; } catch { return escapeHtml(text); } } export function displayTime(timestamp: string) { return new Intl.DateTimeFormat("es-CL", { timeZone: "America/Santiago", hour: "2-digit", minute: "2-digit" }).format( new Date(timestamp), ); } export function serializeConversation(messages: Message[], userName: string) { return { exportedAt: new Date().toISOString(), messageCount: messages.length, messages: messages.map((message) => message.role === "tool" ? { type: "tool" as const, timestamp: message.timestamp, name: message.toolName ?? "", args: message.toolArguments ?? "", output: message.text, } : { type: message.role, timestamp: message.timestamp, text: message.text, ...(message.role === "user" ? { name: userName || "Usuario", initials: initials(userName || "Usuario") } : {}), ...(message.interrupted ? { interrupted: true } : {}), }, ), }; } export function conversationText(conversation: ReturnType) { const lines = [ `Conversación Vocero — ${conversation.exportedAt}`, `Mensajes totales: ${String(conversation.messageCount)}`, "—".repeat(52), "", ]; for (const message of conversation.messages) { if (message.type === "tool") { lines.push( `${message.timestamp} HERRAMIENTA: ${message.name ?? ""}`, `Entrada:\n${message.args ?? ""}`, `Salida:\n${message.output ?? ""}`, "", ); } else { lines.push( `${message.timestamp} ${message.type === "user" ? (message.name ?? "Usuario") : "Vocero"}${message.interrupted ? " [interrumpida]" : ""}:`, message.text ?? "", "", ); } } return lines.join("\n"); } export function exportConversation(messages: Message[], userName: string) { const conversation = serializeConversation(messages, userName); const name = `vocero-conversacion-${conversation.exportedAt.replace(/[:.]/g, "-")}`; const downloads = [ { content: JSON.stringify(conversation, null, 2), extension: "json", mime: "application/json" }, { content: conversationText(conversation), extension: "txt", mime: "text/plain;charset=utf-8" }, ]; for (const { content, extension, mime } of downloads) { const url = URL.createObjectURL(new Blob([content], { type: mime })); const link = document.createElement("a"); link.href = url; link.download = `${name}.${extension}`; document.body.append(link); link.click(); link.remove(); setTimeout(() => { URL.revokeObjectURL(url); }, 4000); } }