Menu
Public repository / master
AkurAI-Framework / site/content/docs/chat.md
Latest change 631417472adebacf9be16c712af89ca5d8a25c28 - Add RustAgent agent OS UI contract by Ólafur Búi Ólafsson
# Chat primitives
A native-ESM port of [assistant-ui](https://github.com/assistant-ui/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
```html
<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:
```js
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:
```js
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
```js
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).
```js
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`
```json
{ "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:
```js
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-demo`
> needs one route entry in `backend/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.