UNPKG

@yext/chat-headless

Version:

A state manager library powered by Redux for Yext Chat integrations

423 lines (420 loc) 15.6 kB
import { provideChatCore, MessageSource, StreamEventName, ApiError } from '@yext/chat-core'; import { ReduxStateManager } from './ReduxStateManager.mjs'; import { loadSessionState, saveSessionState, setMessages, addMessage, setMessageNotes, setIsLoading, setCanSendMessage, setConversationId } from './slices/conversation.mjs'; import { setContext } from './slices/meta.mjs'; import { analytics } from '@yext/analytics'; import { getClientSdk } from './utils/clientSdk.mjs'; import { isChatEventClient } from './models/clients/ChatEventClient.mjs'; const BASE_HANDOFF_CREDENTIALS_SESSION_STORAGE_KEY = "yext_chat_handoff_credentials"; /** * Concrete implementation of {@link ChatHeadless} * * @internal */ class ChatHeadlessImpl { config; chatClient; botClient; clients; stateManager; analyticsService; credentialsSessionStorageKey; 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, botClient, agentClient) { const defaultConfig = { 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(); } } get sessionAgentCredentials() { 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}`); } } } } setSessionAgentCredentials(credentials) { if (this.credentialsSessionStorageKey) { sessionStorage.setItem(this.credentialsSessionStorageKey, JSON.stringify(credentials)); } } removeSessionAgentCredentials() { if (this.credentialsSessionStorageKey) { sessionStorage.removeItem(this.credentialsSessionStorageKey); } } get state() { return this.stateManager.getState(); } setState(state) { this.stateManager.dispatch({ type: "set-state", payload: state, }); } get store() { return this.stateManager.getStore(); } /** * Sets up event listeners to update state for event-driven clients. */ setClientEventListeners() { this.clients.forEach((client) => { if (!client || !isChatEventClient(client)) { return; } client.on("message", (data) => { this.addMessage({ source: client === this.botClient ? MessageSource.BOT : MessageSource.AGENT, text: data, timestamp: new Date().toISOString(), }); }); client.on("typing", (data) => { this.setChatLoadingStatus(data); }); client.on("close", async (_data) => { this.handoff(); }); }); } /** * Switches the current chat client with the next client, if available. */ async handoff(integrationDetails) { if (isChatEventClient(this.chatClient)) { this.removeSessionAgentCredentials(); } let nextClient = 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; } async reinitializeAgentSession(client) { this.setCanSendMessage(false); await client.reinitializeSession(this.sessionAgentCredentials); this.setCanSendMessage(true); } addClientSdk(additionalClientSdk) { 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) { if (eventPayload.action === "CHAT_IMPRESSION") { if (this.isImpressionAnalyticEventSent) { return; } this.isImpressionAnalyticEventSent = true; } const chatProps = { 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) { this.stateManager.dispatch(setContext(context)); } setMessages(messages) { this.stateManager.dispatch(setMessages(messages)); } addMessage(message) { this.stateManager.dispatch(addMessage(message)); } setMessageNotes(notes) { this.stateManager.dispatch(setMessageNotes(notes)); } setChatLoadingStatus(isLoading) { this.stateManager.dispatch(setIsLoading(isLoading)); } setCanSendMessage(canSendMessage) { this.stateManager.dispatch(setCanSendMessage(canSendMessage)); } /** * Sets {@link ConversationState.conversationId} to the specified id * * @internal * * @param id - the id to set */ setConversationId(id) { 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(listener) { return this.stateManager.addListener(listener); } async getNextMessage(text, source = MessageSource.USER) { 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, source = MessageSource.USER) { const client = this.chatClient; if (isChatEventClient(client)) { throw new Error("streamNextMessage is not supported by this client."); } return this.nextMessageHandler(async () => { let messageResponse = undefined; let nextMessage = { 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 */ async nextMessageHandler(nextMessageFn, text, source = MessageSource.USER) { 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 = 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; } } export { ChatHeadlessImpl }; //# sourceMappingURL=ChatHeadlessImpl.mjs.map