AkurAI Build
Menu

Public repository / 91bd2a37654909c5a3aef6ddd614991bd378ccb8

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, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

// ── 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 (/^&gt;\s?/.test(line)) {
      flushParagraph();
      closeList();
      out.push(`<blockquote class="chat-md-quote">${renderInline(line.replace(/^&gt;\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;