refactor(frontend): rename project to s2s-frontend
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user