UNPKG

@yext/chat-headless

Version:

A state manager library powered by Redux for Yext Chat integrations

542 lines (499 loc) 15.3 kB
import { provideChatCore, Message, MessageNotes, MessageResponse, MessageSource, StreamEventName, ApiError, IntegrationDetails, } from "@yext/chat-core"; import { ChatClient, ChatHeadless, HeadlessConfig, State, StateListener, } from "./models"; import { ReduxStateManager } from "./ReduxStateManager"; import { loadSessionState, saveSessionState, setCanSendMessage, setConversationId, setIsLoading, setMessageNotes, setMessages, addMessage, } from "./slices/conversation"; import { Store, Unsubscribe } from "@reduxjs/toolkit"; import { setContext } from "./slices/meta"; import { analytics, AnalyticsEventService, EventPayload, } from "@yext/analytics"; import { getClientSdk } from "./utils/clientSdk"; import { ChatEventClient, isChatEventClient, } from "./models/clients/ChatEventClient"; import { RecursivePartial } from "./models/ChatHeadless"; const BASE_HANDOFF_CREDENTIALS_SESSION_STORAGE_KEY = "yext_chat_handoff_credentials"; /** * Concrete implementation of {@link ChatHeadless} * * @internal */ export class ChatHeadlessImpl implements ChatHeadless { private config: HeadlessConfig; private chatClient: ChatClient; private botClient: ChatClient; private clients: ChatClient[]; private stateManager: ReduxStateManager; private analyticsService: AnalyticsEventService; private credentialsSessionStorageKey: string | undefined; private isImpressionAnalyticEventSent = false; /** * Constructs a new instance of the {@link ChatHeadlessImpl} class. * * @internal * * @param config - The configuration for the {@link ChatHeadlessImpl} instance * @param chatClient - An optional override for the default {@link ChatClient} instance */ constructor( config: HeadlessConfig, botClient?: ChatClient, agentClient?: ChatClient ) { const defaultConfig: Partial<HeadlessConfig> = { saveToLocalStorage: true, }; this.config = { ...defaultConfig, ...config }; // bot client is the default client. // If agent client is provided, it will be used as the second client on handoff this.chatClient = botClient ?? provideChatCore(this.config); this.botClient = this.chatClient; this.clients = [this.chatClient]; if (agentClient) { this.clients.push(agentClient); } this.setClientEventListeners(); this.stateManager = new ReduxStateManager(); this.analyticsService = analytics({ authorizationType: 'apiKey', authorization: this.config.apiKey, env: this.config.env, region: this.config.region, ...this.config.analyticsConfig, }); if (this.config.saveToLocalStorage) { this.initLocalStorage(); } } private get sessionAgentCredentials(): unknown | void { if (this.credentialsSessionStorageKey) { const credentials = sessionStorage.getItem( this.credentialsSessionStorageKey ); if (credentials) { try { return JSON.parse(credentials); } catch (e) { this.removeSessionAgentCredentials(); throw new Error( `Error occurred while parsing credentials from session storage: ${e}` ); } } } } private setSessionAgentCredentials(credentials: unknown) { if (this.credentialsSessionStorageKey) { sessionStorage.setItem( this.credentialsSessionStorageKey, JSON.stringify(credentials) ); } } private removeSessionAgentCredentials() { if (this.credentialsSessionStorageKey) { sessionStorage.removeItem(this.credentialsSessionStorageKey); } } get state(): State { return this.stateManager.getState(); } setState(state: State): void { this.stateManager.dispatch({ type: "set-state", payload: state, }); } get store(): Store { return this.stateManager.getStore(); } /** * Sets up event listeners to update state for event-driven clients. */ private setClientEventListeners() { this.clients.forEach((client) => { if (!client || !isChatEventClient(client)) { return; } client.on("message", (data: string) => { this.addMessage({ source: client === this.botClient ? MessageSource.BOT : MessageSource.AGENT, text: data, timestamp: new Date().toISOString(), }); }); client.on("typing", (data: boolean) => { this.setChatLoadingStatus(data); }); client.on("close", async (_data: unknown) => { this.handoff(); }); }); } /** * Switches the current chat client with the next client, if available. */ private async handoff(integrationDetails?: IntegrationDetails) { if (isChatEventClient(this.chatClient)) { this.removeSessionAgentCredentials(); } let nextClient: ChatClient | undefined = undefined; for (const client of this.clients) { if (this.chatClient !== client) { nextClient = client; } } if (!nextClient) { console.warn("No next client available for handoff."); return; } if (isChatEventClient(nextClient)) { try { if (this.sessionAgentCredentials) { await this.reinitializeAgentSession(nextClient); } else { const creds = await nextClient.init({ conversationId: this.state.conversation.conversationId, message: this.state.conversation.messages.at(-1) || { source: MessageSource.BOT, text: "", }, notes: this.state.conversation.notes || {}, integrationDetails, }); this.setSessionAgentCredentials(creds); } } catch (e) { console.error("Error occurred while initializing next client:", e); return; } } this.chatClient = nextClient; } private async reinitializeAgentSession(client: ChatEventClient) { this.setCanSendMessage(false); await client.reinitializeSession(this.sessionAgentCredentials); this.setCanSendMessage(true); } addClientSdk(additionalClientSdk: Record<string, string>) { const { analyticsConfig } = this.config; this.config.analyticsConfig = { ...analyticsConfig, baseEventPayload: { ...analyticsConfig?.baseEventPayload, clientSdk: { ...analyticsConfig?.baseEventPayload?.clientSdk, ...additionalClientSdk, }, }, }; } initLocalStorage() { const hostname = window.location.hostname; if (!hostname) { console.warn( "Unable to get hostname of current page. State will not be persisted while navigating across pages." ); return; } if (!localStorage) { console.warn( "Local storage is not available. State will not be persisted while navigating across pages." ); return; } this.setState({ ...this.state, conversation: loadSessionState(this.config.botId), }); this.addListener({ valueAccessor: (s) => s.conversation, callback: () => saveSessionState(this.config.botId, this.state.conversation), }); if (!sessionStorage) { console.warn( "Session storage is not available. State will not be persisted while navigating across pages." ); return; } this.credentialsSessionStorageKey = `${BASE_HANDOFF_CREDENTIALS_SESSION_STORAGE_KEY}__${hostname}__${this.config.botId}`; try { const credentials = this.sessionAgentCredentials; if (credentials) { this.handoff(); } } catch (e) { console.error( "Error occurred while initializing agent session using stored credentials:", e ); } } async report( eventPayload: Omit<EventPayload, "chat"> & RecursivePartial<Pick<EventPayload, "chat">> ) { if (eventPayload.action === "CHAT_IMPRESSION") { if (this.isImpressionAnalyticEventSent) { return; } this.isImpressionAnalyticEventSent = true; } const chatProps: EventPayload["chat"] = { botId: this.config.botId, conversationId: this.state.conversation.conversationId, }; const baseEventPayload = this.config.analyticsConfig?.baseEventPayload; try { await this.analyticsService.report({ timestamp: new Date().toISOString(), pageUrl: window?.location.href || undefined, referrerUrl: window?.document.referrer || undefined, ...baseEventPayload, ...eventPayload, clientSdk: getClientSdk({ ...baseEventPayload?.clientSdk, ...eventPayload.clientSdk, }), chat: { ...chatProps, ...baseEventPayload?.chat, ...eventPayload.chat, }, }); } catch (e) { console.error("Error occured on request to Analytics API:", e); } } setContext(context: unknown) { this.stateManager.dispatch(setContext(context)); } setMessages(messages: Message[]) { this.stateManager.dispatch(setMessages(messages)); } addMessage(message: Message) { this.stateManager.dispatch(addMessage(message)); } setMessageNotes(notes: MessageNotes) { this.stateManager.dispatch(setMessageNotes(notes)); } setChatLoadingStatus(isLoading: boolean) { this.stateManager.dispatch(setIsLoading(isLoading)); } setCanSendMessage(canSendMessage: boolean) { this.stateManager.dispatch(setCanSendMessage(canSendMessage)); } /** * Sets {@link ConversationState.conversationId} to the specified id * * @internal * * @param id - the id to set */ private setConversationId(id: string | undefined) { this.stateManager.dispatch(setConversationId(id)); } restartConversation() { if (isChatEventClient(this.chatClient)) { this.removeSessionAgentCredentials(); this.chatClient.resetSession(); } this.chatClient = this.botClient; this.setConversationId(undefined); this.setChatLoadingStatus(false); this.setCanSendMessage(true); this.setMessageNotes({}); this.setMessages([]); } addListener<T>(listener: StateListener<T>): Unsubscribe { return this.stateManager.addListener<T>(listener); } async getNextMessage( text?: string, source: MessageSource = MessageSource.USER ): Promise<MessageResponse | undefined> { const client = this.chatClient; if (isChatEventClient(client)) { const { conversationId, notes } = this.state.conversation; if (text && text.length > 0) { this.addMessage({ timestamp: new Date().toISOString(), source, text, }); } client.processMessage({ conversationId, notes, messages: this.state.conversation.messages, context: this.state.meta.context, }); return; } return this.nextMessageHandler( async () => { const { messages, conversationId, notes } = this.state.conversation; const nextMessage = await client.getNextMessage({ conversationId, messages, notes, context: this.state.meta.context, }); this.setConversationId(nextMessage.conversationId); this.addMessage(nextMessage.message); this.setMessageNotes(nextMessage.notes); return nextMessage; }, text, source ); } async streamNextMessage( text?: string, source: MessageSource = MessageSource.USER ): Promise<MessageResponse | undefined> { const client = this.chatClient; if (isChatEventClient(client)) { throw new Error("streamNextMessage is not supported by this client."); } return this.nextMessageHandler( async () => { let messageResponse: MessageResponse | undefined = undefined; let nextMessage: Message = { source: MessageSource.BOT, text: "", }; const { messages, conversationId, notes } = this.state.conversation; const stream = await client.streamNextMessage({ conversationId, messages, notes, context: this.state.meta.context, }); stream.addEventListener(StreamEventName.StartEvent, ({ data }) => { this.setChatLoadingStatus(false); this.setMessageNotes(data); }); stream.addEventListener( StreamEventName.TokenStreamEvent, ({ data }) => { nextMessage = { ...nextMessage, text: nextMessage.text + data.token, }; this.setMessages([...messages, nextMessage]); } ); stream.addEventListener(StreamEventName.EndEvent, ({ data }) => { this.setConversationId(data.conversationId); this.setMessages([...messages, data.message]); messageResponse = data; }); await stream.consume(); if (!messageResponse) { return Promise.reject( new ApiError( "Stream Error: Missing full message response at the end of stream." ) ); } return messageResponse; }, text, source ); } /** * Setup relevant state before hitting Chat API endpoint for next message, such as * setting loading status, "canSendMessage" status, and appending new user's message * in conversation state. * * @remarks * If the response contains integration details, it will trigger a handoff to the next client. * * @internal * * @param nextMessageFn - function to invoke to get next message * @param text - the text of the next message * @param source - the source of the message * @returns a Promise of a response from the Chat API */ private async nextMessageHandler( nextMessageFn: () => Promise<MessageResponse>, text?: string, source: MessageSource = MessageSource.USER ): Promise<MessageResponse | undefined> { if (!this.state.conversation.canSendMessage) { console.warn( "Unable to process new message at the moment. Another message is still being processed." ); return; } this.setCanSendMessage(false); this.setChatLoadingStatus(true); let messages: Message[] = this.state.conversation.messages; if (text && text.length > 0) { messages = [ ...messages, { timestamp: new Date().toISOString(), source, text, }, ]; this.setMessages(messages); } let messageResponse; try { messageResponse = await nextMessageFn(); } catch (e) { this.setCanSendMessage(true); this.setChatLoadingStatus(false); return Promise.reject(e); } await this.report({ action: "CHAT_RESPONSE", timestamp: messageResponse.message.timestamp, chat: { conversationId: messageResponse.conversationId, responseId: messageResponse.message.responseId, }, }); this.setCanSendMessage(true); this.setChatLoadingStatus(false); if (!!messageResponse.integrationDetails) { await this.handoff(messageResponse.integrationDetails); } return messageResponse; } }