diff --git a/client/src/components/Chat/Messages/HoverButtons.tsx b/client/src/components/Chat/Messages/HoverButtons.tsx index a8df7dfbb3..5c4b3876b0 100644 --- a/client/src/components/Chat/Messages/HoverButtons.tsx +++ b/client/src/components/Chat/Messages/HoverButtons.tsx @@ -45,6 +45,9 @@ const extractMessageContent = (message: TMessage): string => { if (Array.isArray(message.content)) { return message.content .map((part) => { + if (part == null) { + return ''; + } if (typeof part === 'string') { return part; } diff --git a/client/src/hooks/Conversations/useExportConversation.ts b/client/src/hooks/Conversations/useExportConversation.ts index 6b5d53e65e..579b5f1cf6 100644 --- a/client/src/hooks/Conversations/useExportConversation.ts +++ b/client/src/hooks/Conversations/useExportConversation.ts @@ -73,7 +73,9 @@ export default function useExportConversation({ } return message.content + .filter((content) => content != null) .map((content) => getMessageContent(message.sender || '', content)) + .filter((text) => text.length > 0) .map((text) => { return formatText(text[0], text[1]); }) diff --git a/client/src/hooks/SSE/useContentHandler.ts b/client/src/hooks/SSE/useContentHandler.ts index 5784799b6d..d51cb1e016 100644 --- a/client/src/hooks/SSE/useContentHandler.ts +++ b/client/src/hooks/SSE/useContentHandler.ts @@ -33,9 +33,8 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont const _messages = getMessages(); const messages = - _messages - ?.filter((m) => m.messageId !== messageId) - .map((msg) => ({ ...msg, thread_id })) ?? []; + _messages?.filter((m) => m.messageId !== messageId).map((msg) => ({ ...msg, thread_id })) ?? + []; const userMessage = messages[messages.length - 1] as TMessage | undefined; const { initialResponse } = submission; @@ -66,14 +65,17 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont response.content[index] = { type, [type]: part } as TMessageContentParts; + const lastContentPart = response.content[response.content.length - 1]; + const initialContentPart = initialResponse.content?.[0]; if ( type !== ContentTypes.TEXT && - initialResponse.content && - ((response.content[response.content.length - 1].type === ContentTypes.TOOL_CALL && - response.content[response.content.length - 1][ContentTypes.TOOL_CALL].progress === 1) || - response.content[response.content.length - 1].type === ContentTypes.IMAGE_FILE) + initialContentPart != null && + lastContentPart != null && + ((lastContentPart.type === ContentTypes.TOOL_CALL && + lastContentPart[ContentTypes.TOOL_CALL]?.progress === 1) || + lastContentPart.type === ContentTypes.IMAGE_FILE) ) { - response.content.push(initialResponse.content[0]); + response.content.push(initialContentPart); } setMessages([...messages, response]); diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 6348581b68..bde0319695 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -87,12 +87,14 @@ const createErrorMessage = ({ let isValidContentPart = false; if (latestContent.length > 0) { const latestContentPart = latestContent[latestContent.length - 1]; - const latestPartValue = latestContentPart?.[latestContentPart.type ?? '']; - isValidContentPart = - latestContentPart.type !== ContentTypes.TEXT || - (latestContentPart.type === ContentTypes.TEXT && typeof latestPartValue === 'string') - ? true - : latestPartValue?.value !== ''; + if (latestContentPart != null) { + const latestPartValue = latestContentPart[latestContentPart.type ?? '']; + isValidContentPart = + latestContentPart.type !== ContentTypes.TEXT || + (latestContentPart.type === ContentTypes.TEXT && typeof latestPartValue === 'string') + ? true + : latestPartValue?.value !== ''; + } } if ( latestMessage?.conversationId && @@ -455,141 +457,145 @@ export default function useEventHandlers({ isTemporary = false, } = submission; - if (responseMessage?.attachments && responseMessage.attachments.length > 0) { - // Process each attachment through the attachmentHandler - responseMessage.attachments.forEach((attachment) => { - const attachmentData = { - ...attachment, - messageId: responseMessage.messageId, - }; + try { + if (responseMessage?.attachments && responseMessage.attachments.length > 0) { + // Process each attachment through the attachmentHandler + responseMessage.attachments.forEach((attachment) => { + const attachmentData = { + ...attachment, + messageId: responseMessage.messageId, + }; - attachmentHandler({ - data: attachmentData, - submission: submission as EventSubmission, + attachmentHandler({ + data: attachmentData, + submission: submission as EventSubmission, + }); }); - }); - } + } - setShowStopButton(false); - setCompleted((prev) => new Set(prev.add(submission.initialResponse.messageId))); + setCompleted((prev) => new Set(prev.add(submission.initialResponse.messageId))); - const currentMessages = getMessages(); - /* Early return if messages are empty; i.e., the user navigated away */ - if (!currentMessages || currentMessages.length === 0) { - setIsSubmitting(false); - return; - } + const currentMessages = getMessages(); + /* Early return if messages are empty; i.e., the user navigated away */ + if (!currentMessages || currentMessages.length === 0) { + return; + } - /* a11y announcements */ - announcePolite({ message: 'end', isStatus: true }); - announcePolite({ message: getAllContentText(responseMessage) }); + /* a11y announcements */ + announcePolite({ message: 'end', isStatus: true }); + announcePolite({ message: getAllContentText(responseMessage) }); - const isNewConvo = conversation.conversationId !== submissionConvo.conversationId; + const isNewConvo = conversation.conversationId !== submissionConvo.conversationId; - const setFinalMessages = (id: string | null, _messages: TMessage[]) => { - setMessages(_messages); - queryClient.setQueryData([QueryKeys.messages, id], _messages); - }; + const setFinalMessages = (id: string | null, _messages: TMessage[]) => { + setMessages(_messages); + queryClient.setQueryData([QueryKeys.messages, id], _messages); + }; - const hasNoResponse = - responseMessage?.content?.[0]?.['text']?.value === - submission.initialResponse?.content?.[0]?.['text']?.value || - !!responseMessage?.content?.[0]?.['tool_call']?.auth; + const hasNoResponse = + responseMessage?.content?.[0]?.['text']?.value === + submission.initialResponse?.content?.[0]?.['text']?.value || + !!responseMessage?.content?.[0]?.['tool_call']?.auth; + + /** Handle edge case where stream is cancelled before any response, which creates a blank page */ + if (!conversation.conversationId && hasNoResponse) { + const currentConvoId = + (submissionConvo.conversationId ?? conversation.conversationId) || Constants.NEW_CONVO; + if (isNewConvo && submissionConvo.conversationId) { + removeConvoFromAllQueries(queryClient, submissionConvo.conversationId); + } + + const isNewChat = + location.pathname === `/c/${Constants.NEW_CONVO}` && + currentConvoId === Constants.NEW_CONVO; + + setFinalMessages(currentConvoId, isNewChat ? [] : [...messages]); + setDraft({ id: currentConvoId, value: requestMessage?.text }); + if (isNewChat) { + navigate(`/c/${Constants.NEW_CONVO}`, { replace: true, state: { focusChat: true } }); + } + return; + } + + /* Update messages; if assistants endpoint, client doesn't receive responseMessage */ + let finalMessages: TMessage[] = []; + if (runMessages) { + finalMessages = [...runMessages]; + } else if (isRegenerate && responseMessage) { + finalMessages = [...messages, responseMessage]; + } else if (requestMessage != null && responseMessage != null) { + finalMessages = [...messages, requestMessage, responseMessage]; + } + if (finalMessages.length > 0) { + setFinalMessages(conversation.conversationId, finalMessages); + } else if ( + isAssistantsEndpoint(submissionConvo.endpoint) && + (!submissionConvo.conversationId || + submissionConvo.conversationId === Constants.NEW_CONVO) + ) { + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [...currentMessages], + ); + } - /** Handle edge case where stream is cancelled before any response, which creates a blank page */ - if (!conversation.conversationId && hasNoResponse) { - const currentConvoId = - (submissionConvo.conversationId ?? conversation.conversationId) || Constants.NEW_CONVO; if (isNewConvo && submissionConvo.conversationId) { removeConvoFromAllQueries(queryClient, submissionConvo.conversationId); } - const isNewChat = - location.pathname === `/c/${Constants.NEW_CONVO}` && - currentConvoId === Constants.NEW_CONVO; - - setFinalMessages(currentConvoId, isNewChat ? [] : [...messages]); - setDraft({ id: currentConvoId, value: requestMessage?.text }); - setIsSubmitting(false); - if (isNewChat) { - navigate(`/c/${Constants.NEW_CONVO}`, { replace: true, state: { focusChat: true } }); + /* Refresh title */ + if ( + genTitle && + isNewConvo && + !isTemporary && + requestMessage && + requestMessage.parentMessageId === Constants.NO_PARENT + ) { + setTimeout(() => { + genTitle.mutate({ conversationId: conversation.conversationId as string }); + }, 2500); } - return; - } - /* Update messages; if assistants endpoint, client doesn't receive responseMessage */ - let finalMessages: TMessage[] = []; - if (runMessages) { - finalMessages = [...runMessages]; - } else if (isRegenerate && responseMessage) { - finalMessages = [...messages, responseMessage]; - } else if (requestMessage != null && responseMessage != null) { - finalMessages = [...messages, requestMessage, responseMessage]; - } - if (finalMessages.length > 0) { - setFinalMessages(conversation.conversationId, finalMessages); - } else if ( - isAssistantsEndpoint(submissionConvo.endpoint) && - (!submissionConvo.conversationId || submissionConvo.conversationId === Constants.NEW_CONVO) - ) { - queryClient.setQueryData( - [QueryKeys.messages, conversation.conversationId], - [...currentMessages], - ); - } - - if (isNewConvo && submissionConvo.conversationId) { - removeConvoFromAllQueries(queryClient, submissionConvo.conversationId); - } - - /* Refresh title */ - if ( - genTitle && - isNewConvo && - !isTemporary && - requestMessage && - requestMessage.parentMessageId === Constants.NO_PARENT - ) { - setTimeout(() => { - genTitle.mutate({ conversationId: conversation.conversationId as string }); - }, 2500); - } - - if (setConversation && isAddedRequest !== true) { - setConversation((prevState) => { - const update = { - ...prevState, - ...(conversation as TConversation), - }; - if (prevState?.model != null && prevState.model !== submissionConvo.model) { - update.model = prevState.model; - } - const cachedConvo = queryClient.getQueryData([ - QueryKeys.conversation, - conversation.conversationId, - ]); - if (!cachedConvo) { - queryClient.setQueryData([QueryKeys.conversation, conversation.conversationId], update); - } - return update; - }); - - if (conversation.conversationId && submission.ephemeralAgent) { - applyAgentTemplate({ - targetId: conversation.conversationId, - sourceId: submissionConvo.conversationId, - ephemeralAgent: submission.ephemeralAgent, - specName: submission.conversation?.spec, - startupConfig: queryClient.getQueryData([QueryKeys.startupConfig]), + if (setConversation && isAddedRequest !== true) { + setConversation((prevState) => { + const update = { + ...prevState, + ...(conversation as TConversation), + }; + if (prevState?.model != null && prevState.model !== submissionConvo.model) { + update.model = prevState.model; + } + const cachedConvo = queryClient.getQueryData([ + QueryKeys.conversation, + conversation.conversationId, + ]); + if (!cachedConvo) { + queryClient.setQueryData( + [QueryKeys.conversation, conversation.conversationId], + update, + ); + } + return update; }); - } - if (location.pathname === `/c/${Constants.NEW_CONVO}`) { - navigate(`/c/${conversation.conversationId}`, { replace: true }); + if (conversation.conversationId && submission.ephemeralAgent) { + applyAgentTemplate({ + targetId: conversation.conversationId, + sourceId: submissionConvo.conversationId, + ephemeralAgent: submission.ephemeralAgent, + specName: submission.conversation?.spec, + startupConfig: queryClient.getQueryData([QueryKeys.startupConfig]), + }); + } + + if (location.pathname === `/c/${Constants.NEW_CONVO}`) { + navigate(`/c/${conversation.conversationId}`, { replace: true }); + } } + } finally { + setShowStopButton(false); + setIsSubmitting(false); } - - setIsSubmitting(false); }, [ navigate, @@ -722,26 +728,37 @@ export default function useEventHandlers({ messages[messages.length - 2] != null ) { let requestMessage = messages[messages.length - 2]; - const responseMessage = messages[messages.length - 1]; - if (requestMessage.messageId !== responseMessage.parentMessageId) { + const _responseMessage = messages[messages.length - 1]; + if (requestMessage.messageId !== _responseMessage.parentMessageId) { // the request message is the parent of response, which we search for backwards for (let i = messages.length - 3; i >= 0; i--) { - if (messages[i].messageId === responseMessage.parentMessageId) { + if (messages[i].messageId === _responseMessage.parentMessageId) { requestMessage = messages[i]; break; } } } - finalHandler( - { - conversation: { - conversationId, + /** Sanitize content array to remove undefined parts from interrupted streaming */ + const responseMessage = { + ..._responseMessage, + content: _responseMessage.content?.filter((part) => part != null), + }; + try { + finalHandler( + { + conversation: { + conversationId, + }, + requestMessage, + responseMessage, }, - requestMessage, - responseMessage, - }, - submission, - ); + submission, + ); + } catch (error) { + console.error('Error in finalHandler during abort:', error); + setShowStopButton(false); + setIsSubmitting(false); + } return; } else if (!isAssistantsEndpoint(endpoint)) { const convoId = conversationId || `_${v4()}`; @@ -809,13 +826,14 @@ export default function useEventHandlers({ } }, [ - finalHandler, - newConversation, - setIsSubmitting, token, - cancelHandler, getMessages, setMessages, + finalHandler, + cancelHandler, + newConversation, + setIsSubmitting, + setShowStopButton, ], ); diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index f639e408bb..945c13b449 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -124,7 +124,13 @@ export default function useSSE( if (data.final != null) { clearDraft(submission.conversation?.conversationId); const { plugins } = data; - finalHandler(data, { ...submission, plugins } as EventSubmission); + try { + finalHandler(data, { ...submission, plugins } as EventSubmission); + } catch (error) { + console.error('Error in finalHandler:', error); + setIsSubmitting(false); + setShowStopButton(false); + } (startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch(); console.log('final', data); return; @@ -187,14 +193,20 @@ export default function useSSE( setCompleted((prev) => new Set(prev.add(streamKey))); const latestMessages = getMessages(); const conversationId = latestMessages?.[latestMessages.length - 1]?.conversationId; - return await abortConversation( - conversationId ?? - userMessage.conversationId ?? - submission.conversation?.conversationId ?? - '', - submission as EventSubmission, - latestMessages, - ); + try { + await abortConversation( + conversationId ?? + userMessage.conversationId ?? + submission.conversation?.conversationId ?? + '', + submission as EventSubmission, + latestMessages, + ); + } catch (error) { + console.error('Error during abort:', error); + setIsSubmitting(false); + setShowStopButton(false); + } }); sse.addEventListener('error', async (e: MessageEvent) => { diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 309b70ea85..52ae53a460 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -313,6 +313,10 @@ export default function useStepHandler({ ? messageDelta.delta.content[0] : messageDelta.delta.content; + if (contentPart == null) { + return; + } + const currentIndex = calculateContentIndex( runStep.index, initialContent, @@ -345,6 +349,10 @@ export default function useStepHandler({ ? reasoningDelta.delta.content[0] : reasoningDelta.delta.content; + if (contentPart == null) { + return; + } + const currentIndex = calculateContentIndex( runStep.index, initialContent, diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index d436c45077..bd763f3bc2 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -44,7 +44,7 @@ export const getAllContentText = (message?: TMessage | null): string => { if (message.content && message.content.length > 0) { return message.content - .filter((part) => part.type === ContentTypes.TEXT) + .filter((part) => part != null && part.type === ContentTypes.TEXT) .map((part) => { if (!('text' in part)) return ''; const text = part.text;