175 lines
5.1 KiB
TypeScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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);
|
|
}
|
|
}
|