feat(ui): render assistant replies as Markdown
This commit is contained in:
+17
-5
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { $, escHtml, DEBUG } from "./dom.js";
|
||||
import { renderMarkdown } from "./markdown.js";
|
||||
|
||||
/**
|
||||
* @param {string} hypothesisId
|
||||
@@ -99,6 +100,8 @@ export class ChatView {
|
||||
// ── Assistant transcript state (keyed by response_id) ──────────────────
|
||||
/** @type {Map<string, { bubble: HTMLElement, hist: HTMLElement }>} */
|
||||
this._asstByResp = new Map();
|
||||
/** Original transcript text is retained independently of rendered markup. @type {WeakMap<HTMLElement, string>} */
|
||||
this._messageText = new WeakMap();
|
||||
|
||||
// ── Ephemeral bubble auto-dismiss ──────────────────────────────────────
|
||||
// Per-element expiry (epoch ms). A bubble fades once its expiry passes —
|
||||
@@ -310,9 +313,10 @@ export class ChatView {
|
||||
el.setAttribute("data-asst-name", this._assistantName);
|
||||
el.setAttribute("data-timestamp", stamp);
|
||||
}
|
||||
el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}">${escHtml(text)}</div>`;
|
||||
el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}"></div>`;
|
||||
const body = /** @type {HTMLDivElement} */ (el.lastElementChild);
|
||||
if (body) {
|
||||
this._renderMessageBody(body, text, role === "assistant");
|
||||
if (role === "user") {
|
||||
body.setAttribute("data-user-name", el.getAttribute("data-user-name") || "");
|
||||
} else {
|
||||
@@ -323,6 +327,14 @@ export class ChatView {
|
||||
return el;
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} body @param {string} text @param {boolean} assistant */
|
||||
_renderMessageBody(body, text, assistant) {
|
||||
this._messageText.set(body, text);
|
||||
body.classList.toggle("markdown-content", assistant);
|
||||
if (assistant) body.replaceChildren(renderMarkdown(text));
|
||||
else body.textContent = text;
|
||||
}
|
||||
|
||||
// ── Ephemeral bubbles ───────────────────────────────────────────────────
|
||||
|
||||
/** @param {"user"|"assistant"|"tool"} role @param {string} text @returns {HTMLElement} */
|
||||
@@ -348,8 +360,8 @@ export class ChatView {
|
||||
|
||||
/** @param {HTMLElement} el @param {string} text */
|
||||
_updateBubbleText(el, text) {
|
||||
const t = el.querySelector(".bubble-body");
|
||||
if (t) t.textContent = text;
|
||||
const t = /** @type {HTMLElement | null} */ (el.querySelector(".bubble-body"));
|
||||
if (t) this._renderMessageBody(t, text, el.classList.contains("assistant"));
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} el */
|
||||
@@ -433,7 +445,7 @@ export class ChatView {
|
||||
if (!el) return;
|
||||
const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body"));
|
||||
if (!body) return;
|
||||
body.textContent = text;
|
||||
this._renderMessageBody(body, text, el.classList.contains("assistant"));
|
||||
body.classList.toggle("partial", partial);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
@@ -544,7 +556,7 @@ export class ChatView {
|
||||
const role = row.classList.contains("user") ? "user" : "assistant";
|
||||
const out = /** @type {{ type: string; text: string; timestamp?: string; name?: string; initials?: string; interrupted?: boolean }} */ ({
|
||||
type: role,
|
||||
text: body.textContent || "",
|
||||
text: this._messageText.get(body) ?? body.textContent ?? "",
|
||||
});
|
||||
if (ts) out.timestamp = ts;
|
||||
if (role === "user") {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @ts-check
|
||||
import { Marked } from "../vendor/marked/marked.esm.js";
|
||||
import DOMPurify from "../vendor/dompurify/purify.es.mjs";
|
||||
import { escHtml } from "./dom.js";
|
||||
|
||||
const markdown = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
// Treat embedded HTML as text. Replies can format Markdown, not UI controls.
|
||||
html: ({ text }) => escHtml(text),
|
||||
checkbox: ({ checked }) => checked ? "☑ " : "☐ ",
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse assistant text into passive, sanitized message content. @param {string} text */
|
||||
export function renderMarkdown(text) {
|
||||
if (!DOMPurify.isSupported) return document.createTextNode(text);
|
||||
try {
|
||||
const html = markdown.parse(text, { async: false });
|
||||
const fragment = DOMPurify.sanitize(html, {
|
||||
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.appendChild(table);
|
||||
}
|
||||
for (const code of fragment.querySelectorAll("pre")) code.tabIndex = 0;
|
||||
return fragment;
|
||||
} catch (err) {
|
||||
console.warn("[ui] Markdown rendering failed", err);
|
||||
return document.createTextNode(text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user