@yext/chat-headless
Version:
A state manager library powered by Redux for Yext Chat integrations
425 lines (421 loc) • 15.8 kB
JavaScript
'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