Menu
Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f
AkurAI-Framework / site/frontend/ai/chat/index.js
Latest change 631417472adebacf9be16c712af89ca5d8a25c28 - Add RustAgent agent OS UI contract by Ólafur Búi Ólafsson
// index.js — assembles the chat primitives into a working assistant and wires it
// to the streaming `/api/assistant` endpoint.
//
// This is the "assembled" layer: Thread + Message + Composer + ActionBar are the
// composable Radix-style primitives; `mountChat` wires them together and speaks
// the server's SSE protocol. Use the primitives directly when you want a custom
// layout; use `mountChat` for a batteries-included chat.
//
// Endpoint contract (consumed exactly):
// POST /api/assistant body: { messages: [{role, content}], tools? }
// → text/event-stream with `\n\n`-delimited frames. EventSource can't POST, so
// we read the body as a ReadableStream and parse frames by hand. Events:
// delta data = token text → append to the assistant message
// tool_call data = JSON {id,name,arguments}
// → emit a `chat:toolcall` DOM event with {payload,target,toolCallId}
// done end of turn
// error data = message → shown inline
//
// Native ES module — pure DOM + fetch, zero dependencies.
//
// Public API:
// mountChat(target, options) -> controller
// target an element or a selector string to mount into
// options.endpoint default "/api/assistant"
// options.tools optional tools array forwarded in each request
// options.system optional system prompt seeded as the first message
// options.greeting optional assistant message shown before any turn
// options.emptyState placeholder text for the empty thread
// controller.send(text) programmatically send a user turn
// controller.thread the Thread handle
// controller.composer the Composer handle
// controller.messages the message log ({role, content}) sent to the server
// controller.el the mounted root element
import { createThread } from "./thread.js";
import { createMessage } from "./message.js";
import { createComposer } from "./composer.js";
import { createActionBar } from "./actionbar.js";
const DEFAULT_ENDPOINT = "/api/assistant";
// ── SSE frame parsing ────────────────────────────────────────────────────────
//
// Splits a buffer into complete `\n\n`-delimited frames, returning the parsed
// frames and the trailing partial buffer. Each frame may carry an `event:` line
// and one or more `data:` lines (joined with "\n", SSE-style).
function parseFrames(buffer) {
const frames = [];
const parts = buffer.split("\n\n");
const rest = parts.pop(); // last chunk may be incomplete — keep buffering it
for (const block of parts) {
if (!block.trim()) continue;
let event = "message";
const data = [];
for (const line of block.split("\n")) {
if (line.startsWith(":")) continue; // comment / keep-alive
if (line.startsWith("event:")) {
event = line.slice(6).trim();
} else if (line.startsWith("data:")) {
// Strip exactly one leading space after the colon (SSE convention).
let value = line.slice(5);
if (value.startsWith(" ")) value = value.slice(1);
data.push(value);
}
}
frames.push({ event, data: data.join("\n") });
}
return { frames, rest };
}
// ── streaming request ────────────────────────────────────────────────────────
async function streamAssistant(endpoint, payload, handlers) {
let response;
try {
response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify(payload),
});
} catch (err) {
handlers.onError(`Could not reach the assistant: ${err.message}`);
return;
}
if (!response.ok || !response.body) {
handlers.onError(`Assistant request failed (HTTP ${response.status}).`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
let chunk;
try {
chunk = await reader.read();
} catch (err) {
handlers.onError(`Stream interrupted: ${err.message}`);
return;
}
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const { frames, rest } = parseFrames(buffer);
buffer = rest;
for (const frame of frames) {
dispatch(frame, handlers);
}
}
// Flush any trailing frame without a terminal blank line.
if (buffer.trim()) {
const { frames } = parseFrames(buffer + "\n\n");
for (const frame of frames) dispatch(frame, handlers);
}
handlers.onDone();
}
function dispatch(frame, handlers) {
switch (frame.event) {
case "delta":
handlers.onDelta(frame.data);
break;
case "tool_call":
handlers.onToolCall(safeJson(frame.data));
break;
case "error":
handlers.onError(frame.data || "The assistant returned an error.");
break;
case "done":
// `onDone` is also called when the stream closes; mark it here so a server
// that ends with an explicit `done` event stops the cursor immediately.
handlers.onDone();
break;
default:
// Unknown event types are ignored (forward-compatible).
break;
}
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
}
function toolPayloadFromCall(call) {
const args = call && call.arguments;
let payload = args;
if (typeof args === "string") {
payload = safeJson(args);
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
payload = { value: payload };
}
return {
tool_call_id: call && call.id,
tool_name: (call && call.name) || "unknown",
...payload,
};
}
// ── assembled chat ───────────────────────────────────────────────────────────
function mountChat(target, options = {}) {
const root = typeof target === "string" ? document.querySelector(target) : target;
if (!root) {
console.warn("[chat] mountChat: target not found", target);
return null;
}
const endpoint = options.endpoint || DEFAULT_ENDPOINT;
const tools = options.tools || undefined;
// The conversation log we send to the server on every turn.
const messages = [];
if (options.system) messages.push({ role: "system", content: options.system });
const thread = createThread({ emptyState: options.emptyState });
const composer = createComposer({
placeholder: options.placeholder,
voice: options.voice,
onSend: send,
});
root.classList.add("chat-root");
root.replaceChildren(thread.el, composer.el);
if (options.greeting) {
const greet = thread.addMessage(createMessage({ role: "assistant", content: options.greeting }));
attachActions(greet, { retry: false });
messages.push({ role: "assistant", content: options.greeting });
}
let busy = false;
function attachActions(msgHandle, opts = {}) {
const actions = ["copy"];
if (msgHandle.role === "assistant" && opts.retry !== false) actions.push("retry");
if (msgHandle.role === "user") actions.push("edit");
const bar = createActionBar({
actions,
message: msgHandle,
onAction(name, detail) {
if (name === "retry") retry();
if (name === "edit") composer.setValue(detail.text || "");
},
});
msgHandle.body.appendChild(bar.el);
}
async function send(text) {
if (busy) return;
const user = thread.addMessage(createMessage({ role: "user", content: text }));
attachActions(user);
messages.push({ role: "user", content: text });
await run();
}
async function retry() {
if (busy) return;
// Drop the last assistant turn from the log and re-run from the prior user.
if (messages.length && messages[messages.length - 1].role === "assistant") {
messages.pop();
}
await run();
}
async function run() {
busy = true;
composer.setDisabled(true);
const assistant = thread.addMessage(createMessage({ role: "assistant", streaming: true }));
let received = false;
// `done` may arrive both as an explicit event and at stream close — run the
// teardown exactly once per turn.
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
busy = false;
composer.setDisabled(false);
composer.focus();
};
await streamAssistant(
endpoint,
{ messages: messages.slice(), tools },
{
onDelta(text) {
received = true;
assistant.append(text);
thread.autoScroll();
},
onToolCall(call) {
received = true;
const placeholder = document.createElement("div");
placeholder.className = "chat-toolcall";
placeholder.innerHTML =
'<span class="chat-toolcall-icon" aria-hidden="true">⚙</span> ' +
`<span>Tool call: <code>${(call && call.name) || "unknown"}</code></span>`;
placeholder.dataset.toolCallId = (call && call.id) || "";
assistant.body.appendChild(placeholder);
// Hand the call to the tool-UI runtime (if any is listening).
root.dispatchEvent(
new CustomEvent("chat:toolcall", {
detail: {
call,
payload: toolPayloadFromCall(call),
target: placeholder,
toolCallId: call && call.id,
},
bubbles: true,
})
);
console.log("[chat] toolcall", call && call.name);
thread.autoScroll();
},
onError(message) {
assistant.setError(message);
console.log("[chat] error", message);
thread.autoScroll();
},
onDone() {
if (finished) return; // fired twice (explicit event + stream close)
if (!assistant.el.classList.contains("chat-message-error")) {
assistant.setStreaming(false);
if (received) {
messages.push({ role: "assistant", content: assistant.getText() });
attachActions(assistant);
}
}
finish();
},
}
);
}
console.log("[chat] mounted", endpoint);
return {
el: root,
thread,
composer,
messages,
send,
};
}
export { mountChat, createThread, createMessage, createComposer, createActionBar };
export default mountChat;