From db9de7ab3bcd1cb2b6acc0d2a964d7db0f7f9a3f Mon Sep 17 00:00:00 2001 From: David Scheier Date: Thu, 6 Mar 2025 14:31:41 +0100 Subject: [PATCH 1/9] added finishReason to messages --- src/common/interfaces/message.ts | 1 + src/webchat/store/messages/message-reducer.ts | 41 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/common/interfaces/message.ts b/src/common/interfaces/message.ts index 32e7cea4..ff9cfc2a 100644 --- a/src/common/interfaces/message.ts +++ b/src/common/interfaces/message.ts @@ -33,6 +33,7 @@ export interface IStreamingMessage extends IBaseMessage { traceId: string; id?: string; animationState?: "start" | "animating" | "done" | "exited"; + finishReason?: string; } export type IMessage = diff --git a/src/webchat/store/messages/message-reducer.ts b/src/webchat/store/messages/message-reducer.ts index b6cec4c0..49094f0c 100644 --- a/src/webchat/store/messages/message-reducer.ts +++ b/src/webchat/store/messages/message-reducer.ts @@ -32,6 +32,7 @@ export type SetMessageAnimatedAction = ReturnType; interface CognigyData { _messageId?: string; + _finishReason?: string; } // Helper to get message ID from message @@ -39,6 +40,12 @@ const getMessageId = (message: IMessage) => { return (message.data?._cognigy as CognigyData)?._messageId; }; +// Helper to get finishReason from message +// If there is no messageId, we are not streaming, so the message is always finished and finishReason is "stop" +const getFinishReason = (message: IMessage, messageId?: string) => { + return messageId ? (message.data?._cognigy as CognigyData)?._finishReason : "stop"; +}; + // slice of the store state that contains the info about streaming mode, to avoid circular dependency type ConfigState = { settings?: { @@ -73,27 +80,25 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => return [...state, newMessage]; } + let newMessageId = getMessageId(newMessage); + // If message doesn't have text (e.g. Text with Quick Replies), still add an ID and animationState for enabling the animation. - if (!newMessage.text) { + // if there is a messageId, it means the message was a streaming message that was finished and will be handled further below + if (!newMessage.text && !newMessageId) { return [ ...state, { ...newMessage, id: generateRandomId(), animationState: "start", + finishReason: "stop" }, ]; } - let newMessageId = getMessageId(newMessage); - - if (!newMessageId) { - newMessageId = generateRandomId(); - } - // Find existing message with same ID if we're collating outputs let messageIndex = -1; - if (isOutputCollationEnabled) { + if (isOutputCollationEnabled && newMessageId) { messageIndex = state.findIndex(msg => { if ("text" in msg) { const msgId = getMessageId(msg as IMessage); @@ -105,6 +110,12 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => }); } + const finishReason = getFinishReason(newMessage, newMessageId); + + if (!newMessageId) { + newMessageId = generateRandomId(); + } + // If no matching message, create new with array if (messageIndex === -1) { // break string into chunks on new lines so that markdown is evaluated while a long text is animated @@ -119,6 +130,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => text: textChunks, id: newMessageId, animationState: "start", + finishReason, }, ]; } @@ -127,12 +139,24 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => const existingMessage = state[messageIndex] as IStreamingMessage; const newState = [...state]; + // if there is a finishReason, only add the finishReason to the streaming message + if (finishReason) { + newState[messageIndex] = { + ...existingMessage, + finishReason, + }; + return newState; + } + // Convert existing text to array if needed const existingText = Array.isArray(existingMessage.text) ? existingMessage.text : [existingMessage.text]; + // reset animation state let nextAnimationState: IStreamingMessage["animationState"] = "start"; + + // if the message was exited, keep it exited if (existingMessage.animationState === "exited") { nextAnimationState = "exited"; } @@ -142,6 +166,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => ...existingMessage, text: [...existingText, newMessage.text as string], animationState: nextAnimationState, + finishReason, } as IMessage; return newState; From 75a4014b1245bcb30240df6edda9d2300a6b3b99 Mon Sep 17 00:00:00 2001 From: David Scheier Date: Fri, 14 Mar 2025 16:10:11 +0100 Subject: [PATCH 2/9] WIP on bug/93503-improve-progressive-rendering --- src/webchat-ui/components/WebchatUI.tsx | 4 +- src/webchat/components/ConnectedWebchatUI.tsx | 116 ++++++++++-------- .../store/autoinject/autoinject-middleware.ts | 2 +- .../store/messages/message-middleware.ts | 2 +- src/webchat/store/messages/message-reducer.ts | 97 ++++++++++----- src/webchat/store/reducer.ts | 18 ++- 6 files changed, 142 insertions(+), 97 deletions(-) diff --git a/src/webchat-ui/components/WebchatUI.tsx b/src/webchat-ui/components/WebchatUI.tsx index 97ffe4e5..b15aa737 100644 --- a/src/webchat-ui/components/WebchatUI.tsx +++ b/src/webchat-ui/components/WebchatUI.tsx @@ -1,5 +1,6 @@ import React from "react"; import { IMessage, IStreamingMessage } from "../../common/interfaces/message"; +import { IMessageEvent } from "../../common/interfaces/event"; import Header from "./presentational/Header"; import { CacheProvider, ThemeProvider } from "@emotion/react"; import styled from "@emotion/styled"; @@ -71,7 +72,8 @@ import { isValidMarkdown, removeMarkdownChars } from "../../webchat/helper/handl export interface WebchatUIProps { currentSession: string; - messages: IMessage[]; + messages: (IMessage | IMessageEvent)[]; + visibleOutputMessages: string[]; unseenMessages: IMessage[]; fullscreenMessage?: IMessage; onSetFullscreenMessage: (message: IMessage) => void; diff --git a/src/webchat/components/ConnectedWebchatUI.tsx b/src/webchat/components/ConnectedWebchatUI.tsx index 24a1422e..3e743005 100644 --- a/src/webchat/components/ConnectedWebchatUI.tsx +++ b/src/webchat/components/ConnectedWebchatUI.tsx @@ -43,6 +43,7 @@ type FromState = Pick< | "connected" | "reconnectionLimit" >; + type FromDispatch = Pick< WebchatUIProps, | "onSendMessage" @@ -54,33 +55,62 @@ type FromDispatch = Pick< | "onTriggerEngagementMessage" | "onSetMessageAnimated" >; + export type FromProps = Pick< WebchatUIProps, "messagePlugins" | "inputPlugins" | "webchatRootProps" | "webchatToggleProps" | "options" >; + type Merge = FromState & FromDispatch & FromProps & Pick; export const ConnectedWebchatUI = connect( - ({ - messages, - unseenMessages, - prevConversations, - connection: { connected, reconnectionLimit }, - ui: { + (state: StoreState) => { + const { + messages: { messageHistory: messages, visibleOutputMessages }, + unseenMessages, + prevConversations, + connection: { connected, reconnectionLimit }, + ui: { + open, + typing, + inputMode, + fullscreenMessage, + showHomeScreen, + showPrevConversations, + showChatOptionsScreen, + hasAcceptedTerms, + ttsActive, + lastInputId, + }, + config, + options: { sessionId, userId }, + rating: { + showRatingScreen, + hasGivenRating, + requestRatingScreenTitle, + customRatingTitle, + customRatingCommentText, + requestRatingSubmitButtonText, + requestRatingEventBannerText, + requestRatingChatStatusBadgeText, + }, + input: { sttActive, textActive, isDropZoneVisible, fileList, fileUploadError }, + xAppOverlay: { open: isXAppOverlayOpen }, + } = state; + + return { + currentSession: sessionId, + messages, + visibleOutputMessages, + unseenMessages, + prevConversations, open, - typing, + typingIndicator: typing, inputMode, fullscreenMessage, - showHomeScreen, - showPrevConversations, - showChatOptionsScreen, - hasAcceptedTerms, - ttsActive, - lastInputId, - }, - config, - options: { sessionId, userId }, - rating: { + config, + connected, + reconnectionLimit, showRatingScreen, hasGivenRating, requestRatingScreenTitle, @@ -89,43 +119,21 @@ export const ConnectedWebchatUI = connect ({ - currentSession: sessionId, - messages, - unseenMessages, - prevConversations, - open, - typingIndicator: typing, - inputMode, - fullscreenMessage, - config, - connected, - reconnectionLimit, - showRatingScreen, - hasGivenRating, - requestRatingScreenTitle, - customRatingTitle, - customRatingCommentText, - requestRatingSubmitButtonText, - requestRatingEventBannerText, - requestRatingChatStatusBadgeText, - showHomeScreen, - sttActive, - textActive, - isDropZoneVisible, - fileList, - fileUploadError, - showPrevConversations, - showChatOptionsScreen, - hasAcceptedTerms, - isXAppOverlayOpen, - userId, - ttsActive, - lastInputId, - }), + showHomeScreen, + sttActive, + textActive, + isDropZoneVisible, + fileList, + fileUploadError, + showPrevConversations, + showChatOptionsScreen, + hasAcceptedTerms, + isXAppOverlayOpen, + userId, + ttsActive, + lastInputId, + } as FromState; + }, dispatch => ({ onSendMessage: (text, data, options) => dispatch(sendMessage({ text, data }, options)), onSetInputMode: inputMode => dispatch(setInputMode(inputMode)), diff --git a/src/webchat/store/autoinject/autoinject-middleware.ts b/src/webchat/store/autoinject/autoinject-middleware.ts index 6b0f9bc7..5529af06 100644 --- a/src/webchat/store/autoinject/autoinject-middleware.ts +++ b/src/webchat/store/autoinject/autoinject-middleware.ts @@ -61,7 +61,7 @@ export const createAutoInjectMiddleware = // except if explicitly set via enableAutoInjectWithHistory if (!config.settings.widgetSettings.enableInjectionWithoutEmptyHistory) { // Exclude engagement messages from state.messages - const messagesExcludingEngagementMessages = state.messages?.filter( + const messagesExcludingEngagementMessages = state.messages.messageHistory?.filter( message => message.source !== "engagement", ); // Exclude controlCommands messages from filtered message list diff --git a/src/webchat/store/messages/message-middleware.ts b/src/webchat/store/messages/message-middleware.ts index f2c60b00..ecc6054e 100644 --- a/src/webchat/store/messages/message-middleware.ts +++ b/src/webchat/store/messages/message-middleware.ts @@ -158,7 +158,7 @@ export const createMessageMiddleware = const isStreamingMessage = state.config.settings.behavior.collateStreamedOutputs && !!message?.data?._cognigy?._messageId && - state.messages.some( + state.messages.messageHistory.some( storeMsg => message?.data?._cognigy?._messageId === (storeMsg as IStreamingMessage).id, diff --git a/src/webchat/store/messages/message-reducer.ts b/src/webchat/store/messages/message-reducer.ts index 49094f0c..2160098d 100644 --- a/src/webchat/store/messages/message-reducer.ts +++ b/src/webchat/store/messages/message-reducer.ts @@ -2,7 +2,15 @@ import { IMessage, IStreamingMessage } from "../../../common/interfaces/message" import { IMessageEvent } from "../../../common/interfaces/event"; import { generateRandomId } from "./helper"; -export type MessageState = (IMessage | IMessageEvent)[]; +export interface MessageState { + messageHistory: (IMessage | IMessageEvent)[]; + visibleOutputMessages: string[]; +} + +const initialState: MessageState = { + messageHistory: [], + visibleOutputMessages: [] +}; const ADD_MESSAGE = "ADD_MESSAGE"; export const addMessage = (message: IMessage, unseen?: boolean) => ({ @@ -58,12 +66,15 @@ type ConfigState = { export const createMessageReducer = (getState: () => { config: ConfigState }) => { return ( - state: MessageState = [], + state: MessageState = initialState, action: AddMessageAction | AddMessageEventAction | SetMessageAnimatedAction, ) => { switch (action.type) { case "ADD_MESSAGE_EVENT": { - return [...state, action.event]; + return { + ...state, + messageHistory: [...state.messageHistory, action.event] + }; } case "ADD_MESSAGE": { const newMessage = action.message; @@ -77,7 +88,10 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => (!isOutputCollationEnabled && !isprogressiveMessageRenderingEnabled) || (newMessage.source !== "bot" && newMessage.source !== "engagement") ) { - return [...state, newMessage]; + return { + ...state, + messageHistory: [...state.messageHistory, newMessage] + }; } let newMessageId = getMessageId(newMessage); @@ -85,21 +99,24 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => // If message doesn't have text (e.g. Text with Quick Replies), still add an ID and animationState for enabling the animation. // if there is a messageId, it means the message was a streaming message that was finished and will be handled further below if (!newMessage.text && !newMessageId) { - return [ + return { ...state, - { - ...newMessage, - id: generateRandomId(), - animationState: "start", - finishReason: "stop" - }, - ]; + messageHistory: [ + ...state.messageHistory, + { + ...newMessage, + id: generateRandomId(), + animationState: "start", + finishReason: "stop" + }, + ] + }; } // Find existing message with same ID if we're collating outputs let messageIndex = -1; if (isOutputCollationEnabled && newMessageId) { - messageIndex = state.findIndex(msg => { + messageIndex = state.messageHistory.findIndex(msg => { if ("text" in msg) { const msgId = getMessageId(msg as IMessage); if (msgId) { @@ -123,29 +140,35 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => .split(/(\n)/) .filter(chunk => chunk.length > 0); - return [ + return { ...state, - { - ...newMessage, - text: textChunks, - id: newMessageId, - animationState: "start", - finishReason, - }, - ]; + messageHistory: [ + ...state.messageHistory, + { + ...newMessage, + text: textChunks, + id: newMessageId, + animationState: "start", + finishReason, + }, + ] + }; } // Get existing message - const existingMessage = state[messageIndex] as IStreamingMessage; - const newState = [...state]; + const existingMessage = state.messageHistory[messageIndex] as IStreamingMessage; + const newMessageHistory = [...state.messageHistory]; // if there is a finishReason, only add the finishReason to the streaming message if (finishReason) { - newState[messageIndex] = { + newMessageHistory[messageIndex] = { ...existingMessage, finishReason, }; - return newState; + return { + ...state, + messageHistory: newMessageHistory + }; } // Convert existing text to array if needed @@ -162,22 +185,28 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => } // Append new chunk - newState[messageIndex] = { + newMessageHistory[messageIndex] = { ...existingMessage, text: [...existingText, newMessage.text as string], animationState: nextAnimationState, finishReason, } as IMessage; - return newState; + return { + ...state, + messageHistory: newMessageHistory + }; } case "SET_MESSAGE_ANIMATED": { - return state.map(message => { - if ("id" in message && message.id === action.messageId) { - return { ...message, animationState: action.animationState }; - } - return message; - }); + return { + ...state, + messageHistory: state.messageHistory.map(message => { + if ("id" in message && message.id === action.messageId) { + return { ...message, animationState: action.animationState }; + } + return message; + }) + }; } default: return state; diff --git a/src/webchat/store/reducer.ts b/src/webchat/store/reducer.ts index d6fc9c4d..49f39989 100644 --- a/src/webchat/store/reducer.ts +++ b/src/webchat/store/reducer.ts @@ -56,11 +56,14 @@ export const reducer = (state = rootReducer(undefined, { type: "" }), action) => return rootReducer( { ...state, - messages: [ - // To avoid duplicate messages in chat history during re-connection, we only restore messages and prepend them if the current message history is empty - ...(state.messages.length === 0 ? action.state.messages : []), - ...state.messages, - ], + messages: { + messageHistory: [ + // To avoid duplicate messages in chat history during re-connection, we only restore messages and prepend them if the current message history is empty + ...(state.messages.messageHistory.length === 0 ? action.state.messages : []), + ...state.messages.messageHistory, + ], + visibleOutputMessages: state.messages.visibleOutputMessages + }, rating: { ...state.rating, hasGivenRating: action.state.rating.hasGivenRating, @@ -81,7 +84,10 @@ export const reducer = (state = rootReducer(undefined, { type: "" }), action) => return rootReducer( { ...state, - messages: [...messages], + messages: { + messageHistory: [...messages], + visibleOutputMessages: [] + }, rating: { showRatingScreen: false, ...rating }, }, { type: "" }, From 14189e450aa5dc59b6e904c3323d0cf01d5812a1 Mon Sep 17 00:00:00 2001 From: David Scheier Date: Fri, 14 Mar 2025 16:47:45 +0100 Subject: [PATCH 3/9] fix messages when loaded from prevConversations --- .../previous-conversations-middleware.ts | 2 +- src/webchat/store/reducer.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/webchat/store/previous-conversations/previous-conversations-middleware.ts b/src/webchat/store/previous-conversations/previous-conversations-middleware.ts index a3b385ab..0bf29fb1 100644 --- a/src/webchat/store/previous-conversations/previous-conversations-middleware.ts +++ b/src/webchat/store/previous-conversations/previous-conversations-middleware.ts @@ -72,7 +72,7 @@ export const createPrevConversationsMiddleware = if (!currentSession) break; const conversation = { - messages: store.getState().messages, + messages: store.getState().messages.messageHistory, rating: store.getState().rating, }; store.dispatch(upsertPrevConversation(currentSession, conversation)); diff --git a/src/webchat/store/reducer.ts b/src/webchat/store/reducer.ts index 49f39989..004df43c 100644 --- a/src/webchat/store/reducer.ts +++ b/src/webchat/store/reducer.ts @@ -16,6 +16,7 @@ import { import { StoreState } from "./store"; import xAppOverlay from "./xapp-overlay/slice"; import queueUpdates from "./queue-updates/slice"; +import { IStreamingMessage } from "../../common/interfaces/message"; const rootReducer = (state, action) => { const combinedReducer = combineReducers({ @@ -75,10 +76,14 @@ export const reducer = (state = rootReducer(undefined, { type: "" }), action) => case "SET_PREV_STATE": { const { showRatingScreen, ...rating } = action.state.rating; + const visibleOutputMessages = []; const messages = action.state.messages.map(message => { if (message.animationState) { message.animationState = "done"; } + if ((message.source === "bot" || message.source === "engagement") && message.id) { + visibleOutputMessages.push(message.id as string); + } return message; }); return rootReducer( @@ -86,7 +91,7 @@ export const reducer = (state = rootReducer(undefined, { type: "" }), action) => ...state, messages: { messageHistory: [...messages], - visibleOutputMessages: [] + visibleOutputMessages, }, rating: { showRatingScreen: false, ...rating }, }, From 90d96c3ed1ddbcdb293ee8935d1348a3ff690351 Mon Sep 17 00:00:00 2001 From: David Scheier Date: Tue, 18 Mar 2025 08:21:40 +0100 Subject: [PATCH 4/9] only show animated messages in order --- src/webchat-ui/components/WebchatUI.tsx | 25 +++-- src/webchat/store/messages/helper.ts | 17 +++ src/webchat/store/messages/message-reducer.ts | 101 ++++++++++++++++-- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/webchat-ui/components/WebchatUI.tsx b/src/webchat-ui/components/WebchatUI.tsx index b15aa737..4d6f86c6 100644 --- a/src/webchat-ui/components/WebchatUI.tsx +++ b/src/webchat-ui/components/WebchatUI.tsx @@ -1439,20 +1439,27 @@ export class WebchatUI extends React.PureComponent< } renderHistory() { - const { messages, typingIndicator, config, onEmitAnalytics, openXAppOverlay } = this.props; + const { messages, typingIndicator, config, onEmitAnalytics, openXAppOverlay, visibleOutputMessages } = this.props; const { messagePlugins = [] } = this.state; - const { enableTypingIndicator, messageDelay, enableAIAgentNotice, AIAgentNoticeText } = - config.settings.behavior; + const { enableTypingIndicator, messageDelay, enableAIAgentNotice, AIAgentNoticeText, progressiveMessageRendering } = config.settings.behavior; const isTyping = typingIndicator !== "remove" && typingIndicator !== "hide"; const isEnded = isConversationEnded(messages); // Find privacy message and remove it from the messages list (these message types are not displayed in the chat log). // If we do not remove, it will cause the collatation of the first user message. - const messagesExcludingPrivacyMessage = getMessagesListWithoutControlCommands(messages, [ - "acceptPrivacyPolicy", - ]); + const messagesExcludingPrivacyMessage = getMessagesListWithoutControlCommands(messages, ["acceptPrivacyPolicy"]); + + // Filter messages based on progressive rendering settings + const visibleMessages = progressiveMessageRendering + ? messagesExcludingPrivacyMessage.filter(message => { + if (message.source !== "bot" && message.source !== "engagement") { + return true; + } + return visibleOutputMessages.includes((message as IStreamingMessage).id as string); + }) + : messagesExcludingPrivacyMessage; return ( <> @@ -1461,9 +1468,9 @@ export class WebchatUI extends React.PureComponent< {AIAgentNoticeText || "You're now chatting with an AI Agent."} )} - {messagesExcludingPrivacyMessage.map((message, index) => { + {visibleMessages.map((message, index) => { // Lookahead if there is a user reply - const hasReply = messagesExcludingPrivacyMessage + const hasReply = visibleMessages .slice(index + 1) .some( message => @@ -1484,7 +1491,7 @@ export class WebchatUI extends React.PureComponent< onSetFullscreen={() => this.props.onSetFullscreenMessage(message)} openXAppOverlay={openXAppOverlay} plugins={messagePlugins} - prevMessage={messagesExcludingPrivacyMessage?.[index - 1]} + prevMessage={visibleMessages?.[index - 1]} theme={this.state.theme} onSetMessageAnimated={this.props.onSetMessageAnimated} /> diff --git a/src/webchat/store/messages/helper.ts b/src/webchat/store/messages/helper.ts index 8976e45a..8cdc7cb4 100644 --- a/src/webchat/store/messages/helper.ts +++ b/src/webchat/store/messages/helper.ts @@ -1,3 +1,20 @@ +import { IWebchatMessage, IWebchatTemplateAttachment } from "@cognigy/socket-client"; +import { IStreamingMessage } from "../../../common/interfaces/message"; + export function generateRandomId(): string { return String(Math.random()).slice(2, 18); } + + +export function isAnimatedRichBotMessage(message: IStreamingMessage): boolean { + const { _facebook, _webchat } = message?.data?._cognigy || {}; + const payload = (_webchat as IWebchatMessage) || _facebook || {}; + + const isQuickReplies = !!(payload?.message?.quick_replies && payload.message.quick_replies.length > 0); + + const isTextWithButtons = (payload?.message?.attachment as IWebchatTemplateAttachment)?.payload?.template_type === "button"; + + const hasMessengerText = !!payload?.message?.text; + + return isQuickReplies || isTextWithButtons || hasMessengerText; +} \ No newline at end of file diff --git a/src/webchat/store/messages/message-reducer.ts b/src/webchat/store/messages/message-reducer.ts index 2160098d..86ea5945 100644 --- a/src/webchat/store/messages/message-reducer.ts +++ b/src/webchat/store/messages/message-reducer.ts @@ -1,15 +1,17 @@ import { IMessage, IStreamingMessage } from "../../../common/interfaces/message"; import { IMessageEvent } from "../../../common/interfaces/event"; -import { generateRandomId } from "./helper"; +import { generateRandomId, isAnimatedRichBotMessage } from "./helper"; export interface MessageState { messageHistory: (IMessage | IMessageEvent)[]; visibleOutputMessages: string[]; + currentlyAnimatingId: string | null; } const initialState: MessageState = { messageHistory: [], - visibleOutputMessages: [] + visibleOutputMessages: [], + currentlyAnimatingId: null, }; const ADD_MESSAGE = "ADD_MESSAGE"; @@ -94,22 +96,38 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => }; } + const visibleOutputMessages = state.visibleOutputMessages; let newMessageId = getMessageId(newMessage); + let nextAnimatingId = state.currentlyAnimatingId; - // If message doesn't have text (e.g. Text with Quick Replies), still add an ID and animationState for enabling the animation. + // If message doesn't have text, still add an ID. + // Check if the message is an animated bot message (e.g. Text with Quick Replies) and set the animationState accordingly // if there is a messageId, it means the message was a streaming message that was finished and will be handled further below if (!newMessage.text && !newMessageId) { + const isAnimated = isAnimatedRichBotMessage(newMessage as IStreamingMessage); + + const newMessageId = generateRandomId(); + + if (!state.currentlyAnimatingId) { + visibleOutputMessages.push(newMessageId as string); + } + if (!nextAnimatingId) { + nextAnimatingId = newMessageId; + } + return { ...state, messageHistory: [ ...state.messageHistory, { ...newMessage, - id: generateRandomId(), - animationState: "start", + id: newMessageId, + animationState: isAnimated ? "start" : "done", finishReason: "stop" }, - ] + ], + visibleOutputMessages, + currentlyAnimatingId: nextAnimatingId }; } @@ -133,8 +151,16 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => newMessageId = generateRandomId(); } + if (!nextAnimatingId) { + nextAnimatingId = newMessageId; + } + // If no matching message, create new with array if (messageIndex === -1) { + if (!state.currentlyAnimatingId) { + visibleOutputMessages.push(newMessageId as string); + } + // break string into chunks on new lines so that markdown is evaluated while a long text is animated const textChunks = (newMessage.text as string) .split(/(\n)/) @@ -151,10 +177,16 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => animationState: "start", finishReason, }, - ] + ], + visibleOutputMessages, + currentlyAnimatingId: nextAnimatingId }; } + /* + ** From here on, we are only handling a streaming message that has already been added to the messageHistory + */ + // Get existing message const existingMessage = state.messageHistory[messageIndex] as IStreamingMessage; const newMessageHistory = [...state.messageHistory]; @@ -184,6 +216,14 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => nextAnimationState = "exited"; } + // if the streaming message is empty, keep the animation state of the existing message, since the empty message just streams the finishReason + if (!newMessage.text) { + nextAnimationState = existingMessage.animationState; + } + + console.log("nextAnimationState", nextAnimationState); + console.log(newMessage); + // Append new chunk newMessageHistory[messageIndex] = { ...existingMessage, @@ -197,7 +237,50 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => messageHistory: newMessageHistory }; } + case "SET_MESSAGE_ANIMATED": { + const messageIndex = state.messageHistory.findIndex(message => "id" in message && message.id === action.messageId); + if (messageIndex === -1) return state; + + // Create a new Set to deduplicate messages while maintaining order + const visibleMessagesSet = new Set(state.visibleOutputMessages); + + // Add current message if it's done or exited + if (action.animationState === "done" || action.animationState === "exited") { + visibleMessagesSet.add(action.messageId); + } + + let currentlyAnimatingId = state.currentlyAnimatingId; + + // Find the next message that should be animated + if (action.animationState === "done" || action.animationState === "exited") { + let nextAnimatingMessageFound = false; + + for (let i = messageIndex + 1; i < state.messageHistory.length; i++) { + const message = state.messageHistory[i]; + if ((message.source === "bot" || message.source === "engagement") && "id" in message) { + visibleMessagesSet.add(message.id as string); + + // If we find a message that should be animated (state is "start") + if (message.animationState === "start" && !nextAnimatingMessageFound) { + currentlyAnimatingId = message.id as string; + nextAnimatingMessageFound = true; + break; + } + } + } + + // If we didn't find a next message to animate, clear the animating ID + if (!nextAnimatingMessageFound) { + currentlyAnimatingId = null; + } + } + + // Convert Set back to array while maintaining order from messageHistory + const newVisibleOutputMessages = state.messageHistory + .filter(message => "id" in message && visibleMessagesSet.has(message.id)) + .map(message => ("id" in message ? message.id : "")) as string[]; + return { ...state, messageHistory: state.messageHistory.map(message => { @@ -205,7 +288,9 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => return { ...message, animationState: action.animationState }; } return message; - }) + }), + visibleOutputMessages: newVisibleOutputMessages, + currentlyAnimatingId }; } default: From bfcf57f75d1fb3e0574c630cc862a00c86935bec Mon Sep 17 00:00:00 2001 From: David Scheier Date: Tue, 18 Mar 2025 14:45:01 +0100 Subject: [PATCH 5/9] fix animation rendering one by one --- src/webchat/store/messages/helper.ts | 4 +++- src/webchat/store/messages/message-reducer.ts | 17 +++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/webchat/store/messages/helper.ts b/src/webchat/store/messages/helper.ts index 8cdc7cb4..e9a85c56 100644 --- a/src/webchat/store/messages/helper.ts +++ b/src/webchat/store/messages/helper.ts @@ -16,5 +16,7 @@ export function isAnimatedRichBotMessage(message: IStreamingMessage): boolean { const hasMessengerText = !!payload?.message?.text; - return isQuickReplies || isTextWithButtons || hasMessengerText; + const isAnimatedMsg = isQuickReplies || isTextWithButtons || hasMessengerText; + + return isAnimatedMsg; } \ No newline at end of file diff --git a/src/webchat/store/messages/message-reducer.ts b/src/webchat/store/messages/message-reducer.ts index 86ea5945..6c9b480e 100644 --- a/src/webchat/store/messages/message-reducer.ts +++ b/src/webchat/store/messages/message-reducer.ts @@ -112,7 +112,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => visibleOutputMessages.push(newMessageId as string); } if (!nextAnimatingId) { - nextAnimatingId = newMessageId; + nextAnimatingId = isAnimated ? newMessageId : null; } return { @@ -155,6 +155,11 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => nextAnimatingId = newMessageId; } + // If no matching message and the message has no text, we discard the message + if (messageIndex === -1 && !newMessage.text) { + return state; + } + // If no matching message, create new with array if (messageIndex === -1) { if (!state.currentlyAnimatingId) { @@ -221,9 +226,6 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => nextAnimationState = existingMessage.animationState; } - console.log("nextAnimationState", nextAnimationState); - console.log(newMessage); - // Append new chunk newMessageHistory[messageIndex] = { ...existingMessage, @@ -245,11 +247,6 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => // Create a new Set to deduplicate messages while maintaining order const visibleMessagesSet = new Set(state.visibleOutputMessages); - // Add current message if it's done or exited - if (action.animationState === "done" || action.animationState === "exited") { - visibleMessagesSet.add(action.messageId); - } - let currentlyAnimatingId = state.currentlyAnimatingId; // Find the next message that should be animated @@ -278,7 +275,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => // Convert Set back to array while maintaining order from messageHistory const newVisibleOutputMessages = state.messageHistory - .filter(message => "id" in message && visibleMessagesSet.has(message.id)) + .filter(message => "id" in message && visibleMessagesSet.has(message.id as string)) .map(message => ("id" in message ? message.id : "")) as string[]; return { From dab20686c88641408fa052ff51d10c46420979a0 Mon Sep 17 00:00:00 2001 From: David Scheier Date: Tue, 18 Mar 2025 15:29:49 +0100 Subject: [PATCH 6/9] prettier fixes --- src/webchat-ui/components/WebchatUI.tsx | 33 ++++++++++++++----- .../store/autoinject/autoinject-middleware.ts | 7 ++-- src/webchat/store/messages/helper.ts | 11 ++++--- src/webchat/store/messages/message-reducer.ts | 33 +++++++++++-------- src/webchat/store/reducer.ts | 8 +++-- 5 files changed, 61 insertions(+), 31 deletions(-) diff --git a/src/webchat-ui/components/WebchatUI.tsx b/src/webchat-ui/components/WebchatUI.tsx index 4d6f86c6..002efa31 100644 --- a/src/webchat-ui/components/WebchatUI.tsx +++ b/src/webchat-ui/components/WebchatUI.tsx @@ -1439,26 +1439,43 @@ export class WebchatUI extends React.PureComponent< } renderHistory() { - const { messages, typingIndicator, config, onEmitAnalytics, openXAppOverlay, visibleOutputMessages } = this.props; + const { + messages, + typingIndicator, + config, + onEmitAnalytics, + openXAppOverlay, + visibleOutputMessages, + } = this.props; const { messagePlugins = [] } = this.state; - const { enableTypingIndicator, messageDelay, enableAIAgentNotice, AIAgentNoticeText, progressiveMessageRendering } = config.settings.behavior; + const { + enableTypingIndicator, + messageDelay, + enableAIAgentNotice, + AIAgentNoticeText, + progressiveMessageRendering, + } = config.settings.behavior; const isTyping = typingIndicator !== "remove" && typingIndicator !== "hide"; const isEnded = isConversationEnded(messages); // Find privacy message and remove it from the messages list (these message types are not displayed in the chat log). // If we do not remove, it will cause the collatation of the first user message. - const messagesExcludingPrivacyMessage = getMessagesListWithoutControlCommands(messages, ["acceptPrivacyPolicy"]); + const messagesExcludingPrivacyMessage = getMessagesListWithoutControlCommands(messages, [ + "acceptPrivacyPolicy", + ]); // Filter messages based on progressive rendering settings const visibleMessages = progressiveMessageRendering ? messagesExcludingPrivacyMessage.filter(message => { - if (message.source !== "bot" && message.source !== "engagement") { - return true; - } - return visibleOutputMessages.includes((message as IStreamingMessage).id as string); - }) + if (message.source !== "bot" && message.source !== "engagement") { + return true; + } + return visibleOutputMessages.includes( + (message as IStreamingMessage).id as string, + ); + }) : messagesExcludingPrivacyMessage; return ( diff --git a/src/webchat/store/autoinject/autoinject-middleware.ts b/src/webchat/store/autoinject/autoinject-middleware.ts index 5529af06..e883de6b 100644 --- a/src/webchat/store/autoinject/autoinject-middleware.ts +++ b/src/webchat/store/autoinject/autoinject-middleware.ts @@ -61,9 +61,10 @@ export const createAutoInjectMiddleware = // except if explicitly set via enableAutoInjectWithHistory if (!config.settings.widgetSettings.enableInjectionWithoutEmptyHistory) { // Exclude engagement messages from state.messages - const messagesExcludingEngagementMessages = state.messages.messageHistory?.filter( - message => message.source !== "engagement", - ); + const messagesExcludingEngagementMessages = + state.messages.messageHistory?.filter( + message => message.source !== "engagement", + ); // Exclude controlCommands messages from filtered message list const messagesExcludingControlCommands = getMessagesListWithoutControlCommands( messagesExcludingEngagementMessages, diff --git a/src/webchat/store/messages/helper.ts b/src/webchat/store/messages/helper.ts index e9a85c56..534cf6f3 100644 --- a/src/webchat/store/messages/helper.ts +++ b/src/webchat/store/messages/helper.ts @@ -5,18 +5,21 @@ export function generateRandomId(): string { return String(Math.random()).slice(2, 18); } - export function isAnimatedRichBotMessage(message: IStreamingMessage): boolean { const { _facebook, _webchat } = message?.data?._cognigy || {}; const payload = (_webchat as IWebchatMessage) || _facebook || {}; - const isQuickReplies = !!(payload?.message?.quick_replies && payload.message.quick_replies.length > 0); + const isQuickReplies = !!( + payload?.message?.quick_replies && payload.message.quick_replies.length > 0 + ); - const isTextWithButtons = (payload?.message?.attachment as IWebchatTemplateAttachment)?.payload?.template_type === "button"; + const isTextWithButtons = + (payload?.message?.attachment as IWebchatTemplateAttachment)?.payload?.template_type === + "button"; const hasMessengerText = !!payload?.message?.text; const isAnimatedMsg = isQuickReplies || isTextWithButtons || hasMessengerText; return isAnimatedMsg; -} \ No newline at end of file +} diff --git a/src/webchat/store/messages/message-reducer.ts b/src/webchat/store/messages/message-reducer.ts index 6c9b480e..379f3b8f 100644 --- a/src/webchat/store/messages/message-reducer.ts +++ b/src/webchat/store/messages/message-reducer.ts @@ -75,7 +75,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => case "ADD_MESSAGE_EVENT": { return { ...state, - messageHistory: [...state.messageHistory, action.event] + messageHistory: [...state.messageHistory, action.event], }; } case "ADD_MESSAGE": { @@ -92,7 +92,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => ) { return { ...state, - messageHistory: [...state.messageHistory, newMessage] + messageHistory: [...state.messageHistory, newMessage], }; } @@ -123,11 +123,11 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => ...newMessage, id: newMessageId, animationState: isAnimated ? "start" : "done", - finishReason: "stop" + finishReason: "stop", }, ], visibleOutputMessages, - currentlyAnimatingId: nextAnimatingId + currentlyAnimatingId: nextAnimatingId, }; } @@ -184,13 +184,13 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => }, ], visibleOutputMessages, - currentlyAnimatingId: nextAnimatingId + currentlyAnimatingId: nextAnimatingId, }; } /* - ** From here on, we are only handling a streaming message that has already been added to the messageHistory - */ + ** From here on, we are only handling a streaming message that has already been added to the messageHistory + */ // Get existing message const existingMessage = state.messageHistory[messageIndex] as IStreamingMessage; @@ -204,7 +204,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => }; return { ...state, - messageHistory: newMessageHistory + messageHistory: newMessageHistory, }; } @@ -236,12 +236,14 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => return { ...state, - messageHistory: newMessageHistory + messageHistory: newMessageHistory, }; } case "SET_MESSAGE_ANIMATED": { - const messageIndex = state.messageHistory.findIndex(message => "id" in message && message.id === action.messageId); + const messageIndex = state.messageHistory.findIndex( + message => "id" in message && message.id === action.messageId, + ); if (messageIndex === -1) return state; // Create a new Set to deduplicate messages while maintaining order @@ -255,7 +257,10 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => for (let i = messageIndex + 1; i < state.messageHistory.length; i++) { const message = state.messageHistory[i]; - if ((message.source === "bot" || message.source === "engagement") && "id" in message) { + if ( + (message.source === "bot" || message.source === "engagement") && + "id" in message + ) { visibleMessagesSet.add(message.id as string); // If we find a message that should be animated (state is "start") @@ -275,7 +280,9 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => // Convert Set back to array while maintaining order from messageHistory const newVisibleOutputMessages = state.messageHistory - .filter(message => "id" in message && visibleMessagesSet.has(message.id as string)) + .filter( + message => "id" in message && visibleMessagesSet.has(message.id as string), + ) .map(message => ("id" in message ? message.id : "")) as string[]; return { @@ -287,7 +294,7 @@ export const createMessageReducer = (getState: () => { config: ConfigState }) => return message; }), visibleOutputMessages: newVisibleOutputMessages, - currentlyAnimatingId + currentlyAnimatingId, }; } default: diff --git a/src/webchat/store/reducer.ts b/src/webchat/store/reducer.ts index 004df43c..6f483166 100644 --- a/src/webchat/store/reducer.ts +++ b/src/webchat/store/reducer.ts @@ -59,11 +59,13 @@ export const reducer = (state = rootReducer(undefined, { type: "" }), action) => ...state, messages: { messageHistory: [ - // To avoid duplicate messages in chat history during re-connection, we only restore messages and prepend them if the current message history is empty - ...(state.messages.messageHistory.length === 0 ? action.state.messages : []), + // To avoid duplicate messages in chat history during re-connection, we only restore messages and prepend them if the current message history is empty + ...(state.messages.messageHistory.length === 0 + ? action.state.messages + : []), ...state.messages.messageHistory, ], - visibleOutputMessages: state.messages.visibleOutputMessages + visibleOutputMessages: state.messages.visibleOutputMessages, }, rating: { ...state.rating, From 2f61aa0b58ed903fb6c090f6d41e7801a6d735eb Mon Sep 17 00:00:00 2001 From: David Scheier Date: Wed, 19 Mar 2025 16:47:26 +0100 Subject: [PATCH 7/9] fix e2e tests --- cypress/support/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index b9608214..aec75403 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -217,7 +217,7 @@ Cypress.Commands.add("setRTLDocument", () => { Cypress.Commands.add("getHistory", () => { return cy.getWebchat().then(webchat => { // @ts-ignore - return webchat.store.getState().messages; + return webchat.store.getState().messages.messageHistory; }); }); From 8afb7189f427ab7f67fada80fc784369b1de77cd Mon Sep 17 00:00:00 2001 From: David Scheier Date: Thu, 20 Mar 2025 15:46:08 +0100 Subject: [PATCH 8/9] fix e2e tests --- src/webchat/store/options/options-middleware.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/webchat/store/options/options-middleware.ts b/src/webchat/store/options/options-middleware.ts index 8c444537..3dd95f42 100644 --- a/src/webchat/store/options/options-middleware.ts +++ b/src/webchat/store/options/options-middleware.ts @@ -9,6 +9,7 @@ import { SendMessageAction, TriggerEngagementMessageAction } from "../messages/m import { ReceiveMessageAction } from "../messages/message-handler"; import { RatingAction } from "../rating/rating-reducer"; import { SetShowPrevConversationsAction } from "../ui/ui-reducer"; +import { IMessage } from "@cognigy/socket-client"; type Actions = | SetOptionsAction @@ -77,10 +78,17 @@ export const optionsMiddleware: Middleware = case "SET_CUSTOM_RATING_COMMENT_TEXT": { if (browserStorage && active && userId && sessionId && !disablePersistentHistory) { const { messages, rating } = store.getState(); + // maintain backward compatibility with old messages format + let messageHistory: IMessage[] = []; + if (Array.isArray(messages)) { + messageHistory = messages as IMessage[]; + } else if (messages?.messageHistory && Array.isArray(messages.messageHistory)) { + messageHistory = messages.messageHistory as IMessage[]; + } browserStorage.setItem( key, JSON.stringify({ - messages, + messages: messageHistory, rating, }), ); From 1db138f2cc1fd3d8d88e89951f56d2836f3281d7 Mon Sep 17 00:00:00 2001 From: David Scheier Date: Tue, 25 Mar 2025 16:11:00 +0100 Subject: [PATCH 9/9] update chat-components pkg --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd1610a0..548e0d45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@braintree/sanitize-url": "^6.0.0", - "@cognigy/chat-components": "^0.42.0", + "@cognigy/chat-components": "0.43.0", "@cognigy/socket-client": "5.0.0-beta.22", "@emotion/cache": "^10.0.29", "@emotion/react": "^11.13.0", @@ -785,9 +785,9 @@ } }, "node_modules/@cognigy/chat-components": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@cognigy/chat-components/-/chat-components-0.42.0.tgz", - "integrity": "sha512-GVo8+2AvuODYJ2GGg2vNTf3P9Bwy9KPqVPxdTEcmx1qh57NLez4RujFKG8IfiGXtBXdThoitEZ9IEyI4AZ5QKQ==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@cognigy/chat-components/-/chat-components-0.43.0.tgz", + "integrity": "sha512-0mjjX8zYUAyODttKHGVLGQYq1WmEBZw93MMkoOgZ8m8/HWArbTtr4aho0MxrNNzcqcLE1VnnDQOb7LEOLRiwkg==", "dependencies": { "@braintree/sanitize-url": "^6.0.4", "@fontsource/figtree": "5.0.19", diff --git a/package.json b/package.json index 06a8dff2..43368f33 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@braintree/sanitize-url": "^6.0.0", - "@cognigy/chat-components": "^0.42.0", + "@cognigy/chat-components": "0.43.0", "@cognigy/socket-client": "5.0.0-beta.22", "@emotion/cache": "^10.0.29", "@emotion/react": "^11.13.0",