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;