From 1c2801de2e2cbea7d2327f03f4f8c48d79f44051 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Fri, 8 Nov 2024 08:34:10 +0000 Subject: [PATCH] adss use history utility hook and fixes convo change issues --- .../src/components/Conversations/FileItem.tsx | 2 - .../Conversations/InteractHistory.tsx | 235 ++++-------------- .../Conversations/InteractInput.tsx | 44 +--- .../Conversations/InteractMessage.tsx | 1 - .../src/components/FrontDoor/Chat/Chat.tsx | 84 ++----- .../Workflows/WorkflowConversation.tsx | 74 +++--- workbench-app/src/libs/Utility.ts | 10 + .../src/libs/useConversationUtility.ts | 15 +- workbench-app/src/libs/useHistoryUtility.ts | 206 +++++++++++++++ workbench-app/src/routes/Interact.tsx | 100 ++------ .../src/services/workbench/conversation.ts | 91 +++++-- 11 files changed, 432 insertions(+), 430 deletions(-) create mode 100644 workbench-app/src/libs/useHistoryUtility.ts diff --git a/workbench-app/src/components/Conversations/FileItem.tsx b/workbench-app/src/components/Conversations/FileItem.tsx index bd51c6c8..f9c76254 100644 --- a/workbench-app/src/components/Conversations/FileItem.tsx +++ b/workbench-app/src/components/Conversations/FileItem.tsx @@ -81,7 +81,6 @@ export const FileItem: React.FC = (props) => { // Check if the browser supports pipeTo (most modern browsers do) if (readableStream.pipeTo) { await readableStream.pipeTo(fileStream); - console.log('Download complete'); } else { // Fallback for browsers that don't support pipeTo const reader = readableStream.getReader(); @@ -97,7 +96,6 @@ export const FileItem: React.FC = (props) => { }); await pump(); - console.log('Download complete'); } }; diff --git a/workbench-app/src/components/Conversations/InteractHistory.tsx b/workbench-app/src/components/Conversations/InteractHistory.tsx index b25fc4e5..69316a56 100644 --- a/workbench-app/src/components/Conversations/InteractHistory.tsx +++ b/workbench-app/src/components/Conversations/InteractHistory.tsx @@ -10,14 +10,10 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Constants } from '../../Constants'; import { Utility } from '../../libs/Utility'; -import { useConversationEvents } from '../../libs/useConversationEvents'; import { useConversationUtility } from '../../libs/useConversationUtility'; import { Conversation } from '../../models/Conversation'; import { ConversationMessage } from '../../models/ConversationMessage'; import { ConversationParticipant } from '../../models/ConversationParticipant'; -import { useAppDispatch } from '../../redux/app/hooks'; -import { conversationApi, updateGetConversationParticipantsQueryData } from '../../services/workbench'; -import { Loading } from '../App/Loading'; import { MemoizedInteractMessage } from './InteractMessage'; import { ParticipantStatus } from './ParticipantStatus'; @@ -50,125 +46,21 @@ const useClasses = makeStyles({ interface InteractHistoryProps { conversation: Conversation; + messages: ConversationMessage[]; participants: ConversationParticipant[]; readOnly: boolean; className?: string; + onRewindToBefore?: (message: ConversationMessage, redo: boolean) => Promise; } export const InteractHistory: React.FC = (props) => { - const { conversation, participants, readOnly, className } = props; + const { conversation, messages, participants, readOnly, className, onRewindToBefore } = props; const classes = useClasses(); const { hash } = useLocation(); - const [messages, setMessages] = React.useState(); - const [isLoadingMessages, setIsLoadingMessages] = React.useState(false); - const [isAtBottom, setIsAtBottom] = React.useState(true); - const [newestMessageId, setNewestMessageId] = React.useState(); - const [hashItemIndex, setHashItemIndex] = React.useState(); const { setLastRead } = useConversationUtility(); - const dispatch = useAppDispatch(); - - // helper for adding messages to the end of the messages state - const appendMessages = React.useCallback( - (newMessages: ConversationMessage[]) => { - // update the messages state with the new messages, placing the new messages at the end - setMessages((prevMessages) => (prevMessages ? [...prevMessages, ...newMessages] : newMessages)); - - // update the newest message id for use with the 'after' parameter in the next request - setNewestMessageId(newMessages[newMessages.length - 1].id); - }, - [setMessages], - ); - - // handler for when a new message is created - const onMessageCreated = React.useCallback( - async () => - // load the latest messages and append them to the messages state - appendMessages( - await dispatch( - conversationApi.endpoints.getConversationMessages.initiate({ - conversationId: conversation.id, - limit: Constants.app.maxMessagesPerRequest, - after: newestMessageId, - }), - ).unwrap(), - ), - [appendMessages, dispatch, conversation.id, newestMessageId], - ); - - // handler for when a message is deleted - const onMessageDeleted = React.useCallback( - (messageId: string) => - // remove the message from the messages state - setMessages((prevMessages) => { - if (!prevMessages) { - return prevMessages; - } - return prevMessages.filter((message) => message.id !== messageId); - }), - [], - ); - - // handler for when a new participant is created - const onParticipantCreated = React.useCallback( - (participant: ConversationParticipant) => - // add the new participant to the cached participants - dispatch(updateGetConversationParticipantsQueryData(conversation.id, { participant, participants })), - [dispatch, conversation.id, participants], - ); - - // handler for when a participant is updated - const onParticipantUpdated = React.useCallback( - (participant: ConversationParticipant) => - // update the participant in the cached participants - dispatch(updateGetConversationParticipantsQueryData(conversation.id, { participant, participants })), - [dispatch, conversation.id, participants], - ); - - // subscribe to conversation events - useConversationEvents(conversation.id, { - onMessageCreated, - onMessageDeleted, - onParticipantCreated, - onParticipantUpdated, - }); - - // load all messages for the conversation - const loadMessages = React.useCallback(async () => { - let mayHaveEarlierMessages = true; - let allMessages: ConversationMessage[] = []; - let before: string | undefined; - - // load messages in chunks until we have loaded all the messages - while (mayHaveEarlierMessages) { - const response = await dispatch( - conversationApi.endpoints.getConversationMessages.initiate({ - conversationId: conversation.id, - limit: Constants.app.maxMessagesPerRequest, - before, - }), - ).unwrap(); - allMessages = [...response, ...allMessages]; - mayHaveEarlierMessages = response.length === Constants.app.maxMessagesPerRequest; - before = response[0]?.id; - } - - // set the messages state with all the messages - setMessages(allMessages); - - // set the newest message id for use with the 'after' parameter in the next request - setNewestMessageId(allMessages[allMessages.length - 1].id); - - // set loading messages to false - setIsLoadingMessages(false); - }, [dispatch, conversation.id]); - - // load initial messages - React.useEffect(() => { - if (!messages && !isLoadingMessages) { - setIsLoadingMessages(true); - loadMessages(); - } - }, [messages, loadMessages, isLoadingMessages]); + const [scrollToIndex, setScrollToIndex] = React.useState(); + const [items, setItems] = React.useState([]); + const [isAtBottom, setIsAtBottom] = React.useState(true); // handler for when a message is read const handleOnRead = React.useCallback( @@ -177,53 +69,36 @@ export const InteractHistory: React.FC = (props) => { [setLastRead, conversation], ); - // handler for when a conversation is rewound - const handleOnRewind = React.useCallback( - async (message: ConversationMessage, redo: boolean) => { - if (!messages) { - return; - } + // create a ref for the virtuoso component for using its methods directly + const virtuosoRef = React.useRef(null); - // find the index of the message to rewind to - const messageIndex = messages?.findIndex((possibleMessage) => possibleMessage.id === message.id); + // set the scrollToIndex to the last item if the user is at the bottom of the history + const triggerAutoScroll = React.useCallback(() => { + if (isAtBottom) { + setScrollToIndex(items.length - 1); + } + }, [isAtBottom, items.length]); - // if the message is not found, do nothing - if (messageIndex === -1) { - return; - } + // trigger auto scroll when the items change + React.useEffect(() => { + triggerAutoScroll(); + }, [items, triggerAutoScroll]); - // delete all messages from the message to the end of the conversation - for (let i = messageIndex; i < messages.length; i++) { - await dispatch( - conversationApi.endpoints.deleteConversationMessage.initiate({ - conversationId: conversation.id, - messageId: messages[i].id, - }), - ); - } + // scroll to the bottom of the history when the scrollToIndex changes + React.useEffect(() => { + if (!scrollToIndex) { + return; + } - // if redo is true, create a new message with the same content as the message to redo - if (redo) { - await dispatch( - conversationApi.endpoints.createConversationMessage.initiate({ - conversationId: conversation.id, - ...message, - }), - ); - } - }, - [conversation.id, dispatch, messages], - ); + const index = scrollToIndex; + setScrollToIndex(undefined); - // create a ref for the virtuoso component for using its methods directly - const virtuosoRef = React.useRef(null); + // wait a tick for the DOM to update + setTimeout(() => virtuosoRef.current?.scrollToIndex({ index, align: 'start' }), 0); + }, [scrollToIndex]); // create a list of memoized interact message components for rendering in the virtuoso component - const items = React.useMemo(() => { - if (!messages) { - return []; - } - + React.useEffect(() => { let lastMessageInfo = { participantId: '', attribution: undefined as string | undefined, @@ -236,8 +111,9 @@ export const InteractHistory: React.FC = (props) => { .filter((message) => message.messageType !== 'log') .map((message, index) => { // if a hash is provided, check if the message id matches the hash - if (hash && hashItemIndex === undefined && hash === `#${message.id}`) { - setHashItemIndex(index); + if (hash === `#${message.id}`) { + // set the hash item index to scroll to the item + setScrollToIndex(index); } const senderParticipant = participants.find( @@ -300,7 +176,7 @@ export const InteractHistory: React.FC = (props) => { hideParticipant={hideParticipant} displayDate={displayDate} onRead={handleOnRead} - onRewind={handleOnRewind} + onRewind={onRewindToBefore} /> ); @@ -318,50 +194,27 @@ export const InteractHistory: React.FC = (props) => { updatedItems.push(
- { - if (isAtBottom) { - // wait a tick for the DOM to update - setTimeout(() => { - virtuosoRef.current?.scrollToIndex({ index: items.length - 1, align: 'start' }); - }, 0); - } - }} - /> + triggerAutoScroll()} />
, ); - return updatedItems; + setItems(updatedItems); }, [ - classes.counter, - classes.item, + messages, classes.status, + classes.item, + classes.counter, + participants, + hash, + readOnly, conversation, handleOnRead, - handleOnRewind, - hash, - hashItemIndex, + onRewindToBefore, isAtBottom, - messages, - participants, - readOnly, + items.length, + triggerAutoScroll, ]); - // if hash index is set, scroll to the hash item - React.useEffect(() => { - if (hashItemIndex !== undefined) { - setTimeout(() => { - virtuosoRef.current?.scrollToIndex({ index: hashItemIndex, align: 'start' }); - }, 0); - } - }, [hashItemIndex]); - - // if messages are not loaded, show a loading spinner - if (isLoadingMessages) { - return ; - } - // render the history return ( diff --git a/workbench-app/src/components/Conversations/InteractInput.tsx b/workbench-app/src/components/Conversations/InteractInput.tsx index 2d6551a5..904e709c 100644 --- a/workbench-app/src/components/Conversations/InteractInput.tsx +++ b/workbench-app/src/components/Conversations/InteractInput.tsx @@ -30,13 +30,13 @@ import { Constants } from '../../Constants'; import useDragAndDrop from '../../libs/useDragAndDrop'; import { useNotify } from '../../libs/useNotify'; import { AssistantCapability } from '../../models/AssistantCapability'; +import { Conversation } from '../../models/Conversation'; +import { ConversationMessage } from '../../models/ConversationMessage'; import { ConversationParticipant } from '../../models/ConversationParticipant'; import { useAppDispatch, useAppSelector } from '../../redux/app/hooks'; import { updateGetConversationMessagesQueryData, useCreateConversationMessageMutation, - useGetConversationMessagesQuery, - useGetConversationParticipantsQuery, useUploadConversationFilesMutation, } from '../../services/workbench'; import { ClearEditorPlugin } from './ChatInputPlugins/ClearEditorPlugin'; @@ -106,7 +106,9 @@ const useClasses = makeStyles({ }); interface InteractInputProps { - conversationId: string; + conversation: Conversation; + messages: ConversationMessage[]; + participants: ConversationParticipant[]; additionalContent?: React.ReactNode; readOnly: boolean; assistantCapabilities: Set; @@ -133,7 +135,7 @@ class TemporaryTextNode extends TextNode { } export const InteractInput: React.FC = (props) => { - const { conversationId, additionalContent, readOnly, assistantCapabilities } = props; + const { conversation, messages, participants, additionalContent, readOnly, assistantCapabilities } = props; const classes = useClasses(); const dropTargetRef = React.useRef(null); const localUserId = useAppSelector((state) => state.localUser.id); @@ -153,28 +155,6 @@ export const InteractInput: React.FC = (props) => { const { notifyWarning } = useNotify(); const dispatch = useAppDispatch(); - const { - data: conversationMessages, - isLoading: isConversationMessagesLoading, - error: conversationMessagesError, - } = useGetConversationMessagesQuery({ conversationId }); - - const { - data: participants, - isLoading: isParticipantsLoading, - error: participantsError, - } = useGetConversationParticipantsQuery(conversationId); - - if (conversationMessagesError) { - const errorMessage = JSON.stringify(conversationMessagesError); - console.error(`Failed to load conversation messages: ${errorMessage}`); - } - - if (participantsError) { - const errorMessage = JSON.stringify(participantsError); - console.error(`Failed to load conversation participants: ${errorMessage}`); - } - const editorRefCallback = React.useCallback((editor: LexicalEditor) => { editorRef.current = editor; @@ -293,10 +273,6 @@ export const InteractInput: React.FC = (props) => { [addAttachments], ); - if (isConversationMessagesLoading || isParticipantsLoading) { - return null; - } - const handleSend = (_event: ChatInputSubmitEvents, data: EditorInputValueData) => { if (data.value.trim() === '' || isSubmitting) { return; @@ -341,8 +317,8 @@ export const InteractInput: React.FC = (props) => { // need to define the extra fields for the message such as sender, timestamp, etc. // so that the message can be rendered correctly dispatch( - updateGetConversationMessagesQueryData(conversationId, [ - ...(conversationMessages ?? []), + updateGetConversationMessagesQueryData(conversation.id, [ + ...(messages ?? []), { id: 'optimistic', sender: { @@ -372,12 +348,12 @@ export const InteractInput: React.FC = (props) => { attachmentInputRef.current.value = ''; } if (files) { - await uploadConversationFiles({ conversationId, files }); + await uploadConversationFiles({ conversationId: conversation.id, files }); } // create the message await createMessage({ - conversationId, + conversationId: conversation.id, content, messageType, filenames, diff --git a/workbench-app/src/components/Conversations/InteractMessage.tsx b/workbench-app/src/components/Conversations/InteractMessage.tsx index 9fab562d..7e71eff0 100644 --- a/workbench-app/src/components/Conversations/InteractMessage.tsx +++ b/workbench-app/src/components/Conversations/InteractMessage.tsx @@ -245,7 +245,6 @@ export const InteractMessage: React.FC = (props) => { debug={message.hasDebugData ? debugData?.debugData || { loading: true } : undefined} loading={isLoadingDebugData || isUninitializedDebugData} onOpen={() => { - console.log('OPEN!'); setSkipDebugLoad(false); }} /> diff --git a/workbench-app/src/components/FrontDoor/Chat/Chat.tsx b/workbench-app/src/components/FrontDoor/Chat/Chat.tsx index b44bdf8b..8d2ce384 100644 --- a/workbench-app/src/components/FrontDoor/Chat/Chat.tsx +++ b/workbench-app/src/components/FrontDoor/Chat/Chat.tsx @@ -3,17 +3,11 @@ import { makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; import { Constants } from '../../../Constants'; -import { useGetAssistantCapabilities } from '../../../libs/useAssistantCapabilities'; +import { useHistoryUtility } from '../../../libs/useHistoryUtility'; import { useParticipantUtility } from '../../../libs/useParticipantUtility'; import { useSiteUtility } from '../../../libs/useSiteUtility'; import { Assistant } from '../../../models/Assistant'; import { useAppSelector } from '../../../redux/app/hooks'; -import { - useGetAssistantsInConversationQuery, - useGetConversationFilesQuery, - useGetConversationParticipantsQuery, - useGetConversationQuery, -} from '../../../services/workbench'; import { ExperimentalNotice } from '../../App/ExperimentalNotice'; import { Loading } from '../../App/Loading'; import { ConversationShare } from '../../Conversations/ConversationShare'; @@ -126,56 +120,28 @@ export const Chat: React.FC = (props) => { const { conversationId, headerBefore, headerAfter } = props; const classes = useClasses(); const { sortParticipants } = useParticipantUtility(); - const localUserId = useAppSelector((state) => state.localUser.id); const siteUtility = useSiteUtility(); + const localUserId = useAppSelector((state) => state.localUser.id); const { - data: conversation, - error: conversationError, - isLoading: conversationIsLoading, - } = useGetConversationQuery(conversationId, { refetchOnMountOrArgChange: true }); - const { - data: conversationParticipants, - error: conversationParticipantsError, - isLoading: conversationParticipantsIsLoading, - } = useGetConversationParticipantsQuery(conversationId); - const { - data: assistants, - error: assistantsError, - isLoading: assistantsIsLoading, - refetch: assistantsRefetch, - } = useGetAssistantsInConversationQuery(conversationId); - const { - data: conversationFiles, - error: conversationFilesError, - isLoading: conversationFilesIsLoading, - } = useGetConversationFilesQuery(conversationId); - - const { data: assistantCapabilities, isFetching: assistantCapabilitiesIsFetching } = useGetAssistantCapabilities( - assistants ?? [], - { skip: assistantsIsLoading || assistantsError !== undefined }, - ); + conversation, + allConversationMessages, + conversationParticipants, + assistants, + conversationFiles, + assistantCapabilities, + error: historyError, + isLoading: historyIsLoading, + assistantsRefetch, + assistantCapabilitiesIsFetching, + rewindToBefore, + } = useHistoryUtility(conversationId); - if (conversationError) { - const errorMessage = JSON.stringify(conversationError); + if (historyError) { + const errorMessage = JSON.stringify(historyError); throw new Error(`Error loading conversation (${conversationId}): ${errorMessage}`); } - if (conversationParticipantsError) { - const errorMessage = JSON.stringify(conversationParticipantsError); - throw new Error(`Error loading conversation participants (${conversationId}): ${errorMessage}`); - } - - if (assistantsError) { - const errorMessage = JSON.stringify(assistantsError); - throw new Error(`Error loading assistants (${conversationId}): ${errorMessage}`); - } - - if (conversationFilesError) { - const errorMessage = JSON.stringify(conversationFilesError); - throw new Error(`Error loading conversation files (${conversationId}): ${errorMessage}`); - } - React.useEffect(() => { if (conversation) { siteUtility.setDocumentTitle(conversation.title); @@ -211,13 +177,7 @@ export const Chat: React.FC = (props) => { return results.sort((a, b) => a.name.localeCompare(b.name)); }, [assistants, conversationParticipants, assistantsRefetch]); - if ( - conversationIsLoading || - conversationParticipantsIsLoading || - assistantsIsLoading || - conversationFilesIsLoading || - assistantCapabilitiesIsFetching - ) { + if (historyIsLoading || assistantCapabilitiesIsFetching) { return ; } @@ -225,6 +185,10 @@ export const Chat: React.FC = (props) => { throw new Error(`Conversation (${conversationId}) not found`); } + if (!allConversationMessages) { + throw new Error(`All conversation messages (${conversationId}) not found`); + } + if (!conversationParticipants) { throw new Error(`Conversation participants (${conversationId}) not found`); } @@ -269,14 +233,18 @@ export const Chat: React.FC = (props) => { className={classes.historyRoot} readOnly={readOnly} conversation={conversation} + messages={allConversationMessages} participants={conversationParticipants} + onRewindToBefore={rewindToBefore} />
diff --git a/workbench-app/src/components/Workflows/WorkflowConversation.tsx b/workbench-app/src/components/Workflows/WorkflowConversation.tsx index 09e3e653..0a2be597 100644 --- a/workbench-app/src/components/Workflows/WorkflowConversation.tsx +++ b/workbench-app/src/components/Workflows/WorkflowConversation.tsx @@ -17,19 +17,15 @@ import { Constants } from '../../Constants'; import { InteractHistory } from '../../components/Conversations/InteractHistory'; import { InteractInput } from '../../components/Conversations/InteractInput'; import { WorkbenchEventSource, WorkbenchEventSourceType } from '../../libs/WorkbenchEventSource'; -import { useGetAssistantCapabilities } from '../../libs/useAssistantCapabilities'; import { useChatCanvasController } from '../../libs/useChatCanvasController'; import { useEnvironment } from '../../libs/useEnvironment'; +import { useHistoryUtility } from '../../libs/useHistoryUtility'; import { useSiteUtility } from '../../libs/useSiteUtility'; import { WorkflowDefinition } from '../../models/WorkflowDefinition'; import { WorkflowRun } from '../../models/WorkflowRun'; import { useAppDispatch, useAppSelector } from '../../redux/app/hooks'; import { setChatWidthPercent } from '../../redux/features/app/appSlice'; -import { - useGetConversationParticipantsQuery, - useGetConversationQuery, - useGetWorkflowRunAssistantsQuery, -} from '../../services/workbench'; +import { useGetWorkflowRunAssistantsQuery } from '../../services/workbench'; import { Loading } from '../App/Loading'; import { ConversationCanvas } from '../Conversations/Canvas/ConversationCanvas'; import { WorkflowControl } from './WorkflowControl'; @@ -137,45 +133,37 @@ export const WorkflowConversation: React.FC = (props) const chatCanvasController = useChatCanvasController(); const animationFrame = React.useRef(0); const resizeHandleRef = React.useRef(null); + const [isResizing, setIsResizing] = React.useState(false); + const siteUtility = useSiteUtility(); + const environment = useEnvironment(); const { data: workflowRunAssistants, isLoading: isLoadingWorkflowRunAssistants, error: workflowRunAssistantsError, } = useGetWorkflowRunAssistantsQuery(workflowRun.id); - const { - currentData: conversation, - isLoading: isLoadingConversation, - error: conversationError, - } = useGetConversationQuery(conversationId); - const { - currentData: participants, - isLoading: isLoadingParticipants, - error: participantsError, - } = useGetConversationParticipantsQuery(conversationId, { refetchOnMountOrArgChange: true }); - const { data: assistantCapabilities, isFetching: isFetchingAssistantCapabilities } = useGetAssistantCapabilities( - workflowRunAssistants ?? [], - ); - const [isResizing, setIsResizing] = React.useState(false); - const siteUtility = useSiteUtility(); - const environment = useEnvironment(); - - if (conversationError) { - const errorMessage = JSON.stringify(conversationError); - throw new Error(`Error loading conversation: ${errorMessage}`); - } - - if (participantsError) { - const errorMessage = JSON.stringify(participantsError); - throw new Error(`Error loading participants: ${errorMessage}`); - } + const { + conversation, + allConversationMessages, + conversationParticipants, + assistants, + assistantCapabilities, + error: historyError, + isLoading: historyIsLoading, + assistantCapabilitiesIsFetching, + } = useHistoryUtility(conversationId); if (workflowRunAssistantsError) { const errorMessage = JSON.stringify(workflowRunAssistantsError); throw new Error(`Error loading workflow run assistants: ${errorMessage}`); } + if (historyError) { + const errorMessage = JSON.stringify(historyError); + throw new Error(`Error loading conversation (${conversationId}): ${errorMessage}`); + } + React.useEffect(() => { if (conversation) { siteUtility.setDocumentTitle(conversation.title); @@ -245,12 +233,13 @@ export const WorkflowConversation: React.FC = (props) if ( isLoadingWorkflowRunAssistants || - isLoadingConversation || - isLoadingParticipants || - isFetchingAssistantCapabilities || + historyIsLoading || + assistantCapabilitiesIsFetching || !assistantCapabilities || !conversation || - !participants || + !allConversationMessages || + !conversationParticipants || + !assistants || !workflowRunAssistants ) { return ; @@ -276,13 +265,20 @@ export const WorkflowConversation: React.FC = (props) : classes.historyContent } > - +
@@ -319,7 +315,7 @@ export const WorkflowConversation: React.FC = (props) readOnly={readOnly} conversation={conversation} conversationFiles={[]} - conversationParticipants={participants} + conversationParticipants={conversationParticipants} preventAssistantModifyOnParticipantIds={workflowRunAssistants?.map((assistant) => assistant.id)} /> )} diff --git a/workbench-app/src/libs/Utility.ts b/workbench-app/src/libs/Utility.ts index 17aa16e9..0a0d1a39 100644 --- a/workbench-app/src/libs/Utility.ts +++ b/workbench-app/src/libs/Utility.ts @@ -173,6 +173,15 @@ const errorToMessageString = (error?: Record | string) => { return message; }; +const withStatus = async (setStatus: (status: boolean) => void, callback: () => Promise): Promise => { + setStatus(true); + try { + return await callback(); + } finally { + setStatus(false); + } +}; + export const Utility = { deepEqual, deepCopy, @@ -185,4 +194,5 @@ export const Utility = { getTimestampForFilename, sortKeys, errorToMessageString, + withStatus, }; diff --git a/workbench-app/src/libs/useConversationUtility.ts b/workbench-app/src/libs/useConversationUtility.ts index 98c8a358..1bce7084 100644 --- a/workbench-app/src/libs/useConversationUtility.ts +++ b/workbench-app/src/libs/useConversationUtility.ts @@ -24,7 +24,7 @@ interface ParticipantAppMetadata { } export const useConversationUtility = () => { - const [isMessageVisible, setIsVisible] = React.useState(false); + const [isMessageVisible, setIsMessageVisible] = React.useState(false); const isMessageVisibleRef = React.useRef(null); const [updateConversation] = useUpdateConversationMutation(); const localUserId = useAppSelector((state) => state.localUser.id); @@ -161,7 +161,7 @@ export const useConversationUtility = () => { React.useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { - setIsVisible(entry.isIntersecting); + setIsMessageVisible(entry.isIntersecting); }, { threshold: 0.1 }, ); @@ -210,7 +210,7 @@ export const useConversationUtility = () => { (conversation: Conversation, messageTimestamp: string) => { const lastReadTimestamp = getLastReadTimestamp(conversation); if (!lastReadTimestamp) { - return false; + return true; } return messageTimestamp > lastReadTimestamp; }, @@ -255,8 +255,8 @@ export const useConversationUtility = () => { ); const setLastRead = React.useCallback( - async (conversation: Conversation | Conversation[], messageTimestamp: string) => { - const debouncedFunction = Utility.debounce(async () => { + async (conversation: Conversation | Conversation[], messageTimestamp: string) => + Utility.debounce(async () => { if (Array.isArray(conversation)) { await Promise.all( conversation.map((c) => setAppMetadata(c, { lastReadTimestamp: messageTimestamp })), @@ -264,10 +264,7 @@ export const useConversationUtility = () => { return; } await setAppMetadata(conversation, { lastReadTimestamp: messageTimestamp }); - }, 300); - - debouncedFunction(); - }, + }, 300), [setAppMetadata], ); diff --git a/workbench-app/src/libs/useHistoryUtility.ts b/workbench-app/src/libs/useHistoryUtility.ts new file mode 100644 index 00000000..e4aee57e --- /dev/null +++ b/workbench-app/src/libs/useHistoryUtility.ts @@ -0,0 +1,206 @@ +import React from 'react'; +import { Constants } from '../Constants'; +import { ConversationMessage } from '../models/ConversationMessage'; +import { ConversationParticipant } from '../models/ConversationParticipant'; +import { useAppDispatch } from '../redux/app/hooks'; +import { + conversationApi, + updateGetAllConversationMessagesQueryData, + updateGetConversationParticipantsQueryData, + useGetAllConversationMessagesQuery, + useGetAssistantsInConversationQuery, + useGetConversationFilesQuery, + useGetConversationParticipantsQuery, + useGetConversationQuery, +} from '../services/workbench'; +import { useGetAssistantCapabilities } from './useAssistantCapabilities'; +import { useConversationEvents } from './useConversationEvents'; + +export const useHistoryUtility = (conversationId: string) => { + const dispatch = useAppDispatch(); + + const { + data: conversation, + error: conversationError, + isLoading: conversationIsLoading, + } = useGetConversationQuery(conversationId, { refetchOnMountOrArgChange: true }); + const { + data: allConversationMessages, + error: allConversationMessagesError, + isLoading: allConversationMessagesIsLoading, + } = useGetAllConversationMessagesQuery({ + conversationId, + limit: Constants.app.maxMessagesPerRequest, + }); + const { + data: conversationParticipants, + error: conversationParticipantsError, + isLoading: conversationParticipantsIsLoading, + } = useGetConversationParticipantsQuery(conversationId); + const { + data: assistants, + error: assistantsError, + isLoading: assistantsIsLoading, + refetch: assistantsRefetch, + } = useGetAssistantsInConversationQuery(conversationId); + const { + data: conversationFiles, + error: conversationFilesError, + isLoading: conversationFilesIsLoading, + } = useGetConversationFilesQuery(conversationId); + + const { data: assistantCapabilities, isFetching: assistantCapabilitiesIsFetching } = useGetAssistantCapabilities( + assistants ?? [], + { skip: assistantsIsLoading || assistantsError !== undefined }, + ); + + const error = + conversationError || + allConversationMessagesError || + conversationParticipantsError || + assistantsError || + conversationFilesError; + + const isLoading = + conversationIsLoading || + allConversationMessagesIsLoading || + conversationParticipantsIsLoading || + assistantsIsLoading || + conversationFilesIsLoading; + + // region Events + + // handler for when a new message is created + const onMessageCreated = React.useCallback(async () => { + if (!allConversationMessages) { + return; + } + + const lastMessageId = allConversationMessages[allConversationMessages.length - 1]?.id; + const newMessages = await dispatch( + conversationApi.endpoints.getAllConversationMessages.initiate({ + conversationId, + limit: Constants.app.maxMessagesPerRequest, + after: lastMessageId, + }), + ).unwrap(); + const updatedMessages = [...allConversationMessages, ...newMessages]; + + // update the cache with the new messages + dispatch( + updateGetAllConversationMessagesQueryData( + { conversationId, limit: Constants.app.maxMessagesPerRequest }, + updatedMessages, + ), + ); + }, [allConversationMessages, conversationId, dispatch]); + + // handler for when a message is deleted + const onMessageDeleted = React.useCallback( + (messageId: string) => { + if (!allConversationMessages) { + return; + } + + const updatedMessages = allConversationMessages.filter((message) => message.id !== messageId); + + // remove the message from the messages state + dispatch( + updateGetAllConversationMessagesQueryData( + { conversationId, limit: Constants.app.maxMessagesPerRequest }, + updatedMessages, + ), + ); + }, + [allConversationMessages, conversationId, dispatch], + ); + + // handler for when a new participant is created + const onParticipantCreated = React.useCallback( + (participant: ConversationParticipant) => + // add the new participant to the cached participants + dispatch( + updateGetConversationParticipantsQueryData(conversationId, { participant, conversationParticipants }), + ), + [dispatch, conversationId, conversationParticipants], + ); + + // handler for when a participant is updated + const onParticipantUpdated = React.useCallback( + (participant: ConversationParticipant) => + // update the participant in the cached participants + dispatch( + updateGetConversationParticipantsQueryData(conversationId, { participant, conversationParticipants }), + ), + [dispatch, conversationId, conversationParticipants], + ); + + // subscribe to conversation events + useConversationEvents(conversationId, { + onMessageCreated, + onMessageDeleted, + onParticipantCreated, + onParticipantUpdated, + }); + + // endregion + + // region Rewind + + const rewindToBefore = React.useCallback( + async (message: ConversationMessage, redo: boolean) => { + if (!allConversationMessages) { + return; + } + + // find the index of the message to rewind to + const messageIndex = allConversationMessages.findIndex( + (possibleMessage) => possibleMessage.id === message.id, + ); + + // if the message is not found, do nothing + if (messageIndex === -1) { + return; + } + + // delete all messages from the message to the end of the conversation + for (let i = messageIndex; i < allConversationMessages.length; i++) { + await dispatch( + conversationApi.endpoints.deleteConversationMessage.initiate({ + conversationId, + messageId: allConversationMessages[i].id, + }), + ); + } + + // if redo is true, create a new message with the same content as the message to redo + if (redo) { + await dispatch( + conversationApi.endpoints.createConversationMessage.initiate({ + conversationId, + ...message, + }), + ); + } + }, + [allConversationMessages, conversationId, dispatch], + ); + + // endregion + + // add more messages related utility functions here, separated by region if applicable + + return { + conversation, + allConversationMessages, + conversationParticipants, + assistants, + conversationFiles, + assistantCapabilities, + error, + isLoading, + assistantsRefetch, + assistantCapabilitiesIsFetching, + rewindToBefore, + }; +}; diff --git a/workbench-app/src/routes/Interact.tsx b/workbench-app/src/routes/Interact.tsx index 21b808df..03c65ecf 100644 --- a/workbench-app/src/routes/Interact.tsx +++ b/workbench-app/src/routes/Interact.tsx @@ -11,16 +11,10 @@ import { ConversationRename } from '../components/Conversations/ConversationRena import { ConversationShare } from '../components/Conversations/ConversationShare'; import { InteractHistory } from '../components/Conversations/InteractHistory'; import { InteractInput } from '../components/Conversations/InteractInput'; -import { useGetAssistantCapabilities } from '../libs/useAssistantCapabilities'; +import { useHistoryUtility } from '../libs/useHistoryUtility'; import { useSiteUtility } from '../libs/useSiteUtility'; import { Assistant } from '../models/Assistant'; import { useAppSelector } from '../redux/app/hooks'; -import { - useGetAssistantsInConversationQuery, - useGetConversationFilesQuery, - useGetConversationParticipantsQuery, - useGetConversationQuery, -} from '../services/workbench'; const useClasses = makeStyles({ root: { @@ -76,68 +70,25 @@ export const Interact: React.FC = () => { } const classes = useClasses(); - const { - data: assistants, - error: assistantsError, - isLoading: isLoadingAssistants, - refetch: refetchAssistants, - } = useGetAssistantsInConversationQuery(conversationId); - const { - data: conversation, - error: conversationError, - isLoading: isLoadingConversation, - } = useGetConversationQuery(conversationId); - const { - data: conversationParticipants, - error: conversationParticipantsError, - isLoading: isLoadingConversationParticipants, - } = useGetConversationParticipantsQuery(conversationId); - const { - data: conversationFiles, - error: conversationFilesError, - isLoading: isLoadingConversationFiles, - } = useGetConversationFilesQuery(conversationId); const localUserId = useAppSelector((state) => state.localUser.id); - const { data: assistantCapabilities, isFetching: isFetchingAssistantCapabilities } = useGetAssistantCapabilities( - assistants ?? [], - { skip: isLoadingAssistants || assistantsError !== undefined }, - ); - const siteUtility = useSiteUtility(); - if (assistantsError) { - const errorMessage = JSON.stringify(assistantsError); - throw new Error(`Error loading assistants: ${errorMessage}`); - } - - if (conversationError) { - const errorMessage = JSON.stringify(conversationError); - throw new Error(`Error loading conversation: ${errorMessage}`); - } - - if (conversationParticipantsError) { - const errorMessage = JSON.stringify(conversationParticipantsError); - throw new Error(`Error loading participants: ${errorMessage}`); - } - - if (conversationFilesError) { - const errorMessage = JSON.stringify(conversationFilesError); - throw new Error(`Error loading conversation files: ${errorMessage}`); - } - - if (!isLoadingConversation && !conversation) { - const errorMessage = `No conversation loaded for ${conversationId}`; - throw new Error(errorMessage); - } - - if (!isLoadingConversationParticipants && !conversationParticipants) { - const errorMessage = `No participants loaded for ${conversationId}`; - throw new Error(errorMessage); - } - - if (!isLoadingConversationFiles && !conversationFiles) { - const errorMessage = `No conversation files loaded for ${conversationId}`; - throw new Error(errorMessage); + const { + conversation, + allConversationMessages, + conversationParticipants, + assistants, + conversationFiles, + assistantCapabilities, + error: historyError, + isLoading: historyIsLoading, + assistantsRefetch, + assistantCapabilitiesIsFetching, + } = useHistoryUtility(conversationId); + + if (historyError) { + const errorMessage = JSON.stringify(historyError); + throw new Error(`Error loading conversation (${conversationId}): ${errorMessage}`); } React.useEffect(() => { @@ -166,24 +117,22 @@ export const Interact: React.FC = () => { results.push(assistant); } else { // If the assistant is not found, refetch the assistants - refetchAssistants(); + assistantsRefetch(); // Return early to avoid returning an incomplete list of assistants return; } } return results.sort((a, b) => a.name.localeCompare(b.name)); - }, [assistants, conversationParticipants, refetchAssistants]); + }, [assistants, conversationParticipants, assistantsRefetch]); if ( - isLoadingAssistants || - isLoadingConversation || - isLoadingConversationParticipants || - isLoadingConversationFiles || - isFetchingAssistantCapabilities || + historyIsLoading || + assistantCapabilitiesIsFetching || !assistants || !assistantCapabilities || !conversation || + !allConversationMessages || !conversationParticipants || !conversationFiles ) { @@ -222,6 +171,7 @@ export const Interact: React.FC = () => {
@@ -229,7 +179,9 @@ export const Interact: React.FC = () => {
diff --git a/workbench-app/src/services/workbench/conversation.ts b/workbench-app/src/services/workbench/conversation.ts index af3f71a8..1b5c0817 100644 --- a/workbench-app/src/services/workbench/conversation.ts +++ b/workbench-app/src/services/workbench/conversation.ts @@ -4,6 +4,16 @@ import { ConversationMessageDebug, conversationMessageDebugFromJSON } from '../. import { transformResponseToConversationParticipant } from './participant'; import { workbenchApi } from './workbench'; +interface GetConversationMessagesProps { + conversationId: string; + messageTypes?: string[]; + participantRoles?: string[]; + participantIds?: string[]; + before?: string; + after?: string; + limit?: number; +} + export const conversationApi = workbenchApi.injectEndpoints({ endpoints: (builder) => ({ createConversation: builder.mutation & Pick>({ @@ -39,31 +49,12 @@ export const conversationApi = workbenchApi.injectEndpoints({ providesTags: ['Conversation'], transformResponse: (response: any) => transformResponseToConversation(response), }), - getConversationMessages: builder.query< - ConversationMessage[], - { - conversationId: string; - messageTypes?: string[]; - participantRoles?: string[]; - participantIds?: string[]; - before?: string; - after?: string; - limit?: number; - } - >({ - query: ({ - conversationId, - messageTypes = ['chat', 'note', 'notice', 'command', 'command-response'], - participantRoles, - participantIds, - before, - after, - limit, - }) => { + getConversationMessages: builder.query({ + query: ({ conversationId, messageTypes, participantRoles, participantIds, before, after, limit }) => { const params = new URLSearchParams(); // Append parameters to the query string, one by one for arrays - messageTypes.forEach((type) => params.append('message_type', type)); + messageTypes?.forEach((type) => params.append('message_type', type)); participantRoles?.forEach((role) => params.append('participant_role', role)); participantIds?.forEach((id) => params.append('participant_id', id)); @@ -83,6 +74,56 @@ export const conversationApi = workbenchApi.injectEndpoints({ providesTags: ['Conversation'], transformResponse: (response: any) => transformResponseToConversationMessages(response), }), + getAllConversationMessages: builder.query({ + async queryFn( + { conversationId, messageTypes, participantRoles, participantIds, before, after, limit }, + _queryApi, + _extraOptions, + fetchWithBQ, + ) { + let allMessages: ConversationMessage[] = []; + let updatedBefore = before; + + while (true) { + const params = new URLSearchParams(); + + // Append parameters to the query string, one by one for arrays + messageTypes?.forEach((type) => params.append('message_type', type)); + participantRoles?.forEach((role) => params.append('participant_role', role)); + participantIds?.forEach((id) => params.append('participant_id', id)); + + if (updatedBefore) { + params.set('before', updatedBefore); + } + if (after) { + params.set('after', after); + } + // Ensure limit does not exceed 500 + if (limit !== undefined) { + params.set('limit', String(Math.min(limit, 500))); + } + + const url = `/conversations/${conversationId}/messages?${params.toString()}`; + + const response = await fetchWithBQ(url); + if (response.error) { + return { error: response.error }; + } + + const messages: ConversationMessage[] = transformResponseToConversationMessages(response.data); + allMessages = [...allMessages, ...messages]; + + if (messages.length !== limit) { + break; + } + + updatedBefore = messages[0].id; + } + + return { data: allMessages }; + }, + providesTags: ['Conversation'], + }), getConversationMessageDebugData: builder.query< ConversationMessageDebug, { conversationId: string; messageId: string } @@ -127,11 +168,17 @@ export const { useGetAssistantConversationsQuery, useGetConversationQuery, useGetConversationMessagesQuery, + useGetAllConversationMessagesQuery, useGetConversationMessageDebugDataQuery, useCreateConversationMessageMutation, useDeleteConversationMessageMutation, } = conversationApi; +export const updateGetAllConversationMessagesQueryData = ( + options: GetConversationMessagesProps, + messages: ConversationMessage[], +) => conversationApi.util.updateQueryData('getAllConversationMessages', options, () => messages); + const transformConversationForRequest = (conversation: Partial) => ({ id: conversation.id, title: conversation.title,