AkurAI Build
Menu

Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f

AkurAI-Framework / site/frontend/ai/runtime/receipts.js

Latest change 22a320a8e40a1822b386e2d5fd9bcf3537282876 - feat(ai): generative-UI / tool-call runtime + primitives by Ólafur Búi Ólafsson

// receipts.js — send a tool-result receipt back to the assistant.
//
// When an interactive generative-UI component (an approval, a form, a picker)
// resolves, it calls `ctx.sendReceipt(obj)`. That receipt has to travel back to
// the assistant as the result of the tool call that produced the component.
//
// ── RECEIPT POST SHAPE ───────────────────────────────────────────────────────
//
//   POST /api/assistant            (the tool-result channel)
//   Content-Type: application/json
//   {
//     "channel":   "tool-result",  // discriminator on the assistant endpoint
//     "receipt": {
//       "toolCallId": string|null, // id of the tool call being answered
//       "component":  string,      // def.name that produced the UI
//       "action":     string,      // e.g. "approve" | "reject" | "submit"
//       "data":       any,         // arbitrary component payload
//       "ts":         number       // Date.now() when sent
//     }
//   }
//
// The endpoint contract may not be finalized, so sendReceipt does BOTH, always:
//   1. dispatches a `toolui:receipt` DOM CustomEvent (detail = the receipt), so
//      the chat core / tests can observe it with zero network, and
//   2. attempts the POST, swallowing network errors gracefully (logged, never
//      thrown — a failed POST must not crash the UI).

const RECEIPT_ENDPOINT = "/api/assistant";

// Coerce whatever a component passes into the canonical receipt envelope.
function normalizeReceipt(obj) {
  const o = obj || {};
  return {
    toolCallId: o.toolCallId ?? o.tool_call_id ?? null,
    component: o.component ?? o.name ?? "unknown",
    action: o.action ?? "submit",
    data: o.data ?? o.value ?? o,
    ts: typeof o.ts === "number" ? o.ts : Date.now(),
  };
}

// Send a receipt. Returns a Promise that resolves to { ok, status } and never
// rejects — callers (UI components) should not have to try/catch.
function sendReceipt(obj, opts) {
  const receipt = normalizeReceipt(obj);
  const endpoint = (opts && opts.endpoint) || RECEIPT_ENDPOINT;

  // 1. Always emit the DOM event first — synchronous, dependency-free, testable.
  document.dispatchEvent(
    new CustomEvent("toolui:receipt", { detail: receipt, bubbles: true })
  );
  console.log("[runtime] receipt emitted", receipt);

  // 2. Attempt the POST. fetch may be unavailable (SSR/tests) — guard it.
  if (typeof fetch !== "function") {
    return Promise.resolve({ ok: false, status: 0, posted: false });
  }
  const body = JSON.stringify({ channel: "tool-result", receipt });
  return fetch(endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
  })
    .then((res) => {
      console.log(`[runtime] receipt POST → ${res.status}`);
      return { ok: res.ok, status: res.status, posted: true };
    })
    .catch((err) => {
      // Graceful: the event already fired, so the receipt is not lost.
      console.warn("[runtime] receipt POST failed (event still emitted):", err);
      return { ok: false, status: 0, posted: false, error: String(err) };
    });
}

// Convenience: build a ctx whose sendReceipt routes through this transport.
// Component agents normally get ctx from index.js, but standalone callers can
// mint one here.
function makeReceiptCtx(extra) {
  return {
    sendReceipt,
    onAction(name, data) {
      console.log("[runtime] action:", name, data);
    },
    theme: "dark",
    ...(extra || {}),
  };
}

const ToolUiReceipts = { sendReceipt, makeReceiptCtx, normalizeReceipt, RECEIPT_ENDPOINT };
if (typeof window !== "undefined") window.ToolUiReceipts = ToolUiReceipts;

export { sendReceipt, makeReceiptCtx, normalizeReceipt, RECEIPT_ENDPOINT };
export default ToolUiReceipts;