Chat primitives
A native-ESM port of assistant-ui's composable chat primitives. No React, no bundler, no TypeScript, zero dependencies — just ES modules the browser loads directly, built on the framework's design tokens and signals.js.
You assemble a chat from small Radix-style primitives rather than dropping in a monolith. Each primitive is a factory that returns a handle { el, … }; you own the layout and wiring. Or call mountChat() for a batteries-included assistant.
The modules live under /ai/chat/ and are served as plain static assets:
| Module | Primitive | Export | |---|---|---| | thread.js | Thread — message list, auto-scroll, scroll-to-bottom, empty state | createThread | | message.js | Message — one turn, role styling, Markdown + streaming | createMessage, renderMarkdown, escapeHtml | | composer.js | Composer — textarea, Send, Enter/Shift-Enter, attach, voice | createComposer | | actionbar.js | ActionBar — per-message copy / retry / edit | createActionBar | | index.js | assembled chat + SSE client | mountChat (default) |
Quick start
<div id="chat"></div>
<script type="module">
import { mountChat } from "/ai/chat/index.js";
mountChat("#chat", {
greeting: "Hi! Ask me anything.",
placeholder: "Send a message…",
});
</script>
mountChat(target, options) returns a controller:
const chat = mountChat("#chat", { system: "You are concise.", tools });
chat.send("Hello"); // send a turn programmatically
chat.thread; // the Thread handle
chat.composer; // the Composer handle
chat.messages; // the {role, content}[] log sent to the server
Options: endpoint (default /api/assistant), tools, system, greeting, placeholder, emptyState, voice (set false to hide the dictation button).
Composing primitives by hand
Skip mountChat when you want a custom layout:
import { createThread } from "/ai/chat/thread.js";
import { createMessage } from "/ai/chat/message.js";
import { createComposer } from "/ai/chat/composer.js";
import { createActionBar } from "/ai/chat/actionbar.js";
const thread = createThread({ emptyState: "No messages yet." });
document.body.append(thread.el);
const composer = createComposer({
onSend(text) {
const user = thread.addMessage(createMessage({ role: "user", content: text }));
user.body.appendChild(createActionBar({ message: user, actions: ["copy"] }).el);
// …kick off your own request here…
},
});
document.body.append(composer.el);
Message + streaming
const m = thread.addMessage(createMessage({ role: "assistant", streaming: true }));
m.append("Hel"); // push deltas as they arrive
m.append("lo world"); // Markdown re-flows live; a blinking cursor shows streaming
m.setStreaming(false); // turn complete
m.setError("Network failed."); // or render an inline error state
Assistant content is rendered with a tiny client-side Markdown renderer (renderMarkdown) — bold, italic, inline code, links, headings, lists, blockquotes and fenced code blocks. All input is HTML-escaped first, so message text can never inject markup. User and system turns render as escaped plain text with line breaks preserved.
ActionBar events
ActionBar makes no backend assumptions. It dispatches a bubbling chat:action CustomEvent and invokes an optional onAction(name, detail) callback. The copy action also writes to the clipboard itself (best effort).
thread.el.addEventListener("chat:action", (e) => {
console.log(e.detail.action, e.detail.text); // "retry" | "copy" | "edit"
});
Composer: voice & attachments
The 🎤 dictation button uses the browser Web Speech API (webkitSpeechRecognition / SpeechRecognition). It is feature-detected: in browsers without the API the button is simply omitted — no errors, no dead button. The 📎 attachment button dispatches a chat:attach event (wire it to the blob upload). Enter sends; Shift+Enter inserts a newline; the textarea auto-grows.
Endpoint contract
mountChat consumes the streaming assistant endpoint exactly as specified. EventSource cannot issue a POST, so the client uses fetch with a ReadableStream reader and parses \n\n-delimited SSE frames by hand.
Request — POST /api/assistant
{ "messages": [{ "role": "user", "content": "Hello" }], "tools": [] }
Response — text/event-stream, frames separated by a blank line:
| Event | data | Client behaviour | |---|---|---| | delta | token text | appended to the current assistant message | | tool_call | JSON {id,name,arguments} | renders a placeholder, emits chat:toolcall with {call,payload,target,toolCallId} for the tool-UI runtime | | done | — | ends the turn | | error | message | rendered inline on the assistant message |
event: delta
data: Hello
event: delta
data: world
event: done
data:
Generative-UI hook — listen for tool calls:
chat.el.addEventListener("chat:toolcall", (e) => {
console.log(e.detail.call.name, e.detail.payload);
});
Demo
site/frontend/ai/chat-demo.html mounts a full chat. When no AKURAI_LLM_URL is configured the server emits an SSE error event; the assistant turn renders that inline, so the demo stays usable without a live model. Open DevTools → Console to watch the [chat] breadcrumbs as you send messages.
Serving the demo page. The chat modules (/ai/chat/*.js) are served as static assets out of the box. The demo page uses server-side{% include %}and lives in a subdirectory, so reaching it at/ai/chat-demoneeds one route entry inbackend/routes.json({ "path": "/ai/chat-demo", "template": "chat-demo" }) with the template registered at the frontend root — add it when you want the page routed.