Menu
Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f
AkurAI-Framework / site/frontend/ai/chat/message.js
Latest change 6dcfea4319fde02bfdcdbd5c872f5ee323b2cf65 - Checkpoint WIP + add AkurAI Build CI/CD pipeline by Ólafur Búi Ólafsson
// message.js — Message primitive (a 1:1 native port of assistant-ui's Message).
//
// Renders a single conversation turn (user / assistant / system) with role
// styling. Assistant text is rendered as lightweight client-side Markdown (bold,
// italic, inline code, links, headings, lists, and fenced code blocks); user and
// system turns render as escaped plain text with line breaks preserved. The
// primitive supports streaming: call `append(delta)` to push tokens as they
// arrive and the rendered Markdown re-flows live.
//
// Native ES module — pure DOM, zero dependencies. Nothing here runs at import
// time, so it is safe to load anywhere and `node --check` parses cleanly.
//
// Public API:
// createMessage({ role, content, streaming }) -> handle
// handle.el the message root element (append it to a Thread)
// handle.role "user" | "assistant" | "system"
// handle.append(s) append a streaming delta, re-render
// handle.setText(s) replace the full text, re-render
// handle.getText() current raw text
// handle.setStreaming(bool) toggle the streaming cursor
// handle.setError(msg) render an inline error state
// renderMarkdown(text) -> HTML string (exported for reuse/tests)
// escapeHtml(text) -> HTML string
// ── HTML escaping ────────────────────────────────────────────────────────────
function escapeHtml(text) {
return String(text ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// ── tiny Markdown renderer ───────────────────────────────────────────────────
//
// Deliberately small: enough to make assistant prose readable without bundling a
// Markdown library. Everything is escaped first, so no input can inject HTML.
// Fenced code blocks are extracted before escaping and restored verbatim
// (escaped) at the end, so backticks and markup inside them are shown literally.
// A placeholder marker for extracted code blocks. ASCII, no spaces, vanishingly
// unlikely to appear in real prose; matched again at restore time.
const CODE_OPEN = "@@CHATCODE";
const CODE_CLOSE = "@@";
function renderInline(text) {
// `text` is already HTML-escaped. Apply inline spans on the escaped string.
return text
.replace(/`([^`]+)`/g, (_m, code) => `<code class="chat-code-inline">${code}</code>`)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(_m, label, url) => link(url, label)
)
.replace(/(^|[^"=>])\b(https?:\/\/[^\s<]+)/g, (_m, pre, url) => `${pre}${link(url, url)}`);
}
function link(url, label) {
// url/label are already escaped; rel/target keep external links safe.
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
function renderMarkdown(src) {
const text = String(src ?? "");
const codeBlocks = [];
// 1. Pull out fenced code blocks so their contents are never marked up.
const withoutCode = text.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
const idx = codeBlocks.length;
const langAttr = lang ? ` data-lang="${escapeHtml(lang)}"` : "";
codeBlocks.push(
`<pre class="chat-code-block"${langAttr}><code>${escapeHtml(body.replace(/\n$/, ""))}</code></pre>`
);
return CODE_OPEN + idx + CODE_CLOSE;
});
// 2. Block-level parse on the escaped, code-free text.
const escaped = escapeHtml(withoutCode);
const lines = escaped.split("\n");
const out = [];
let listType = null; // "ul" | "ol" | null
let paragraph = [];
const flushParagraph = () => {
if (paragraph.length) {
out.push(`<p>${renderInline(paragraph.join("<br>"))}</p>`);
paragraph = [];
}
};
const closeList = () => {
if (listType) {
out.push(`</${listType}>`);
listType = null;
}
};
const codeLineRe = new RegExp("^" + CODE_OPEN + "(\\d+)" + CODE_CLOSE + "$");
for (const raw of lines) {
const line = raw.replace(/\s+$/, "").replace(/^\s+/, (m) => (m.length ? m : ""));
const trimmed = line.trim();
const heading = line.match(/^(#{1,6})\s+(.*)$/);
const ordered = line.match(/^\d+\.\s+(.*)$/);
const bullet = line.match(/^[-*+]\s+(.*)$/);
if (trimmed === "") {
flushParagraph();
closeList();
} else if (codeLineRe.test(trimmed)) {
// A fenced-code placeholder on its own line — emit at block level so the
// restored <pre> is never nested inside a <p>.
flushParagraph();
closeList();
out.push(trimmed);
} else if (heading) {
flushParagraph();
closeList();
const level = heading[1].length;
out.push(`<h${level} class="chat-md-h">${renderInline(heading[2])}</h${level}>`);
} else if (bullet || ordered) {
flushParagraph();
const want = ordered ? "ol" : "ul";
if (listType !== want) {
closeList();
listType = want;
out.push(`<${want} class="chat-md-list">`);
}
out.push(`<li>${renderInline(ordered ? ordered[1] : bullet[1])}</li>`);
} else if (/^>\s?/.test(line)) {
flushParagraph();
closeList();
out.push(`<blockquote class="chat-md-quote">${renderInline(line.replace(/^>\s?/, ""))}</blockquote>`);
} else {
if (listType) closeList();
paragraph.push(line);
}
}
flushParagraph();
closeList();
// 3. Restore code blocks (inline remnants and block-level lines alike).
const restoreRe = new RegExp(CODE_OPEN + "(\\d+)" + CODE_CLOSE, "g");
return out.join("\n").replace(restoreRe, (_m, i) => codeBlocks[Number(i)] || "");
}
// ── Message primitive ────────────────────────────────────────────────────────
const ROLE_LABELS = { user: "You", assistant: "Assistant", system: "System" };
function createMessage(options = {}) {
const role = options.role === "assistant" || options.role === "system" ? options.role : "user";
let text = String(options.content ?? "");
let streaming = !!options.streaming;
const el = document.createElement("div");
el.className = `chat-message chat-message-${role}`;
el.setAttribute("data-role", role);
el.setAttribute("role", "listitem");
const avatar = document.createElement("div");
avatar.className = "chat-message-avatar";
avatar.setAttribute("aria-hidden", "true");
if (role === "assistant") {
const image = document.createElement("img");
image.src = "/img/forkbird-avatar.png";
image.alt = "";
image.width = 256;
image.height = 256;
avatar.appendChild(image);
} else {
avatar.textContent = role === "system" ? "S" : "U";
}
const body = document.createElement("div");
body.className = "chat-message-body";
const label = document.createElement("div");
label.className = "chat-message-role";
label.textContent = ROLE_LABELS[role] || role;
const content = document.createElement("div");
content.className = "chat-message-content";
body.append(label, content);
el.append(avatar, body);
function render() {
if (role === "assistant") {
content.innerHTML =
renderMarkdown(text) +
(streaming ? '<span class="chat-cursor" aria-hidden="true"></span>' : "");
} else {
// Plain text for user/system, line breaks preserved, never interpreted.
content.innerHTML = escapeHtml(text).replace(/\n/g, "<br>");
}
el.classList.toggle("is-streaming", streaming);
}
render();
return {
el,
body,
role,
get text() {
return text;
},
getText() {
return text;
},
append(delta) {
text += String(delta ?? "");
render();
return text;
},
setText(next) {
text = String(next ?? "");
render();
return text;
},
setStreaming(on) {
streaming = !!on;
render();
},
setError(message) {
streaming = false;
el.classList.add("chat-message-error");
content.innerHTML =
'<span class="chat-message-error-icon" aria-hidden="true">x</span> ' +
`<span>${escapeHtml(message || "Something went wrong.")}</span>`;
},
};
}
export { createMessage, renderMarkdown, escapeHtml };
export default createMessage;