UNPKG

@yext/chat-headless

Version:

A state manager library powered by Redux for Yext Chat integrations

425 lines (421 loc) 15.8 kB
'use strict'; var chatCore = require('@yext/chat-core'); var ReduxStateManager = require('./ReduxStateManager.js'); var conversation = require('./slices/conversation.js'); var meta = require('./slices/meta.js'); var analytics = require('@yext/analytics'); var clientSdk = require('./utils/clientSdk.js'); var ChatEventClient = require('./models/clients/ChatEventClient.js'); 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 ?? chatCore.provideChatCore(this.config); this.botClient = this.chatClient; this.clients = [this.chatClient]; if (agentClient) { this.clients.push(agentClient); } this.setClientEventListeners(); this.stateManager = new ReduxStateManager.ReduxStateManager(); this.analyticsService = analytics.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 || !ChatEventClient.isChatEventClient(client)) { return; } client.on("message", (data) => { this.addMessage({ source: client === this.botClient ? chatCore.MessageSource.BOT : chatCore.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 (ChatEventClient.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 (ChatEventClient.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: chatCore.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: conversation.loadSessionState(this.config.botId), }); this.addListener({ valueAccessor: (s) => s.conversation, callback: () => conversation.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: 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(meta.setContext(context)); } setMessages(messages) { this.stateManager.dispatch(conversation.setMessages(messages)); } addMessage(message) { this.stateManager.dispatch(conversation.addMessage(message)); } setMessageNotes(notes) { this.stateManager.dispatch(conversation.setMessageNotes(notes)); } setChatLoadingStatus(isLoading) { this.stateManager.dispatch(conversation.setIsLoading(isLoading)); } setCanSendMessage(canSendMessage) { this.stateManager.dispatch(conversation.setCanSendMessage(canSendMessage)); } /** * Sets {@link ConversationState.conversationId} to the specified id * * @internal * * @param id - the id to set */ setConversationId(id) { this.stateManager.dispatch(conversation.setConversationId(id)); } restartConversation() { if (ChatEventClient.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 = chatCore.MessageSource.USER) { const client = this.chatClient; if (ChatEventClient.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 = chatCore.MessageSource.USER) { const client = this.chatClient; if (ChatEventClient.isChatEventClient(client)) { throw new Error("streamNextMessage is not supported by this client."); } return this.nextMessageHandler(async () => { let messageResponse = undefined; let nextMessage = { source: chatCore.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(chatCore.StreamEventName.StartEvent, ({ data }) => { this.setChatLoadingStatus(false); this.setMessageNotes(data); }); stream.addEventListener(chatCore.StreamEventName.TokenStreamEvent, ({ data }) => { nextMessage = { ...nextMessage, text: nextMessage.text + data.token, }; this.setMessages([...messages, nextMessage]); }); stream.addEventListener(chatCore.StreamEventName.EndEvent, ({ data }) => { this.setConversationId(data.conversationId); this.setMessages([...messages, data.message]); messageResponse = data; }); await stream.consume(); if (!messageResponse) { return Promise.reject(new chatCore.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 = chatCore.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; } } exports.ChatHeadlessImpl = ChatHeadlessImpl; //# sourceMappingURL=ChatHeadlessImpl.js.map