import type { ActionConfirmation } from "./public-contracts.js"; import type { CalendlyEmbeddedBooking } from "./public-contracts.js"; import type { CalendlyScheduledEvent } from "./public-contracts.js"; import type { CalendlyBookingOffer, CalendlyBookingSlot, } from "./public-contracts.js"; import type { ChatRequest } from "./public-contracts.js"; import type { StructuredUiRequest, SubmitPlaybookFieldsRequest, } from "./playbook-contracts.js"; import type { WidgetApiClient } from "./api.js"; import { getWidgetErrorMessage, isAbortError } from "./errors.js"; import { ProgressiveText, type FrameScheduler } from "./progressive-text.js"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; export interface ConversationMessage { id: string; role: "assistant" | "user"; content: string; // Source UUIDs stay internal until the API exposes safe public metadata. // SurfaceView deliberately never renders this collection. sourceChunkIds: string[]; } export interface ExternalAction extends CalendlyEmbeddedBooking { actionId: string; } export interface ConversationState { error: string | null; isHistoryLoading: boolean; externalAction: ExternalAction | null; bookingOffer: CalendlyBookingOffer | null; selectedBookingSlot: CalendlyBookingSlot | null; isActionSubmitting: boolean; isStreaming: boolean; messages: ConversationMessage[]; pendingAction: ActionConfirmation | null; playbookRequest: StructuredUiRequest | null; feedback: "helpful" | "not_helpful" | null; } export interface ConversationControllerOptions { apiClient: WidgetApiClient; context?: string | undefined; isReducedMotion: boolean; onStateChange(state: ConversationState): void; scheduler?: FrameScheduler | undefined; sessionId: string; } /** * Owns one surface's network lifecycle and serializable presentation state. * DOM concerns remain in SurfaceView, while this class enforces single-flight * sends, cancellation, history restoration, and progressive delivery. */ export class ConversationController { private readonly options: ConversationControllerOptions; private state: ConversationState = { error: null, isHistoryLoading: true, externalAction: null, bookingOffer: null, selectedBookingSlot: null, isActionSubmitting: false, isStreaming: false, messages: [], pendingAction: null, playbookRequest: null, feedback: null, }; private abortController: AbortController | undefined; private progressiveText: ProgressiveText | undefined; private isDestroyed = false; private isExternalActionSubmitting = false; constructor(options: ConversationControllerOptions) { this.options = options; } get currentState(): ConversationState { // Never expose mutable arrays owned by the controller to UI consumers. return cloneState(this.state); } /** Restores validated server history without preventing a new text request on failure. */ async loadHistory(): Promise { const abortController = new AbortController(); this.abortController = abortController; this.publishState({ isHistoryLoading: true, error: null }); try { const history = await this.options.apiClient.getHistory( this.options.sessionId, abortController.signal, ); const messages = history.messages.flatMap( (message) => { // Future server-side roles such as tools or system messages are not // visitor-facing and must not leak into the widget transcript. if (message.role !== "user" && message.role !== "assistant") return []; const conversationMessage: ConversationMessage = { id: message.id, role: message.role, content: message.content, sourceChunkIds: message.citedChunkIds ?? [], }; return [conversationMessage]; }, ); this.publishState({ messages, externalAction: history.externalAction ?? null, playbookRequest: history.structuredUiRequest ?? null, ...(history.externalAction === null || history.externalAction === undefined ? {} : { pendingAction: null, error: null }), }); } catch (error) { if (!isAbortError(error)) { this.publishState({ error: "Non è stato possibile recuperare la conversazione precedente.", }); } } finally { if (this.abortController === abortController) { this.abortController = undefined; } this.publishState({ isHistoryLoading: false }); } } /** * Starts one turn when idle and resolves after the final character is shown, * not merely when the network stream closes. */ async sendMessage(message: string): Promise { const normalizedMessage = message.trim(); if ( normalizedMessage.length === 0 || normalizedMessage.length > 8_000 || this.state.isStreaming || this.state.externalAction !== null || this.isDestroyed ) { return false; } const assistantIndex = this.appendPendingMessages(normalizedMessage); const abortController = new AbortController(); this.abortController = abortController; this.progressiveText = new ProgressiveText({ isReducedMotion: this.options.isReducedMotion, scheduler: this.options.scheduler, onText: (content) => this.appendAssistantText(assistantIndex, content), }); try { const messageId = await this.options.apiClient.streamChat( this.createChatRequest(normalizedMessage), { onActionConfirmation: (action) => this.publishState({ pendingAction: action }), onStructuredUi: (request) => this.publishState({ playbookRequest: request }), onBookingSlots: (offer) => this.publishState({ bookingOffer: offer, selectedBookingSlot: null, }), onToken: (content) => this.progressiveText?.enqueue(content), onSources: (chunkIds) => this.updateAssistantSources(assistantIndex, chunkIds), }, abortController.signal, ); this.updateAssistantId(assistantIndex, messageId); // `done` may arrive while ProgressiveText still owns buffered characters; // keeping the busy state here prevents a new turn from interleaving. await this.progressiveText.drain(); return true; } catch (error) { this.progressiveText.cancel(); if (!this.isDestroyed) { this.publishState({ error: getWidgetErrorMessage(error) }); } return false; } finally { if (this.abortController === abortController) { this.abortController = undefined; } this.progressiveText = undefined; this.publishState({ isStreaming: false }); } } async submitPlaybookFields( step: SubmitPlaybookFieldsRequest["step"], values: SubmitPlaybookFieldsRequest["values"], ): Promise { const request = this.state.playbookRequest; if ( request === null || request.kind === "handoff_result" || this.state.isActionSubmitting || this.isDestroyed ) { return; } if (this.options.apiClient.submitPlaybookFields === undefined) { this.publishState({ error: "Questa versione non supporta il percorso guidato.", }); return; } this.publishState({ isActionSubmitting: true, error: null }); try { const response = await this.options.apiClient.submitPlaybookFields( request.runId, { sessionId: this.options.sessionId, step, values, idempotencyKey: globalThis.crypto.randomUUID(), }, ); this.publishState({ playbookRequest: response.nextUiRequest }); } catch (error) { if (!isAbortError(error)) { this.publishState({ error: getWidgetErrorMessage(error) }); } } finally { this.publishState({ isActionSubmitting: false }); } } async confirmPendingAction(): Promise { const action = this.state.pendingAction; if (action === null || this.state.isActionSubmitting || this.isDestroyed) return; this.publishState({ isActionSubmitting: true, error: null }); if (this.options.apiClient.confirmAction === undefined) { this.publishState({ isActionSubmitting: false, error: "Questa versione del widget non supporta la conferma dell’azione.", }); return; } try { const response = await this.options.apiClient.confirmAction( action.actionId, action.confirmationToken, ); if (response.status === "awaiting_external_completion") { this.publishState({ externalAction: { actionId: response.id, ...response.result, }, }); return; } this.appendActionOutcome( response.status === "succeeded", response.result, ); this.publishState({ pendingAction: null }); } catch (error) { if (!isAbortError(error)) { await this.loadHistory(); if (this.state.externalAction !== null) { this.publishState({ pendingAction: null, error: null }); } else { this.publishState({ error: getWidgetErrorMessage(error) }); } } } finally { this.publishState({ isActionSubmitting: false }); } } async cancelPendingAction(): Promise { const action = this.state.pendingAction; if (action === null || this.state.isActionSubmitting || this.isDestroyed) return; this.publishState({ isActionSubmitting: true, error: null }); if (this.options.apiClient.cancelAction === undefined) { this.publishState({ isActionSubmitting: false, error: "Questa versione del widget non supporta la conferma dell’azione.", }); return; } try { await this.options.apiClient.cancelAction(action.actionId); this.appendActionOutcome(false, undefined, true); this.publishState({ pendingAction: null }); } catch (error) { if (!isAbortError(error)) this.publishState({ error: getWidgetErrorMessage(error) }); } finally { this.publishState({ isActionSubmitting: false }); } } async completeExternalAction(event: CalendlyScheduledEvent): Promise { const action = this.state.externalAction; if ( action === null || this.isExternalActionSubmitting || this.isDestroyed ) { return; } if (this.options.apiClient.completeAction === undefined) { this.publishState({ error: "Questa versione del widget non può verificare la prenotazione.", }); return; } this.isExternalActionSubmitting = true; try { const response = await this.options.apiClient.completeAction( action.actionId, event, ); if (response.status !== "succeeded") { this.publishState({ error: "Calendly non ha ancora confermato la prenotazione.", }); return; } this.appendActionOutcome(true, response.result); this.publishState({ externalAction: null, error: null }); } catch (error) { if (!isAbortError(error)) { this.publishState({ error: getWidgetErrorMessage(error) }); } } finally { this.isExternalActionSubmitting = false; } } selectBookingSlot(slot: CalendlyBookingSlot): void { if (this.state.bookingOffer === null || this.isDestroyed) return; const isOffered = this.state.bookingOffer.availableTimes.some( ({ startTime }) => startTime === slot.startTime, ); if (isOffered) this.publishState({ selectedBookingSlot: slot, error: null }); } async prepareCalendlyBooking(details: { name: string; email: string; timezone: string; }): Promise { const slot = this.state.selectedBookingSlot; if (slot === null || this.state.isActionSubmitting || this.isDestroyed) { return; } if (this.options.apiClient.prepareCalendlyBooking === undefined) { this.publishState({ error: "Questa versione non supporta la prenotazione.", }); return; } this.publishState({ isActionSubmitting: true, error: null }); try { const response = await this.options.apiClient.prepareCalendlyBooking({ provider: this.state.bookingOffer?.provider ?? "calendly", sessionId: this.options.sessionId, ...details, startTime: slot.startTime, ...(slot.endTime === undefined ? {} : { endTime: slot.endTime }), ...(slot.schedulingUrl === undefined ? {} : { schedulingUrl: slot.schedulingUrl }), }); if (response.status === "succeeded") { this.appendActionOutcome(true, response.result); this.publishState({ bookingOffer: null, selectedBookingSlot: null, }); return; } if (response.status !== "awaiting_external_completion") { this.publishState({ error: "Non è stato possibile preparare la prenotazione.", }); return; } this.publishState({ externalAction: { actionId: response.id, ...response.result }, bookingOffer: null, selectedBookingSlot: null, }); } catch (error) { if (!isAbortError(error)) { this.publishState({ error: getWidgetErrorMessage(error) }); } } finally { this.publishState({ isActionSubmitting: false }); } } abort(): void { this.abortController?.abort(); this.progressiveText?.cancel(); } async submitFeedback(resolved: boolean): Promise { if (this.state.feedback !== null || this.isDestroyed) return; const lastAssistant = [...this.state.messages] .reverse() .find((message) => message.role === "assistant"); try { await this.options.apiClient.submitFeedback({ sessionId: this.options.sessionId, ...(lastAssistant !== undefined && UUID_PATTERN.test(lastAssistant.id) ? { messageId: lastAssistant.id } : {}), resolved, rating: resolved ? 5 : 1, }); this.publishState({ feedback: resolved ? "helpful" : "not_helpful" }); } catch (error) { if (!isAbortError(error)) { this.publishState({ error: getWidgetErrorMessage(error) }); } } } destroy(): void { this.isDestroyed = true; this.abort(); } private appendPendingMessages(content: string): number { // Create the empty assistant turn before opening the stream so every token // has a stable target and the UI can announce a loading state immediately. const userMessage: ConversationMessage = { id: `user-${this.state.messages.length}`, role: "user", content, sourceChunkIds: [], }; const assistantMessage: ConversationMessage = { id: `assistant-${this.state.messages.length + 1}`, role: "assistant", content: "", sourceChunkIds: [], }; const messages = [...this.state.messages, userMessage, assistantMessage]; this.publishState({ messages, isStreaming: true, error: null, feedback: null, }); return messages.length - 1; } private createChatRequest(message: string): ChatRequest { const request: ChatRequest = { sessionId: this.options.sessionId, message, clientCapabilities: ["interactive_booking", "structured_playbooks"], }; if (this.options.context === undefined) { return request; } return { ...request, context: this.options.context, }; } private appendAssistantText(index: number, content: string): void { const messages = updateMessage(this.state.messages, index, (message) => ({ ...message, content: message.content + content, })); this.publishState({ messages }); } private updateAssistantSources( index: number, sourceChunkIds: string[], ): void { const messages = updateMessage(this.state.messages, index, (message) => ({ ...message, sourceChunkIds: [...sourceChunkIds], })); this.publishState({ messages }); } private updateAssistantId(index: number, id: string): void { const messages = updateMessage(this.state.messages, index, (message) => ({ ...message, id, })); this.publishState({ messages }); } private appendActionOutcome( succeeded: boolean, result?: Record, cancelled = false, ): void { const content = cancelled ? "Azione annullata. Non è stata effettuata alcuna modifica." : succeeded ? formatSuccessfulAction(result) : "Non è stato possibile completare l’azione richiesta."; this.publishState({ messages: [ ...this.state.messages, { id: `action-${this.state.messages.length}`, role: "assistant", content, sourceChunkIds: [], }, ], }); } private publishState(update: Partial): void { if (this.isDestroyed) return; this.state = { ...this.state, ...update }; // A cloned snapshot prevents view code or callbacks from mutating the // controller's source UUIDs and message ordering by reference. this.options.onStateChange(cloneState(this.state)); } } function formatSuccessfulAction(result?: Record): string { const rescheduleUrl = result?.rescheduleUrl; const cancelUrl = result?.cancelUrl; if (typeof rescheduleUrl === "string") { const cancelLink = typeof cancelUrl === "string" ? ` · [Annulla appuntamento](${cancelUrl})` : ""; return `Appuntamento Calendly prenotato correttamente. [Gestisci appuntamento](${rescheduleUrl})${cancelLink}`; } const meetUrl = result?.meetUrl; if (typeof meetUrl === "string") { return `Appuntamento creato correttamente. [Apri Google Meet](${meetUrl})`; } return "Azione completata correttamente."; } function updateMessage( messages: ConversationMessage[], index: number, update: (message: ConversationMessage) => ConversationMessage, ): ConversationMessage[] { return messages.map((message, messageIndex) => messageIndex === index ? update(message) : message, ); } function cloneState(state: ConversationState): ConversationState { return { ...state, externalAction: state.externalAction === null ? null : { ...state.externalAction }, bookingOffer: state.bookingOffer === null ? null : { ...state.bookingOffer, availableTimes: state.bookingOffer.availableTimes.map((slot) => ({ ...slot, })), }, selectedBookingSlot: state.selectedBookingSlot === null ? null : { ...state.selectedBookingSlot }, playbookRequest: state.playbookRequest === null ? null : structuredClone(state.playbookRequest), pendingAction: state.pendingAction === null ? null : { ...state.pendingAction }, messages: state.messages.map((message) => ({ ...message, sourceChunkIds: [...message.sourceChunkIds], })), }; }