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