disclosure-bureau/web/lib/chat/agui.ts

66 lines
2.1 KiB
TypeScript
Raw Normal View History

/**
* AG-UI-style SSE event helpers.
*
* We don't implement the full AG-UI protocol we use a simplified event set
* that maps cleanly to our chat UX:
*
* text_delta append text to the current assistant message
* tool_start model is calling a tool (renders collapsible block)
* tool_result local handler finished (fills the block)
* navigate render a clickable navigation button inline
* done terminal event; carries usage + final assistant message id
* error terminal event; carries error detail
*
* Each is emitted as one SSE message:
*
* event: <name>
* data: <json>
*
* <blank line>
*/
export type AGUIEvent =
| { type: "text_delta"; delta: string }
| { type: "tool_start"; id: string; name: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; result: unknown; durationMs?: number }
| { type: "navigate"; target: string; label: string }
| { type: "done"; provider: string; model: string; usage?: Record<string, unknown>; messageId?: string }
| { type: "error"; message: string };
/**
* Encode an event into the byte-stream chunks expected by the SSE protocol.
*/
export function encodeEvent(ev: AGUIEvent): Uint8Array {
const enc = new TextEncoder();
const json = JSON.stringify(ev);
return enc.encode(`event: ${ev.type}\ndata: ${json}\n\n`);
}
/**
* Helper that creates a ReadableStream + a typed `emit()` callback to push
* events into it. Caller closes the stream by calling `emit({type:"done"})` or
* `close()`.
*/
export function createEventStream(): {
stream: ReadableStream<Uint8Array>;
emit: (ev: AGUIEvent) => void;
close: () => void;
} {
let controller!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
});
return {
stream,
emit(ev) {
try {
controller.enqueue(encodeEvent(ev));
} catch { /* stream closed */ }
},
close() {
try { controller.close(); } catch { /* already closed */ }
},
};
}