Files
vocero-client/s2s-frontend/src/lib/messages.ts
T

175 lines
5.1 KiB
TypeScript

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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<typeof serializeConversation>) {
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);
}
}