From 9bfefc886d2d29de5875a2f13af1d969679781fa Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 26 Nov 2024 16:46:27 +0000 Subject: [PATCH 1/4] updated status and config for workflows, hide child conversations --- .../assistant_extensions/workflows/_model.py | 25 +++++++- .../workflows/runners/_user_proxy.py | 36 ++++++++--- .../workbench_service_client.py | 9 ++- .../Conversations/InteractMessage.tsx | 9 +++ .../FrontDoor/Controls/ConversationList.tsx | 39 ++++++++++-- .../components/FrontDoor/GlobalContent.tsx | 8 +-- workbench-app/src/libs/useHistoryUtility.ts | 8 +-- .../controller/assistant.py | 59 ++++++++----------- .../semantic_workbench_service/service.py | 17 +++--- 9 files changed, 144 insertions(+), 66 deletions(-) diff --git a/libraries/python/assistant-extensions/assistant_extensions/workflows/_model.py b/libraries/python/assistant-extensions/assistant_extensions/workflows/_model.py index a5c33a9a..b8aa4d85 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/workflows/_model.py +++ b/libraries/python/assistant-extensions/assistant_extensions/workflows/_model.py @@ -4,6 +4,28 @@ from semantic_workbench_assistant.config import UISchema +class UserMessage(BaseModel): + class Config: + json_schema_extra = { + "required": ["status_label", "message"], + } + + status_label: Annotated[ + str, + Field( + description="The status label to be displayed when the message is sent to the assistant.", + ), + ] = "" + + message: Annotated[ + str, + Field( + description="The message to be sent to the assistant.", + ), + UISchema(widget="textarea"), + ] = "" + + class UserProxyWorkflowDefinition(BaseModel): class Config: json_schema_extra = { @@ -37,11 +59,10 @@ class Config: UISchema(widget="textarea"), ] = "" user_messages: Annotated[ - list[str], + list[UserMessage], Field( description="A list of user messages that will be sequentially sent to the assistant during the workflow.", ), - UISchema(schema={"items": {"widget": "textarea"}}), ] = [] diff --git a/libraries/python/assistant-extensions/assistant_extensions/workflows/runners/_user_proxy.py b/libraries/python/assistant-extensions/assistant_extensions/workflows/runners/_user_proxy.py index fc92d29c..1be8d6a8 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/workflows/runners/_user_proxy.py +++ b/libraries/python/assistant-extensions/assistant_extensions/workflows/runners/_user_proxy.py @@ -8,6 +8,7 @@ ConversationMessage, MessageSender, MessageType, + NewConversation, NewConversationMessage, UpdateParticipant, ) @@ -101,7 +102,7 @@ async def run( ) # duplicate the current conversation and get the context - workflow_context = await self.duplicate_conversation(context) + workflow_context = await self.duplicate_conversation(context, workflow_definition) # set the current workflow id workflow_state = WorkflowState( @@ -156,21 +157,40 @@ async def _listen_for_events( continue await self._on_assistant_message(context, workflow_state, message) - async def duplicate_conversation(self, context: ConversationContext) -> ConversationContext: + async def duplicate_conversation( + self, context: ConversationContext, workflow_definition: UserProxyWorkflowDefinition + ) -> ConversationContext: """ Duplicate the current conversation """ + title = f"Workflow: {workflow_definition.name} [{context.title}]" + # duplicate the current conversation - response = await context._workbench_client.duplicate_conversation() + response = await context._workbench_client.duplicate_conversation( + new_conversation=NewConversation( + title=title, + ) + ) + + conversation_id = response.conversation_ids[0] # create a new conversation context workflow_context = ConversationContext( - id=str(response.conversation_ids[0]), - title="Workflow", + id=str(conversation_id), + title=title, assistant=context.assistant, ) + # send link to chat for the new conversation + await context.send_messages( + NewConversationMessage( + content=f"New conversation: {title}", + message_type=MessageType.command_response, + metadata={"attribution": "workflows:user_proxy", "href": f"/{conversation_id}"}, + ) + ) + # return the new conversation context return workflow_context @@ -187,7 +207,7 @@ async def _start_step(self, context: ConversationContext, workflow_state: Workfl await workflow_state.context.send_messages( NewConversationMessage( sender=workflow_state.send_as, - content=user_message, + content=user_message.message, message_type=MessageType.chat, metadata={"attribution": "user"}, ) @@ -199,7 +219,7 @@ async def _start_step(self, context: ConversationContext, workflow_state: Workfl # ) await context.update_participant_me( UpdateParticipant( - status=f"Workflow {workflow_state.definition.name}: Step {workflow_state.current_step}, awaiting assistant response..." + status=f"Workflow {workflow_state.definition.name} [Step {workflow_state.current_step} - {user_message.status_label}]: awaiting assistant response..." ) ) @@ -258,7 +278,7 @@ async def _send_final_response( NewConversationMessage( content=assistant_response.content, message_type=MessageType.chat, - metadata={"attribution": "system"}, + metadata={"attribution": "workflows:user_proxy"}, ) ) diff --git a/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py b/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py index 3a8ac757..9a571e63 100644 --- a/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py +++ b/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py @@ -117,9 +117,14 @@ async def delete_conversation(self) -> None: return http_response.raise_for_status() - async def duplicate_conversation(self) -> workbench_model.ConversationImportResult: + async def duplicate_conversation( + self, new_conversation: workbench_model.NewConversation + ) -> workbench_model.ConversationImportResult: async with self._client as client: - http_response = await client.post(f"/conversations/duplicate?id={self._conversation_id}") + http_response = await client.post( + f"/conversations/{self._conversation_id}", + json=new_conversation.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"), + ) http_response.raise_for_status() return workbench_model.ConversationImportResult.model_validate(http_response.json()) diff --git a/workbench-app/src/components/Conversations/InteractMessage.tsx b/workbench-app/src/components/Conversations/InteractMessage.tsx index 7e71eff0..42f780f0 100644 --- a/workbench-app/src/components/Conversations/InteractMessage.tsx +++ b/workbench-app/src/components/Conversations/InteractMessage.tsx @@ -30,6 +30,7 @@ import { TextBulletListSquareSparkleRegular, } from '@fluentui/react-icons'; import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; import { useConversationUtility } from '../../libs/useConversationUtility'; import { useParticipantUtility } from '../../libs/useParticipantUtility'; import { Utility } from '../../libs/Utility'; @@ -157,6 +158,7 @@ export const InteractMessage: React.FC = (props) => { const { getAvatarData } = useParticipantUtility(); const [createConversationMessage] = useCreateConversationMessageMutation(); const { isMessageVisibleRef, isMessageVisible, isUnread } = useConversationUtility(); + const navigate = useNavigate(); const [skipDebugLoad, setSkipDebugLoad] = React.useState(true); const { data: debugData, @@ -261,6 +263,7 @@ export const InteractMessage: React.FC = (props) => { ); const getRenderedMessage = React.useCallback(() => { + let allowLink = true; let renderedContent: JSX.Element; if (message.messageType === 'notice') { renderedContent = ( @@ -299,11 +302,17 @@ export const InteractMessage: React.FC = (props) => { ); } else if (isUser) { + allowLink = false; renderedContent = {content}; } else { + allowLink = false; renderedContent = {content}; } + if (message.metadata?.href && allowLink) { + renderedContent = {renderedContent}; + } + const attachmentList = message.filenames && message.filenames.length > 0 ? ( diff --git a/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx b/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx index 983b2f26..4108218b 100644 --- a/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx @@ -30,7 +30,13 @@ const useClasses = makeStyles({ }, }); -export const ConversationList: React.FC = () => { +interface ConversationListProps { + parentConversationId?: string; + hideChildConversations?: boolean; +} + +export const ConversationList: React.FC = (props) => { + const { parentConversationId, hideChildConversations } = props; const classes = useClasses(); const environment = useEnvironment(); const activeConversationId = useAppSelector((state) => state.app.activeConversationId); @@ -43,6 +49,7 @@ export const ConversationList: React.FC = () => { isUninitialized: conversationsUninitialized, refetch: refetchConversations, } = useGetConversationsQuery(); + const [filteredConversations, setFilteredConversations] = React.useState(); const [displayedConversations, setDisplayedConversations] = React.useState([]); const [renameConversation, setRenameConversation] = React.useState(); @@ -89,6 +96,30 @@ export const ConversationList: React.FC = () => { }; }, [conversationsLoading, conversationsUninitialized, environment.url, refetchConversations]); + React.useEffect(() => { + if (conversationsLoading) { + return; + } + + setFilteredConversations( + conversations?.filter((conversation) => { + if (parentConversationId) { + if (hideChildConversations) { + return ( + conversation.metadata?.['parent_conversation_id'] === undefined || + conversation.metadata?.['parent_conversation_id'] !== parentConversationId + ); + } + return conversation.metadata?.['parent_conversation_id'] === parentConversationId; + } + if (hideChildConversations) { + return conversation.metadata?.['parent_conversation_id'] === undefined; + } + return true; + }), + ); + }, [conversations, conversationsLoading, hideChildConversations, parentConversationId]); + const handleUpdateSelectedForActions = React.useCallback((conversationId: string, selected: boolean) => { if (selected) { setSelectedForActions((prev) => new Set(prev).add(conversationId)); @@ -185,12 +216,12 @@ export const ConversationList: React.FC = () => { <> {actionHelpers} - {!conversations || conversations.length === 0 ? ( + {!filteredConversations || filteredConversations.length === 0 ? (
No conversations found
@@ -221,5 +252,3 @@ export const ConversationList: React.FC = () => { ); }; - -export const MemoizedConversationList = React.memo(ConversationList); diff --git a/workbench-app/src/components/FrontDoor/GlobalContent.tsx b/workbench-app/src/components/FrontDoor/GlobalContent.tsx index 8d999a0e..40ff47ed 100644 --- a/workbench-app/src/components/FrontDoor/GlobalContent.tsx +++ b/workbench-app/src/components/FrontDoor/GlobalContent.tsx @@ -2,7 +2,7 @@ import { makeStyles, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; -import { MemoizedConversationList } from './Controls/ConversationList'; +import { ConversationList } from './Controls/ConversationList'; const useClasses = makeStyles({ root: { @@ -34,15 +34,15 @@ export const GlobalContent: React.FC = (props) => { const { headerBefore, headerAfter } = props; const classes = useClasses(); + const conversationList = React.useMemo(() => , []); + return (
{headerBefore} {headerAfter}
-
- -
+
{conversationList}
); }; diff --git a/workbench-app/src/libs/useHistoryUtility.ts b/workbench-app/src/libs/useHistoryUtility.ts index 98c7b56a..0614462e 100644 --- a/workbench-app/src/libs/useHistoryUtility.ts +++ b/workbench-app/src/libs/useHistoryUtility.ts @@ -121,8 +121,7 @@ export const useHistoryUtility = (conversationId: string) => { // add the new participant to the cached participants dispatch( updateGetConversationParticipantsQueryData(conversationId, { - participant, - participants: conversationParticipants, + participants: [...(conversationParticipants ?? []), participant], }), ), [dispatch, conversationId, conversationParticipants], @@ -134,8 +133,9 @@ export const useHistoryUtility = (conversationId: string) => { // update the participant in the cached participants dispatch( updateGetConversationParticipantsQueryData(conversationId, { - participant, - participants: conversationParticipants, + participants: (conversationParticipants ?? []).map((existingParticipant) => + existingParticipant.id === participant.id ? participant : existingParticipant, + ), }), ), [dispatch, conversationId, conversationParticipants], diff --git a/workbench-service/semantic_workbench_service/controller/assistant.py b/workbench-service/semantic_workbench_service/controller/assistant.py index 3014ab46..e7193341 100644 --- a/workbench-service/semantic_workbench_service/controller/assistant.py +++ b/workbench-service/semantic_workbench_service/controller/assistant.py @@ -31,6 +31,7 @@ ConversationEventType, ConversationImportResult, NewAssistant, + NewConversation, UpdateAssistant, ) from sqlalchemy.orm import joinedload @@ -829,32 +830,14 @@ async def import_conversations( conversation_ids=list(import_result.conversation_id_old_to_new.values()), ) - # async def duplicate_conversation( - # self, - # user_principal: auth.UserPrincipal, - # conversation_id: uuid.UUID, - # ) -> ConversationImportResult: - # # export the conversation - # export_result = await self.export_conversations( - # user_principal=user_principal, conversation_ids={conversation_id} - # ) - - # # import the conversation - # with open(export_result.file_path, "rb") as import_file: - # import_result = await self.import_conversations(from_export=import_file, user_principal=user_principal) - - # # cleanup - # export_result.cleanup() - - # return import_result - + # TODO: decide if we should move this to the conversation controller? + # it's a bit of a mix between the two and reaches into the assistant controller + # to access storage and assistant data, so it's not a clean fit in either + # also, we should consider DRYing up the import/export code with this async def duplicate_conversation( - self, - principal: auth.ActorPrincipal, - conversation_id: uuid.UUID, + self, principal: auth.ActorPrincipal, conversation_id: uuid.UUID, new_conversation: NewConversation ) -> ConversationImportResult: async with self._get_session() as session: - # Ensure the user has access to the conversation # Ensure the actor has access to the conversation original_conversation = await self._ensure_conversation_access( session=session, @@ -864,16 +847,24 @@ async def duplicate_conversation( if original_conversation is None: raise exceptions.NotFoundError() + title = new_conversation.title or f"{original_conversation.title} (Copy)" + + meta_data = { + **original_conversation.meta_data, + **new_conversation.metadata, + "parent_conversation_id": str(original_conversation.conversation_id), + } + # Create a new conversation with the same properties - new_conversation = db.Conversation( + conversation = db.Conversation( owner_id=original_conversation.owner_id, - title=f"{original_conversation.title} (Copy)", - meta_data=original_conversation.meta_data.copy(), + title=title, + meta_data=meta_data, imported_from_conversation_id=original_conversation.conversation_id, # Use the current datetime for the new conversation created_datetime=datetime.datetime.now(datetime.UTC), ) - session.add(new_conversation) + session.add(conversation) await session.flush() # To generate new_conversation.conversation_id # Copy messages from the original conversation @@ -886,7 +877,7 @@ async def duplicate_conversation( new_message = db.ConversationMessage( # Do not set 'sequence'; let the database assign it message_id=uuid.uuid4(), # Generate a new unique message ID - conversation_id=new_conversation.conversation_id, + conversation_id=conversation.conversation_id, created_datetime=message.created_datetime, sender_participant_id=message.sender_participant_id, sender_participant_role=message.sender_participant_role, @@ -902,7 +893,7 @@ async def duplicate_conversation( original_files_path = self._file_storage.path_for( namespace=str(original_conversation.conversation_id), filename="" ) - new_files_path = self._file_storage.path_for(namespace=str(new_conversation.conversation_id), filename="") + new_files_path = self._file_storage.path_for(namespace=str(conversation.conversation_id), filename="") if original_files_path.exists(): await asyncio.to_thread(shutil.copytree, original_files_path, new_files_path) @@ -918,7 +909,7 @@ async def duplicate_conversation( ).all() for participant in assistant_participants: new_participant = db.AssistantParticipant( - conversation_id=new_conversation.conversation_id, + conversation_id=conversation.conversation_id, assistant_id=participant.assistant_id, name=participant.name, image=participant.image, @@ -938,7 +929,7 @@ async def duplicate_conversation( ) for participant in user_participants: new_user_participant = db.UserParticipant( - conversation_id=new_conversation.conversation_id, + conversation_id=conversation.conversation_id, user_id=participant.user_id, name=participant.name, image=participant.image, @@ -974,20 +965,20 @@ async def duplicate_conversation( # **Connect the assistant to the new conversation with the exported data** await self.connect_assistant_to_conversation( - conversation=new_conversation, + conversation=conversation, assistant=assistant, from_export=from_export, ) except AssistantError as e: logger.error( - f"Error connecting assistant {assistant_id} to new conversation {new_conversation.conversation_id}: {e}", + f"Error connecting assistant {assistant_id} to new conversation {conversation.conversation_id}: {e}", exc_info=True, ) # Optionally handle the error (e.g., remove assistant from the conversation) return ConversationImportResult( assistant_ids=list(assistant_ids), - conversation_ids=[new_conversation.conversation_id], + conversation_ids=[conversation.conversation_id], ) async def _ensure_conversation_access( diff --git a/workbench-service/semantic_workbench_service/service.py b/workbench-service/semantic_workbench_service/service.py index 3b6f40ce..3f2f7e8a 100644 --- a/workbench-service/semantic_workbench_service/service.py +++ b/workbench-service/semantic_workbench_service/service.py @@ -504,13 +504,6 @@ async def import_conversations( user_principal=user_principal, from_export=from_export.file ) - @app.post("/conversations/duplicate") - async def duplicate_conversation( - principal: auth.DependsActorPrincipal, - conversation_id: uuid.UUID = Query(alias="id"), - ) -> ConversationImportResult: - return await assistant_controller.duplicate_conversation(principal=principal, conversation_id=conversation_id) - @app.get("/assistants/{assistant_id}/config") async def get_assistant_config( user_principal: auth.DependsUserPrincipal, @@ -761,6 +754,16 @@ async def create_conversation( new_conversation=new_conversation, ) + @app.post("/conversations/{conversation_id}") + async def duplicate_conversation( + conversation_id: uuid.UUID, + principal: auth.DependsActorPrincipal, + new_conversation: NewConversation, + ) -> ConversationImportResult: + return await assistant_controller.duplicate_conversation( + principal=principal, conversation_id=conversation_id, new_conversation=new_conversation + ) + @app.get("/conversations") async def list_conversations( principal: auth.DependsActorPrincipal, From b774622b70c231bd0d468d6f8974042c327058d4 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 26 Nov 2024 17:18:06 +0000 Subject: [PATCH 2/4] allow bulk removal of conversations --- .../Conversations/ConversationRemove.tsx | 61 ++++++++++++------- .../Conversations/MyConversations.tsx | 2 +- .../FrontDoor/Controls/ConversationList.tsx | 2 +- .../Controls/ConversationListOptions.tsx | 35 +++++++++-- 4 files changed, 70 insertions(+), 30 deletions(-) diff --git a/workbench-app/src/components/Conversations/ConversationRemove.tsx b/workbench-app/src/components/Conversations/ConversationRemove.tsx index 8b081e68..d3c49aac 100644 --- a/workbench-app/src/components/Conversations/ConversationRemove.tsx +++ b/workbench-app/src/components/Conversations/ConversationRemove.tsx @@ -17,22 +17,25 @@ const useConversationRemoveControls = () => { const [submitted, setSubmitted] = React.useState(false); const handleRemove = React.useCallback( - async (conversationId: string, participantId: string, onRemove?: () => void) => { + async (conversations: Conversation[], participantId: string, onRemove?: () => void) => { if (submitted) { return; } setSubmitted(true); try { - if (activeConversationId === conversationId) { - // Clear the active conversation if it is the one being removed - dispatch(setActiveConversationId(undefined)); - } + for (const conversation of conversations) { + const conversationId = conversation.id; + if (activeConversationId === conversationId) { + // Clear the active conversation if it is the one being removed + dispatch(setActiveConversationId(undefined)); + } - await removeConversationParticipant({ - conversationId, - participantId, - }); + await removeConversationParticipant({ + conversationId, + participantId, + }); + } onRemove?.(); } finally { setSubmitted(false); @@ -42,16 +45,21 @@ const useConversationRemoveControls = () => { ); const removeConversationForm = React.useCallback( - () =>

Are you sure you want to remove this conversation from your list?

, + (hasMultipleConversations: boolean) => + hasMultipleConversations ? ( +

Are you sure you want to remove these conversations from your list ?

+ ) : ( +

Are you sure you want to remove this conversation from your list ?

+ ), [], ); const removeConversationButton = React.useCallback( - (conversationId: string, participantId: string, onRemove?: () => void) => ( + (conversations: Conversation[], participantId: string, onRemove?: () => void) => (