From 27da43015c2b4e82b226204cb3d9e53c03e291ed Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 6 Nov 2024 21:06:44 -0800 Subject: [PATCH] load all messages for explorer assistant response, app conversation display, and transcript export; plus overflow support for assistant canvas (#228) --- workbench-app/src/Constants.ts | 1 + workbench-app/src/components/App/Loading.tsx | 13 +- .../src/components/App/OverflowMenu.tsx | 91 +++++ .../Canvas/AssistantCanvasList.tsx | 90 +++-- .../Canvas/AssistantInspectorList.tsx | 94 ++--- .../Conversations/ConversationTranscript.tsx | 56 +-- .../Conversations/InteractHistory.tsx | 321 ++++++++++-------- .../Conversations/InteractInput.tsx | 2 +- .../Conversations/InteractMessage.tsx | 7 +- .../Conversations/RewindConversation.tsx | 62 +--- .../src/components/FrontDoor/Chat/Chat.tsx | 8 + .../src/components/FrontDoor/MainContent.tsx | 9 + workbench-app/src/libs/Utility.ts | 4 +- .../src/libs/useConversationEvents.ts | 100 ++++++ workbench-app/src/libs/useSiteUtility.ts | 4 +- workbench-app/src/libs/useWorkbenchService.ts | 90 ++++- .../src/redux/features/app/appSlice.ts | 5 +- workbench-app/src/routes/WorkflowInteract.tsx | 148 +++----- .../src/services/workbench/conversation.ts | 46 ++- 19 files changed, 702 insertions(+), 449 deletions(-) create mode 100644 workbench-app/src/components/App/OverflowMenu.tsx create mode 100644 workbench-app/src/libs/useConversationEvents.ts diff --git a/workbench-app/src/Constants.ts b/workbench-app/src/Constants.ts index f3f2d8ad..30aa393d 100644 --- a/workbench-app/src/Constants.ts +++ b/workbench-app/src/Constants.ts @@ -9,6 +9,7 @@ export const Constants = { maxInputLength: 2000000, // 2M tokens, effectively unlimited minChatWidthPercent: 20, defaultChatWidthPercent: 33, + maxMessagesPerRequest: 500, maxFileAttachmentsPerMessage: 10, loaderDelayMs: 100, responsiveBreakpoints: { diff --git a/workbench-app/src/components/App/Loading.tsx b/workbench-app/src/components/App/Loading.tsx index 08dba01f..4e3a253b 100644 --- a/workbench-app/src/components/App/Loading.tsx +++ b/workbench-app/src/components/App/Loading.tsx @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -import { Spinner, makeStyles, shorthands, tokens } from '@fluentui/react-components'; +import { Spinner, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; import { Constants } from '../../Constants'; @@ -10,7 +10,12 @@ const useClasses = makeStyles({ }, }); -export const Loading: React.FC = () => { +interface LoadingProps { + className?: string; +} + +export const Loading: React.FC = (props) => { + const { className } = props; const classes = useClasses(); const [showSpinner, setShowSpinner] = React.useState(false); @@ -22,5 +27,7 @@ export const Loading: React.FC = () => { return () => clearTimeout(timer); }, []); - return showSpinner ? : null; + return showSpinner ? ( + + ) : null; }; diff --git a/workbench-app/src/components/App/OverflowMenu.tsx b/workbench-app/src/components/App/OverflowMenu.tsx new file mode 100644 index 00000000..ff27cca7 --- /dev/null +++ b/workbench-app/src/components/App/OverflowMenu.tsx @@ -0,0 +1,91 @@ +import { + Button, + makeStyles, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + Slot, + tokens, + useIsOverflowItemVisible, + useOverflowMenu, +} from '@fluentui/react-components'; +import { MoreHorizontalRegular } from '@fluentui/react-icons'; +import React from 'react'; + +const useClasses = makeStyles({ + menu: { + backgroundColor: tokens.colorNeutralBackground1, + }, + menuButton: { + alignSelf: 'center', + }, +}); + +export interface OverflowMenuItemData { + id: string; + icon?: Slot<'span'>; + name?: string; +} + +interface OverflowMenuItemProps { + item: OverflowMenuItemData; + onClick: (event: React.MouseEvent, id: string) => void; +} + +export const OverflowMenuItem: React.FC = (props) => { + const { item, onClick } = props; + const isVisible = useIsOverflowItemVisible(item.id); + + if (isVisible) { + return null; + } + + return ( + onClick(event, item.id)}> + {item.name} + + ); +}; + +interface OverflowMenuProps { + items: OverflowMenuItemData[]; + onItemSelect: (id: string) => void; +} + +export const OverflowMenu: React.FC = (props) => { + const { items, onItemSelect } = props; + const classes = useClasses(); + const { ref, isOverflowing, overflowCount } = useOverflowMenu(); + + const handleItemClick = (_event: React.MouseEvent, id: string) => { + onItemSelect(id); + }; + + if (!isOverflowing) { + return null; + } + + return ( + item.icon !== undefined) !== undefined}> + + + ); +}; diff --git a/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx b/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx index 74b9764d..45ba5e82 100644 --- a/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx +++ b/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx @@ -1,10 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -import { Tab, TabList, makeStyles, shorthands, tokens } from '@fluentui/react-components'; +import { Overflow, OverflowItem, Tab, TabList, makeStyles, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; import { useChatCanvasController } from '../../../libs/useChatCanvasController'; import { Assistant } from '../../../models/Assistant'; import { Conversation } from '../../../models/Conversation'; +import { OverflowMenu, OverflowMenuItemData } from '../../App/OverflowMenu'; import { AssistantCanvas } from './AssistantCanvas'; const useClasses = makeStyles({ @@ -20,13 +21,10 @@ const useClasses = makeStyles({ justifyContent: 'space-between', ...shorthands.padding(tokens.spacingVerticalS), }, - headerContent: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: tokens.spacingHorizontalM, + header: { + overflow: 'hidden', ...shorthands.padding(tokens.spacingVerticalS), + ...shorthands.borderBottom(tokens.strokeWidthThin, 'solid', tokens.colorNeutralStroke1), }, }); @@ -41,40 +39,66 @@ export const AssistantCanvasList: React.FC = (props) = const classes = useClasses(); const chatCanvasController = useChatCanvasController(); + const tabItems = React.useMemo( + () => + conversationAssistants.slice().map( + (assistant): OverflowMenuItemData => ({ + id: assistant.id, + name: assistant.name, + }), + ), + [conversationAssistants], + ); + + const handleTabSelect = React.useCallback( + (id: string) => { + // Find the assistant that corresponds to the selected tab + const conversationAssistant = conversationAssistants.find( + (conversationAssistant) => conversationAssistant.id === id, + ); + + // Set the new assistant as the active assistant + // If we can't find the assistant, we'll set the assistant to undefined + chatCanvasController.transitionToState({ + selectedAssistantId: conversationAssistant?.id, + selectedAssistantStateId: undefined, + }); + }, + [chatCanvasController, conversationAssistants], + ); + + const assistant = React.useMemo( + () => selectedAssistant ?? conversationAssistants[0], + [selectedAssistant, conversationAssistants], + ); + if (conversationAssistants.length === 1) { // Only one assistant, no need to show tabs, just show the single assistant return ; } - const assistant = selectedAssistant ?? conversationAssistants[0]; - // Multiple assistants, show tabs return (
-
- { - // Find the assistant that corresponds to the selected tab - const conversationAssistant = conversationAssistants.find( - (conversationAssistant) => conversationAssistant.id === selectedItem.value, - ); - - // Set the new assistant as the active assistant - // If we can't find the assistant, we'll set the assistant to undefined - chatCanvasController.transitionToState({ - selectedAssistantId: conversationAssistant?.id, - selectedAssistantStateId: undefined, - }); - }} - size="small" - > - {conversationAssistants.slice().map((assistant) => ( - - {assistant.name} - - ))} - +
+ + handleTabSelect(data.value as string)} + size="small" + > + {tabItems.map((tabItem) => ( + + {tabItem.name} + + ))} + + +
diff --git a/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx b/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx index f92de0ab..b563b5a5 100644 --- a/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx +++ b/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx @@ -1,20 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. -import { - SelectTabData, - SelectTabEvent, - SelectTabEventHandler, - Tab, - TabList, - makeStyles, - shorthands, - tokens, -} from '@fluentui/react-components'; +import { Overflow, OverflowItem, Tab, TabList, makeStyles, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; import { useChatCanvasController } from '../../../libs/useChatCanvasController'; import { Assistant } from '../../../models/Assistant'; import { AssistantStateDescription } from '../../../models/AssistantStateDescription'; import { useAppSelector } from '../../../redux/app/hooks'; +import { OverflowMenu, OverflowMenuItemData } from '../../App/OverflowMenu'; import { AssistantInspector } from './AssistantInspector'; const useClasses = makeStyles({ @@ -25,17 +17,8 @@ const useClasses = makeStyles({ }, header: { flexShrink: 0, - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - backgroundImage: `linear-gradient(to right, ${tokens.colorNeutralBackground1}, ${tokens.colorBrandBackground2})`, - }, - headerContent: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: tokens.spacingHorizontalM, + height: 'fit-content', + overflow: 'hidden', ...shorthands.padding(tokens.spacingVerticalS), ...shorthands.borderBottom(tokens.strokeWidthThin, 'solid', tokens.colorNeutralStroke1), }, @@ -57,6 +40,34 @@ export const AssistantInspectorList: React.FC = (pr const chatCanvasState = useAppSelector((state) => state.chatCanvas); const chatCanvasController = useChatCanvasController(); + const selectedStateDescription = React.useMemo( + () => + stateDescriptions.find( + (stateDescription) => stateDescription.id === chatCanvasState.selectedAssistantStateId, + ) ?? stateDescriptions[0], + [stateDescriptions, chatCanvasState.selectedAssistantStateId], + ); + + const tabItems = React.useMemo( + () => + stateDescriptions + .filter((stateDescription) => stateDescription.id !== 'config') + .map( + (stateDescription): OverflowMenuItemData => ({ + id: stateDescription.id, + name: stateDescription.displayName, + }), + ), + [stateDescriptions], + ); + + const handleTabSelect = React.useCallback( + (id: string) => { + chatCanvasController.transitionToState({ selectedAssistantStateId: id }); + }, + [chatCanvasController], + ); + if (stateDescriptions.length === 1) { // Only one assistant state, no need to show tabs, just show the single assistant state return ( @@ -68,42 +79,37 @@ export const AssistantInspectorList: React.FC = (pr ); } - const onTabSelect: SelectTabEventHandler = (_event: SelectTabEvent, data: SelectTabData) => { - chatCanvasController.transitionToState({ selectedAssistantStateId: data.value as string }); - }; - if (stateDescriptions.length === 0) { return (
-
-
No assistant state inspectors available
-
+
No assistant state inspectors available
); } - const selectedStateDescription = - stateDescriptions.find( - (stateDescription) => stateDescription.id === chatCanvasState.selectedAssistantStateId, - ) ?? stateDescriptions[0]; - const selectedTab = selectedStateDescription.id; - return (
-
- - {stateDescriptions - .filter((stateDescription) => stateDescription.id !== 'config') - .map((stateDescription) => ( - - {stateDescription.displayName} - - ))} + + handleTabSelect(data.value as string)} + size="small" + > + {tabItems.map((tabItem) => ( + + {tabItem.name} + + ))} + -
+
= (props) => { const { conversation, participants, iconOnly, asToolbarButton } = props; - const { - data: messages, - error: messagesError, - isLoading: isLoadingMessages, - } = useGetConversationMessagesQuery(conversation.id); - - if (messagesError) { - const errorMessage = JSON.stringify(messagesError); - throw new Error(`Error loading messages: ${errorMessage}`); - } + const workbenchService = useWorkbenchService(); const getTranscript = async () => { - if (!messages) { - return; - } - - const timestampForFilename = Utility.getTimestampForFilename(); - const filename = `transcript_${conversation.title.replaceAll(' ', '_')}_${timestampForFilename}.md`; - - const markdown = messages - .filter((message) => message.messageType !== 'log') - .map((message) => { - const date = Utility.toFormattedDateString(message.timestamp, 'dddd, MMMM D'); - const time = Utility.toFormattedDateString(message.timestamp, 'h:mm A'); - const participant = participants.find( - (possible_participant) => possible_participant.id === message.sender.participantId, - ); - const sender = participant ? participant.name : 'Unknown'; - const parts = []; - parts.push(`### [${date} ${time}] ${sender}:`); - if (message.messageType !== 'chat') { - parts.push(`${message.messageType}: ${message.content}`); - } else { - parts.push(message.content); - } - if (message.filenames && message.filenames.length > 0) { - parts.push( - message.filenames - .map((filename) => { - return `attachment: ${filename}`; - }) - .join('\n'), - ); - } - parts.push('----------------------------------\n\n'); - - return parts.join('\n\n'); - }) - .join('\n'); - - const blob = new Blob([markdown], { type: 'text/markdown' }); - + const { blob, filename } = await workbenchService.exportTranscriptAsync(conversation, participants); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -80,7 +31,6 @@ export const ConversationTranscript: React.FC = (pr return (
} iconOnly={iconOnly} diff --git a/workbench-app/src/components/Conversations/InteractHistory.tsx b/workbench-app/src/components/Conversations/InteractHistory.tsx index 8de41e77..b25fc4e5 100644 --- a/workbench-app/src/components/Conversations/InteractHistory.tsx +++ b/workbench-app/src/components/Conversations/InteractHistory.tsx @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. import { CopilotChat, ResponseCount } from '@fluentui-copilot/react-copilot'; import { makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; -import { EventSourceMessage } from '@microsoft/fetch-event-source'; import dayjs from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; @@ -11,18 +10,13 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Constants } from '../../Constants'; import { Utility } from '../../libs/Utility'; -import { WorkbenchEventSource, WorkbenchEventSourceType } from '../../libs/WorkbenchEventSource'; +import { useConversationEvents } from '../../libs/useConversationEvents'; import { useConversationUtility } from '../../libs/useConversationUtility'; -import { useEnvironment } from '../../libs/useEnvironment'; import { Conversation } from '../../models/Conversation'; -import { ConversationMessage, conversationMessageFromJSON } from '../../models/ConversationMessage'; +import { ConversationMessage } from '../../models/ConversationMessage'; import { ConversationParticipant } from '../../models/ConversationParticipant'; import { useAppDispatch } from '../../redux/app/hooks'; -import { - conversationApi, - updateGetConversationParticipantsQueryData, - useGetConversationMessagesQuery, -} from '../../services/workbench'; +import { conversationApi, updateGetConversationParticipantsQueryData } from '../../services/workbench'; import { Loading } from '../App/Loading'; import { MemoizedInteractMessage } from './InteractMessage'; import { ParticipantStatus } from './ParticipantStatus'; @@ -35,6 +29,9 @@ const useClasses = makeStyles({ root: { height: '100%', }, + loading: { + ...shorthands.margin(tokens.spacingVerticalXXXL, 0), + }, virtuoso: { '::-webkit-scrollbar-thumb': { backgroundColor: tokens.colorNeutralStencil1Alpha, @@ -62,68 +59,169 @@ export const InteractHistory: React.FC = (props) => { const { conversation, participants, readOnly, className } = props; const classes = useClasses(); const { hash } = useLocation(); - const [items, setItems] = React.useState([]); + 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 environment = useEnvironment(); const dispatch = useAppDispatch(); - const { - data: messages, - error: getMessagesError, - isLoading: isLoadingMessages, - } = useGetConversationMessagesQuery(conversation.id); + // 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)); - if (getMessagesError) { - const errorMessage = JSON.stringify(getMessagesError); - throw new Error(`Error loading messages: ${errorMessage}`); - } + // update the newest message id for use with the 'after' parameter in the next request + setNewestMessageId(newMessages[newMessages.length - 1].id); + }, + [setMessages], + ); - const virtuosoRef = React.useRef(null); - const [shouldAutoScroll, setShouldAutoScroll] = React.useState(true); + // 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], + ); - // create a function to scroll to the bottom of the chat - // to be used whenever we need to force a scroll to the bottom - const performScrollToBottom = React.useCallback(() => { - if (shouldAutoScroll) { - // wait a tick for the DOM to update - setTimeout(() => { - virtuosoRef.current?.scrollToIndex({ index: items.length - 1 }); - }, 0); + // 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; } - }, [items.length, shouldAutoScroll]); - // scroll to the bottom when the component mounts - // and whenever the items change, such as when new messages are added - React.useEffect(() => { - performScrollToBottom(); - }, [performScrollToBottom]); + // set the messages state with all the messages + setMessages(allMessages); - // if hash index is set, scroll to the hash item + // 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 (hashItemIndex !== undefined) { - setTimeout(() => { - virtuosoRef.current?.scrollToIndex({ index: hashItemIndex }); - }, 0); + if (!messages && !isLoadingMessages) { + setIsLoadingMessages(true); + loadMessages(); } - }, [hashItemIndex]); - - // scroll to the bottom when the participant status changes - const handleParticipantStatusChange = React.useCallback(() => { - performScrollToBottom(); - }, [performScrollToBottom]); + }, [messages, loadMessages, isLoadingMessages]); + // handler for when a message is read const handleOnRead = React.useCallback( - (message: ConversationMessage) => { - setLastRead(conversation, message.timestamp); - }, + // update the last read timestamp for the conversation + async (message: ConversationMessage) => await setLastRead(conversation, message.timestamp), [setLastRead, conversation], ); - React.useEffect(() => { - if (isLoadingMessages || !messages) { - setItems([]); - return; + // handler for when a conversation is rewound + const handleOnRewind = React.useCallback( + async (message: ConversationMessage, redo: boolean) => { + if (!messages) { + return; + } + + // find the index of the message to rewind to + const messageIndex = messages?.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 < messages.length; i++) { + await dispatch( + conversationApi.endpoints.deleteConversationMessage.initiate({ + conversationId: conversation.id, + messageId: messages[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: conversation.id, + ...message, + }), + ); + } + }, + [conversation.id, dispatch, messages], + ); + + // create a ref for the virtuoso component for using its methods directly + const virtuosoRef = React.useRef(null); + + // create a list of memoized interact message components for rendering in the virtuoso component + const items = React.useMemo(() => { + if (!messages) { + return []; } let lastMessageInfo = { @@ -133,6 +231,7 @@ export const InteractHistory: React.FC = (props) => { }; let lastDate = ''; let generatedResponseCount = 0; + const updatedItems = messages .filter((message) => message.messageType !== 'log') .map((message, index) => { @@ -201,10 +300,12 @@ export const InteractHistory: React.FC = (props) => { hideParticipant={hideParticipant} displayDate={displayDate} onRead={handleOnRead} + onRewind={handleOnRewind} />
); }); + if (generatedResponseCount > 0) { updatedItems.push(
@@ -214,106 +315,56 @@ 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); + } + }} + />
, ); - setItems(updatedItems); + + return updatedItems; }, [ classes.counter, classes.item, classes.status, conversation, handleOnRead, - handleParticipantStatusChange, + handleOnRewind, hash, hashItemIndex, - isLoadingMessages, + isAtBottom, messages, participants, readOnly, ]); + // if hash index is set, scroll to the hash item React.useEffect(() => { - if (isLoadingMessages || !messages) { - return; + if (hashItemIndex !== undefined) { + setTimeout(() => { + virtuosoRef.current?.scrollToIndex({ index: hashItemIndex, align: 'start' }); + }, 0); } + }, [hashItemIndex]); - // handle new message events - const messageHandler = async (event: EventSourceMessage) => { - const { data } = JSON.parse(event.data); - const parsedEventData = { - timestamp: data.timestamp, - data: { - message: conversationMessageFromJSON(data.message), - }, - }; - - if (parsedEventData.data.message.messageType === 'log') { - // ignore log messages - return; - } - - dispatch( - conversationApi.endpoints.getConversationMessages.initiate(conversation.id, { - forceRefetch: true, - }), - ); - }; - - // handle participant events - const handleParticipantEvent = (event: { - timestamp: string; - data: { - participant: ConversationParticipant; - participants: ConversationParticipant[]; - }; - }) => { - // update the conversation participants in the cache - dispatch(updateGetConversationParticipantsQueryData(conversation.id, event.data)); - }; - - const participantCreatedHandler = (event: EventSourceMessage) => { - handleParticipantEvent(JSON.parse(event.data)); - }; - - const participantUpdatedHandler = (event: EventSourceMessage) => { - handleParticipantEvent(JSON.parse(event.data)); - }; - - (async () => { - // create or update the event source - const workbenchEventSource = await WorkbenchEventSource.createOrUpdate( - environment.url, - WorkbenchEventSourceType.Conversation, - conversation.id, - ); - workbenchEventSource.addEventListener('message.created', messageHandler); - workbenchEventSource.addEventListener('message.deleted', messageHandler); - workbenchEventSource.addEventListener('participant.created', participantCreatedHandler); - workbenchEventSource.addEventListener('participant.updated', participantUpdatedHandler); - })(); - - return () => { - (async () => { - const workbenchEventSource = await WorkbenchEventSource.getInstance( - WorkbenchEventSourceType.Conversation, - ); - workbenchEventSource.removeEventListener('message.created', messageHandler); - workbenchEventSource.removeEventListener('message.deleted', messageHandler); - workbenchEventSource.removeEventListener('participant.created', participantCreatedHandler); - workbenchEventSource.removeEventListener('participant.updated', participantUpdatedHandler); - })(); - }; - }, [conversation.id, dispatch, environment.url, isLoadingMessages, messages]); - - if (isLoadingMessages || !messages) { - return ; + // if messages are not loaded, show a loading spinner + if (isLoadingMessages) { + return ; } + // render the history return ( - + {({ height, width }: { height: number; width: number }) => ( = (props) => { itemContent={(_index, item) => item} initialTopMostItemIndex={items.length} atBottomThreshold={Constants.app.autoScrollThreshold} - atBottomStateChange={(isAtBottom) => { - if (isAtBottom) { - setShouldAutoScroll(true); - } else { - setShouldAutoScroll(false); - } - }} + atBottomStateChange={(isAtBottom) => setIsAtBottom(isAtBottom)} /> )} diff --git a/workbench-app/src/components/Conversations/InteractInput.tsx b/workbench-app/src/components/Conversations/InteractInput.tsx index 95b224af..2d6551a5 100644 --- a/workbench-app/src/components/Conversations/InteractInput.tsx +++ b/workbench-app/src/components/Conversations/InteractInput.tsx @@ -157,7 +157,7 @@ export const InteractInput: React.FC = (props) => { data: conversationMessages, isLoading: isConversationMessagesLoading, error: conversationMessagesError, - } = useGetConversationMessagesQuery(conversationId); + } = useGetConversationMessagesQuery({ conversationId }); const { data: participants, diff --git a/workbench-app/src/components/Conversations/InteractMessage.tsx b/workbench-app/src/components/Conversations/InteractMessage.tsx index 3295426f..9fab562d 100644 --- a/workbench-app/src/components/Conversations/InteractMessage.tsx +++ b/workbench-app/src/components/Conversations/InteractMessage.tsx @@ -148,10 +148,11 @@ interface InteractMessageProps { displayDate?: boolean; readOnly: boolean; onRead?: (message: ConversationMessage) => void; + onRewind?: (message: ConversationMessage, redo: boolean) => void; } export const InteractMessage: React.FC = (props) => { - const { conversation, message, participant, hideParticipant, displayDate, readOnly, onRead } = props; + const { conversation, message, participant, hideParticipant, displayDate, readOnly, onRead, onRewind } = props; const classes = useClasses(); const { getAvatarData } = useParticipantUtility(); const [createConversationMessage] = useCreateConversationMessageMutation(); @@ -252,12 +253,12 @@ export const InteractMessage: React.FC = (props) => { {!readOnly && ( <> - + onRewind?.(message, redo)} /> )} ), - [conversation, debugData?.debugData, isLoadingDebugData, isUninitializedDebugData, message, readOnly], + [conversation, debugData?.debugData, isLoadingDebugData, isUninitializedDebugData, message, onRewind, readOnly], ); const getRenderedMessage = React.useCallback(() => { diff --git a/workbench-app/src/components/Conversations/RewindConversation.tsx b/workbench-app/src/components/Conversations/RewindConversation.tsx index c7d57ec9..66aeb232 100644 --- a/workbench-app/src/components/Conversations/RewindConversation.tsx +++ b/workbench-app/src/components/Conversations/RewindConversation.tsx @@ -3,77 +3,25 @@ import { Button, DialogTrigger } from '@fluentui/react-components'; import { RewindRegular } from '@fluentui/react-icons'; import React from 'react'; -import { ConversationMessage } from '../../models/ConversationMessage'; -import { - useCreateConversationMessageMutation, - useDeleteConversationMessageMutation, - useGetConversationMessagesQuery, -} from '../../services/workbench'; import { CommandButton } from '../App/CommandButton'; // TODO: consider removing attachments to messages that are deleted // and send the appropriate events to the assistants interface RewindConversationProps { - conversationId: string; - message: ConversationMessage; - onRewind?: () => void; + onRewind?: (redo: boolean) => void; disabled?: boolean; } export const RewindConversation: React.FC = (props) => { - const { conversationId, message, onRewind, disabled } = props; - const { - data: messages, - error: getMessagesError, - isLoading: isLoadingMessages, - } = useGetConversationMessagesQuery(conversationId); - const [createMessage] = useCreateConversationMessageMutation(); - const [deleteMessage] = useDeleteConversationMessageMutation(); + const { onRewind, disabled } = props; - if (getMessagesError) { - const errorMessage = JSON.stringify(getMessagesError); - throw new Error(`Error loading messages: ${errorMessage}`); - } - - const handleRewind = React.useCallback(async () => { - if (!messages) { - return; - } - - // Find the index of the message to rewind to - const messageIndex = messages.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 < messages.length; i++) { - await deleteMessage({ conversationId, messageId: messages[i].id }); - } - - // Call the onRewind callback - onRewind?.(); - }, [conversationId, deleteMessage, messages, message, onRewind]); - - const handleRewindWithRedo = React.useCallback(async () => { - await handleRewind(); - - // Create a new message with the same content as the message to redo - await createMessage({ - conversationId, - ...message, - }); - - // Call the onRewind callback - onRewind?.(); - }, [conversationId, createMessage, handleRewind, message, onRewind]); + const handleRewind = React.useCallback(async () => onRewind?.(false), [onRewind]); + const handleRewindWithRedo = React.useCallback(async () => onRewind?.(true), [onRewind]); return ( } iconOnly={true} diff --git a/workbench-app/src/components/FrontDoor/Chat/Chat.tsx b/workbench-app/src/components/FrontDoor/Chat/Chat.tsx index c3458682..b44bdf8b 100644 --- a/workbench-app/src/components/FrontDoor/Chat/Chat.tsx +++ b/workbench-app/src/components/FrontDoor/Chat/Chat.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { Constants } from '../../../Constants'; import { useGetAssistantCapabilities } from '../../../libs/useAssistantCapabilities'; import { useParticipantUtility } from '../../../libs/useParticipantUtility'; +import { useSiteUtility } from '../../../libs/useSiteUtility'; import { Assistant } from '../../../models/Assistant'; import { useAppSelector } from '../../../redux/app/hooks'; import { @@ -126,6 +127,7 @@ export const Chat: React.FC = (props) => { const classes = useClasses(); const { sortParticipants } = useParticipantUtility(); const localUserId = useAppSelector((state) => state.localUser.id); + const siteUtility = useSiteUtility(); const { data: conversation, @@ -174,6 +176,12 @@ export const Chat: React.FC = (props) => { throw new Error(`Error loading conversation files (${conversationId}): ${errorMessage}`); } + React.useEffect(() => { + if (conversation) { + siteUtility.setDocumentTitle(conversation.title); + } + }, [conversation, siteUtility]); + const conversationAssistants = React.useMemo(() => { const results: Assistant[] = []; diff --git a/workbench-app/src/components/FrontDoor/MainContent.tsx b/workbench-app/src/components/FrontDoor/MainContent.tsx index 38eebe19..e752a718 100644 --- a/workbench-app/src/components/FrontDoor/MainContent.tsx +++ b/workbench-app/src/components/FrontDoor/MainContent.tsx @@ -2,8 +2,10 @@ import { Button, makeStyles, shorthands, Title3, tokens } from '@fluentui/react-components'; import React from 'react'; +import { Constants } from '../../Constants'; import { useConversationUtility } from '../../libs/useConversationUtility'; import { useCreateConversation } from '../../libs/useCreateConversation'; +import { useSiteUtility } from '../../libs/useSiteUtility'; import { useAppSelector } from '../../redux/app/hooks'; import { ExperimentalNotice } from '../App/ExperimentalNotice'; import { ConversationsImport } from '../Conversations/ConversationsImport'; @@ -62,9 +64,16 @@ export const MainContent: React.FC = (props) => { const [assistantServiceId, setAssistantServiceId] = React.useState(); const [submitted, setSubmitted] = React.useState(false); const { navigateToConversation } = useConversationUtility(); + const siteUtility = useSiteUtility(); const classes = useClasses(); + React.useEffect(() => { + if (!activeConversationId && document.title !== Constants.app.name) { + siteUtility.setDocumentTitle(); + } + }, [activeConversationId, siteUtility]); + const handleCreate = React.useCallback(async () => { if (submitted || !isValid || !title || !assistantId) { return; diff --git a/workbench-app/src/libs/Utility.ts b/workbench-app/src/libs/Utility.ts index 33daaa15..17aa16e9 100644 --- a/workbench-app/src/libs/Utility.ts +++ b/workbench-app/src/libs/Utility.ts @@ -73,7 +73,7 @@ const debounce = (func: Function, wait: number) => { }; }; -const toDayJs = (value?: string | Date, timezone: string = dayjs.tz.guess()) => { +const toDayJs = (value: string | Date, timezone: string = dayjs.tz.guess()) => { return dayjs.utc(value).tz(timezone); }; @@ -110,7 +110,7 @@ const toFormattedDateString = (value: string | Date, format: string, timezone: s const getTimestampForFilename = (timezone: string = dayjs.tz.guess()) => { // return in format YYYYMMDDHHmm - return toDayJs(timezone).format('YYYYMMDDHHmm'); + return toDayJs(new Date(), timezone).format('YYYYMMDDHHmm'); }; const sortKeys = (obj: any): any => { diff --git a/workbench-app/src/libs/useConversationEvents.ts b/workbench-app/src/libs/useConversationEvents.ts new file mode 100644 index 00000000..b8d2f558 --- /dev/null +++ b/workbench-app/src/libs/useConversationEvents.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. +import { EventSourceMessage } from '@microsoft/fetch-event-source'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import React from 'react'; +import { conversationMessageFromJSON } from '../models/ConversationMessage'; +import { ConversationParticipant } from '../models/ConversationParticipant'; +import { useAppDispatch } from '../redux/app/hooks'; +import { useEnvironment } from './useEnvironment'; +import { WorkbenchEventSource, WorkbenchEventSourceType } from './WorkbenchEventSource'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.tz.guess(); + +export const useConversationEvents = ( + conversationId: string, + handlers: { + onMessageCreated?: () => void; + onMessageDeleted?: (messageId: string) => void; + onParticipantCreated?: (participant: ConversationParticipant) => void; + onParticipantUpdated?: (participant: ConversationParticipant) => void; + }, +) => { + const { onMessageCreated, onMessageDeleted, onParticipantCreated, onParticipantUpdated } = handlers; + const environment = useEnvironment(); + const dispatch = useAppDispatch(); + + // handle new message events + const handleMessageEvent = React.useCallback( + async (event: EventSourceMessage) => { + const { data } = JSON.parse(event.data); + const parsedEventData = { + timestamp: data.timestamp, + data: { + message: conversationMessageFromJSON(data.message), + }, + }; + + if (event.event === 'message.created') { + onMessageCreated?.(); + } + + if (event.event === 'message.deleted') { + onMessageDeleted?.(parsedEventData.data.message.id); + } + }, + [onMessageCreated, onMessageDeleted], + ); + + // handle participant events + const handleParticipantEvent = React.useCallback( + (event: EventSourceMessage) => { + const parsedEventData = JSON.parse(event.data) as { + timestamp: string; + data: { + participant: ConversationParticipant; + participants: ConversationParticipant[]; + }; + }; + + if (event.event === 'participant.created') { + onParticipantCreated?.(parsedEventData.data.participant); + } + + if (event.event === 'participant.updated') { + onParticipantUpdated?.(parsedEventData.data.participant); + } + }, + [onParticipantCreated, onParticipantUpdated], + ); + + React.useEffect(() => { + (async () => { + // create or update the event source + const workbenchEventSource = await WorkbenchEventSource.createOrUpdate( + environment.url, + WorkbenchEventSourceType.Conversation, + conversationId, + ); + workbenchEventSource.addEventListener('message.created', handleMessageEvent); + workbenchEventSource.addEventListener('message.deleted', handleMessageEvent); + workbenchEventSource.addEventListener('participant.created', handleParticipantEvent); + workbenchEventSource.addEventListener('participant.updated', handleParticipantEvent); + })(); + + return () => { + (async () => { + const workbenchEventSource = await WorkbenchEventSource.getInstance( + WorkbenchEventSourceType.Conversation, + ); + workbenchEventSource.removeEventListener('message.created', handleMessageEvent); + workbenchEventSource.removeEventListener('message.deleted', handleMessageEvent); + workbenchEventSource.removeEventListener('participant.created', handleParticipantEvent); + workbenchEventSource.removeEventListener('participant.updated', handleParticipantEvent); + })(); + }; + }, [conversationId, dispatch, environment.url, handleMessageEvent, handleParticipantEvent]); +}; diff --git a/workbench-app/src/libs/useSiteUtility.ts b/workbench-app/src/libs/useSiteUtility.ts index bdf58e21..36a40ddc 100644 --- a/workbench-app/src/libs/useSiteUtility.ts +++ b/workbench-app/src/libs/useSiteUtility.ts @@ -4,8 +4,8 @@ import React from 'react'; import { Constants } from '../Constants'; export const useSiteUtility = () => { - const setDocumentTitle = React.useCallback((title: string) => { - document.title = `${title} - ${Constants.app.name}`; + const setDocumentTitle = React.useCallback((title?: string) => { + document.title = title ? `${title} - ${Constants.app.name}` : Constants.app.name; }, []); const forceNavigateTo = React.useCallback((url: string | URL) => { diff --git a/workbench-app/src/libs/useWorkbenchService.ts b/workbench-app/src/libs/useWorkbenchService.ts index 46f0157e..ba40994f 100644 --- a/workbench-app/src/libs/useWorkbenchService.ts +++ b/workbench-app/src/libs/useWorkbenchService.ts @@ -5,10 +5,19 @@ import { useAccount, useMsal } from '@azure/msal-react'; import React from 'react'; import { AssistantServiceInfo } from '../models/AssistantServiceInfo'; import { AssistantServiceRegistration } from '../models/AssistantServiceRegistration'; +import { Conversation } from '../models/Conversation'; import { ConversationFile } from '../models/ConversationFile'; +import { ConversationMessage } from '../models/ConversationMessage'; +import { ConversationParticipant } from '../models/ConversationParticipant'; import { useAppDispatch } from '../redux/app/hooks'; import { addError } from '../redux/features/app/appSlice'; -import { assistantApi, assistantServiceRegistrationApi, workbenchApi, workflowApi } from '../services/workbench'; +import { + assistantApi, + assistantServiceRegistrationApi, + conversationApi, + workbenchApi, + workflowApi, +} from '../services/workbench'; import { AuthHelper } from './AuthHelper'; import { Utility } from './Utility'; import { useEnvironment } from './useEnvironment'; @@ -147,6 +156,76 @@ export const useWorkbenchService = () => { [environment.url, tryFetchStreamAsync], ); + const exportTranscriptAsync = React.useCallback( + async ( + conversation: Conversation, + participants: ConversationParticipant[], + ): Promise<{ blob: Blob; filename: string }> => { + const messages: ConversationMessage[] = []; + let before_message_id: string | undefined = undefined; + + while (true) { + try { + const new_messages = await dispatch( + conversationApi.endpoints.getConversationMessages.initiate({ + conversationId: conversation.id, + before: before_message_id, + }), + ).unwrap(); + + if (new_messages.length === 0) { + break; + } + + messages.unshift(...new_messages); + before_message_id = new_messages[0].id; + } catch (error) { + dispatch(addError({ title: 'Export transcript', message: (error as Error).message })); + throw error; + } + } + + const timestampForFilename = Utility.getTimestampForFilename(); + const filename = `transcript_${conversation.title.replaceAll(' ', '_')}_${timestampForFilename}.md`; + + const markdown = messages + .filter((message) => message.messageType !== 'log') + .map((message) => { + const date = Utility.toFormattedDateString(message.timestamp, 'dddd, MMMM D'); + const time = Utility.toFormattedDateString(message.timestamp, 'h:mm A'); + const participant = participants.find( + (possible_participant) => possible_participant.id === message.sender.participantId, + ); + const sender = participant ? participant.name : 'Unknown'; + const parts = []; + parts.push(`### [${date} ${time}] ${sender}:`); + if (message.messageType !== 'chat') { + parts.push(`${message.messageType}: ${message.content}`); + } else { + parts.push(message.content); + } + if (message.filenames && message.filenames.length > 0) { + parts.push( + message.filenames + .map((filename) => { + return `attachment: ${filename}`; + }) + .join('\n'), + ); + } + parts.push('----------------------------------\n\n'); + + return parts.join('\n\n'); + }) + .join('\n'); + + const blob = new Blob([markdown], { type: 'text/markdown' }); + + return { blob, filename }; + }, + [dispatch], + ); + const exportConversationsAsync = React.useCallback( async (conversationIds: string[]): Promise<{ blob: Blob; filename: string }> => { const response = await tryFetchAsync( @@ -274,8 +353,9 @@ export const useWorkbenchService = () => { const exportWorkflowDefinitionAsync = React.useCallback( async (workflowId: string) => { - const results = await dispatch(workflowApi.endpoints.getWorkflowDefinition.initiate(workflowId)); - const workflowDefinition = results.data; + const workflowDefinition = await dispatch( + workflowApi.endpoints.getWorkflowDefinition.initiate(workflowId), + ).unwrap(); if (!workflowDefinition) { throw new Error(`Workflow with ID ${workflowId} not found`); @@ -298,13 +378,13 @@ export const useWorkbenchService = () => { // }; const getWorkflowDefinitionDefaultsAsync = React.useCallback(async () => { - const results = await dispatch(workflowApi.endpoints.getWorkflowDefinitionDefaults.initiate()); - return results.data; + return await dispatch(workflowApi.endpoints.getWorkflowDefinitionDefaults.initiate()).unwrap(); }, [dispatch]); return { getAzureSpeechTokenAsync, downloadConversationFileAsync, + exportTranscriptAsync, exportConversationsAsync, importConversationsAsync, duplicateConversationsAsync, diff --git a/workbench-app/src/redux/features/app/appSlice.ts b/workbench-app/src/redux/features/app/appSlice.ts index 2e3251ad..0adaffa7 100644 --- a/workbench-app/src/redux/features/app/appSlice.ts +++ b/workbench-app/src/redux/features/app/appSlice.ts @@ -102,7 +102,10 @@ export const appSlice = createSlice({ // dispatch to invalidate messages cache if (action.payload) { - conversationApi.endpoints.getConversationMessages.initiate(action.payload, { forceRefetch: true }); + conversationApi.endpoints.getConversationMessages.initiate( + { conversationId: action.payload }, + { forceRefetch: true }, + ); } }, }, diff --git a/workbench-app/src/routes/WorkflowInteract.tsx b/workbench-app/src/routes/WorkflowInteract.tsx index a4c42114..b17e8491 100644 --- a/workbench-app/src/routes/WorkflowInteract.tsx +++ b/workbench-app/src/routes/WorkflowInteract.tsx @@ -1,32 +1,22 @@ // Copyright (c) Microsoft. All rights reserved. import { - Button, Divider, - Menu, - MenuItem, - MenuItemProps, - MenuList, - MenuPopover, - MenuTrigger, Overflow, OverflowItem, Tab, TabList, - Tooltip, makeStyles, shorthands, tokens, - useIsOverflowItemVisible, - useOverflowMenu, } from '@fluentui/react-components'; -import { MoreHorizontalRegular } from '@fluentui/react-icons'; import { EventSourceMessage } from '@microsoft/fetch-event-source'; import React from 'react'; import { useParams } from 'react-router-dom'; import { Constants } from '../Constants'; import { AppView } from '../components/App/AppView'; import { Loading } from '../components/App/Loading'; +import { OverflowMenu, OverflowMenuItemData } from '../components/App/OverflowMenu'; import { WorkflowConversation } from '../components/Workflows/WorkflowConversation'; import { WorkflowEdit } from '../components/Workflows/WorkflowEdit'; import { WorkbenchEventSource, WorkbenchEventSourceType } from '../libs/WorkbenchEventSource'; @@ -229,7 +219,41 @@ export const WorkflowInteract: React.FC = () => { }; }, [workflowRunId, refetchWorkflowRun, environment, conversationId, workflowRunIsLoading, workflowRun, dispatch]); - if (!workflowDefinition || !workflowRun || !conversationId) { + const tabItems = React.useMemo( + () => + workflowRun?.conversationMappings.map((mapping): OverflowMenuItemData => { + const conversationDefinition = workflowDefinition?.definitions.conversations.find( + (definition) => definition.id === mapping.conversationDefinitionId, + ); + + if (!conversationDefinition) { + throw new Error('No conversation definition found'); + } + + return { + id: mapping.conversationId, + name: conversationDefinition.title, + }; + }), + [workflowDefinition, workflowRun], + ); + + const handleTabSelect = React.useCallback((tabId: string) => { + setConversationId(tabId); + }, []); + + const workflowCurrentConversationId = React.useMemo( + () => + workflowRun?.conversationMappings.find( + (mapping) => + mapping.conversationDefinitionId === + workflowDefinition?.states.find((state) => state.id === workflowRun.currentStateId) + ?.conversationDefinitionId, + )?.conversationId, + [workflowRun, workflowDefinition], + ); + + if (!tabItems || !workflowDefinition || !workflowRun || !conversationId) { return ( @@ -241,86 +265,6 @@ export const WorkflowInteract: React.FC = () => { items: [], }; - const conversationTabListItems = workflowRun.conversationMappings.map((mapping) => { - const conversationDefinition = workflowDefinition.definitions.conversations.find( - (definition) => definition.id === mapping.conversationDefinitionId, - ); - - if (!conversationDefinition) { - throw new Error('No conversation definition found'); - } - - return { - key: mapping.conversationId, - text: conversationDefinition.title, - onClick: () => setConversationId(mapping.conversationId), - }; - }); - - const OverflowMenuItem = (props: { tab: { key: string; text: string }; onClick: MenuItemProps['onClick'] }) => { - const { tab, onClick } = props; - const isVisible = useIsOverflowItemVisible(tab.key); - - if (isVisible) { - return null; - } - - return ( - - {tab.text} - - ); - }; - - const OverflowMenu = (props: { onTabSelect?: (tabId: string) => void }) => { - const { onTabSelect } = props; - const { ref, isOverflowing, overflowCount } = useOverflowMenu(); - - if (!isOverflowing) return null; - - return ( - - - - - ); - }; - - const onTabSelect = (tabId: string) => { - setConversationId(tabId); - }; - - const workflowCurrentConversationId = workflowRun.conversationMappings.find( - (mapping) => - mapping.conversationDefinitionId === - workflowDefinition.states.find((state) => state.id === workflowRun.currentStateId) - ?.conversationDefinitionId, - )?.conversationId; - return (
@@ -328,20 +272,18 @@ export const WorkflowInteract: React.FC = () => { onTabSelect(data.value as string)} + onTabSelect={(_, data) => handleTabSelect(data.value as string)} > - {conversationTabListItems.map((tab) => ( - - - {workflowRun.conversationMappings.length > 1 && - tab.key === workflowCurrentConversationId && ( - - )} - {tab.text} - + {tabItems?.map((tabItem) => ( + + {tabItem.name} ))} - + diff --git a/workbench-app/src/services/workbench/conversation.ts b/workbench-app/src/services/workbench/conversation.ts index ac785565..af3f71a8 100644 --- a/workbench-app/src/services/workbench/conversation.ts +++ b/workbench-app/src/services/workbench/conversation.ts @@ -39,9 +39,47 @@ export const conversationApi = workbenchApi.injectEndpoints({ providesTags: ['Conversation'], transformResponse: (response: any) => transformResponseToConversation(response), }), - getConversationMessages: builder.query({ - query: (id) => - `/conversations/${id}/messages?message_type=chat&message_type=note&message_type=notice&message_type=command&message_type=command-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, + }) => { + 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 (before) { + params.set('before', before); + } + if (after) { + params.set('after', after); + } + // Ensure limit does not exceed 500 + if (limit !== undefined) { + params.set('limit', String(Math.min(limit, 500))); + } + + return `/conversations/${conversationId}/messages?${params.toString()}`; + }, providesTags: ['Conversation'], transformResponse: (response: any) => transformResponseToConversationMessages(response), }), @@ -80,7 +118,7 @@ export const conversationApi = workbenchApi.injectEndpoints({ // Non-hook helpers export const updateGetConversationMessagesQueryData = (conversationId: string, data: ConversationMessage[]) => - conversationApi.util.updateQueryData('getConversationMessages', conversationId, () => data); + conversationApi.util.updateQueryData('getConversationMessages', { conversationId }, () => data); export const { useCreateConversationMutation,