import { parseCalendlyEmbeddedBooking, parseChatHistory, parseWidgetConfig, } from "./contracts.js"; import { mapHttpError, WidgetError } from "./errors.js"; import { parseSubmitPlaybookFieldsResponse } from "./playbook-contract-parser.js"; import type { ActionConfirmation, CalendlyBookingOffer, ChatHistoryResponse, ChatRequest, CalendlyScheduledEvent, ConversationFeedbackRequest, PrepareCalendlyBookingRequest, IntegrationActionResponse, WidgetConfigResponse, } from "./public-contracts.js"; import type { StructuredUiRequest, SubmitPlaybookFieldsRequest, SubmitPlaybookFieldsResponse, } from "./playbook-contracts.js"; import { parseSseStream } from "./sse.js"; export const DEFAULT_API_BASE_URL = "https://api.akintu.io"; export interface ChatStreamHandlers { onActionConfirmation?(action: ActionConfirmation): void; onBookingSlots?(offer: CalendlyBookingOffer): void; onStructuredUi?(request: StructuredUiRequest): void; onSources(chunkIds: string[]): void; onToken(content: string): void; } export interface WidgetApiClientOptions { apiKey: string; apiBaseUrl?: string | undefined; fetchImplementation?: typeof fetch | undefined; } export interface WidgetApiClient { getConfig(signal?: AbortSignal): Promise; getHistory( sessionId: string, signal?: AbortSignal, ): Promise; streamChat( input: ChatRequest, handlers: ChatStreamHandlers, signal?: AbortSignal, ): Promise; confirmAction?( actionId: string, confirmationToken: string, ): Promise; completeAction?( actionId: string, event: CalendlyScheduledEvent, ): Promise; cancelAction?(actionId: string): Promise; prepareCalendlyBooking?( input: PrepareCalendlyBookingRequest, ): Promise; submitPlaybookFields?( runId: string, input: SubmitPlaybookFieldsRequest, ): Promise; submitFeedback(input: ConversationFeedbackRequest): Promise; } /** * Creates the only network boundary used by Embed and Inline. The raw key is * captured in this closure and is never copied into component attributes or * persistent storage. */ export function createWidgetApiClient( options: WidgetApiClientOptions, ): WidgetApiClient { const fetchImplementation = options.fetchImplementation ?? globalThis.fetch; const baseUrl = options.apiBaseUrl ?? DEFAULT_API_BASE_URL; return { async getConfig(signal) { const request: RequestInit = { headers: createWidgetHeaders(options.apiKey), }; if (signal !== undefined) request.signal = signal; const response = await fetchImplementation( createApiUrl(baseUrl, "/api/widget-config"), request, ); ensureSuccessfulResponse(response); return parseWidgetConfig(await readJson(response)); }, async getHistory(sessionId, signal) { const path = `/api/chat/history/${encodeURIComponent(sessionId)}`; const request: RequestInit = { headers: createWidgetHeaders(options.apiKey), }; if (signal !== undefined) request.signal = signal; const response = await fetchImplementation( createApiUrl(baseUrl, path), request, ); ensureSuccessfulResponse(response); return parseChatHistory(await readJson(response)); }, async streamChat(input, handlers, signal) { const request: RequestInit = { method: "POST", headers: { ...createWidgetHeaders(options.apiKey), "Content-Type": "application/json", }, body: JSON.stringify(input), }; if (signal !== undefined) request.signal = signal; const response = await fetchImplementation( createApiUrl(baseUrl, "/api/chat"), request, ); ensureSuccessfulResponse(response); if (response.body === null) throw new WidgetError("stream_interrupted"); // A closed connection is successful only after `done`. This distinguishes // a complete answer from a network interruption after the last token. let messageId: string | undefined; for await (const event of parseSseStream(response.body)) { switch (event.type) { case "token": handlers.onToken(event.content); break; case "sources": handlers.onSources(event.chunkIds); break; case "action_confirmation": handlers.onActionConfirmation?.(event); break; case "booking_slots": handlers.onBookingSlots?.(event.offer); break; case "structured_ui": handlers.onStructuredUi?.(event.request); break; case "done": messageId = event.messageId; break; case "error": throw new WidgetError("stream_error"); } } if (messageId === undefined) throw new WidgetError("stream_interrupted"); return messageId; }, async confirmAction(actionId, confirmationToken) { const response = await fetchImplementation( createApiUrl( baseUrl, `/api/chat/actions/${encodeURIComponent(actionId)}/confirm`, ), { method: "POST", headers: { ...createWidgetHeaders(options.apiKey), "Content-Type": "application/json", }, body: JSON.stringify({ confirmationToken }), }, ); ensureSuccessfulResponse(response); return parseIntegrationActionResponse(await readJson(response)); }, async completeAction(actionId, event) { const response = await fetchImplementation( createApiUrl( baseUrl, `/api/chat/actions/${encodeURIComponent(actionId)}/complete`, ), { method: "POST", headers: { ...createWidgetHeaders(options.apiKey), "Content-Type": "application/json", }, body: JSON.stringify({ externalResult: event }), }, ); ensureSuccessfulResponse(response); return parseIntegrationActionResponse(await readJson(response)); }, async cancelAction(actionId) { const response = await fetchImplementation( createApiUrl( baseUrl, `/api/chat/actions/${encodeURIComponent(actionId)}/cancel`, ), { method: "POST", headers: createWidgetHeaders(options.apiKey) }, ); ensureSuccessfulResponse(response); }, async prepareCalendlyBooking(input) { const response = await fetchImplementation( createApiUrl(baseUrl, "/api/chat/bookings"), { method: "POST", headers: { ...createWidgetHeaders(options.apiKey), "Content-Type": "application/json", }, body: JSON.stringify(input), }, ); ensureSuccessfulResponse(response); return parseIntegrationActionResponse(await readJson(response)); }, async submitPlaybookFields(runId, input) { const path = `/api/chat/playbooks/${encodeURIComponent(runId)}/fields`; const response = await fetchImplementation(createApiUrl(baseUrl, path), { method: "POST", headers: { ...createWidgetHeaders(options.apiKey), "Content-Type": "application/json", }, body: JSON.stringify(input), }); ensureSuccessfulResponse(response); return parseSubmitPlaybookFieldsResponse(await readJson(response)); }, async submitFeedback(input) { const response = await fetchImplementation( createApiUrl(baseUrl, "/api/chat/feedback"), { method: "POST", headers: { ...createWidgetHeaders(options.apiKey), "Content-Type": "application/json", }, body: JSON.stringify(input), }, ); ensureSuccessfulResponse(response); }, }; } function parseIntegrationActionResponse( value: unknown, ): IntegrationActionResponse { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new WidgetError("invalid_response"); } const response = value as Record; if ( typeof response.id !== "string" || typeof response.toolName !== "string" || (response.status !== "succeeded" && response.status !== "failed" && response.status !== "awaiting_external_completion") || typeof response.summary !== "string" ) { throw new WidgetError("invalid_response"); } const result = response.result; if (response.status === "awaiting_external_completion") { return { id: response.id, toolName: response.toolName, status: response.status, summary: response.summary, result: parseCalendlyEmbeddedBooking(result), }; } return { id: response.id, toolName: response.toolName, status: response.status, summary: response.summary, ...(typeof result === "object" && result !== null && !Array.isArray(result) ? { result: result as Record } : {}), }; } function createApiUrl(baseUrl: string, path: string): string { return new URL(path, `${baseUrl.replace(/\/$/, "")}/`).toString(); } // Browsers own the Origin header. Setting it manually would trigger invalid // CORS behavior and could not prove the actual embedding origin. function createWidgetHeaders(apiKey: string): Record { return { Accept: "application/json, text/event-stream", "X-Client-Key": apiKey, }; } function ensureSuccessfulResponse(response: Response): void { if (!response.ok) throw mapHttpError(response.status); } async function readJson(response: Response): Promise { try { return (await response.json()) as unknown; } catch { throw new WidgetError("invalid_response", { status: response.status }); } }