AkurAI Build
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.