From c2d72ee83f694f18db226b626e4744f01bddae41 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 26 Nov 2024 09:53:28 -0800 Subject: [PATCH] workflow-lite, duplicate conversation, conversation list updates (#265) Reduces noise from workflow conversations: * Adds parent_conversation_id as metadata on duplicated conversations * Updates ConversationList to take optional: * parentConversationId: only show children of this conversation * hideChildConversations: hide children of the current level of conversations * Global conversation list is set to hide child conversations * Adds notice at start of workflow that includes new href metadata for link to workflow conversation * Updates rendering of InteractMessage to use React Router links to wrap non-chat message content if href metadata is provided * Updates UX docs regarding message metadata handling Updates ConversationListOptions to allow bulk removal of conversations Fixes status messages for workflow steps Updates workflows config for user proxy workflow definitions: * Steps now include a label for use in status messages * User message input now uses multiline text field Changed conversation_duplicate endpoint: * post -> /conversations/{conversation_id} * takes title for new conversation and optional metadata to merge w/ existing conversation metadata and add original_conversation_id Removes commented out code for alt approach to conversation_duplicate via export/import --- .../assistant_extensions/workflows/_model.py | 25 +++++++- .../workflows/runners/_user_proxy.py | 37 ++++++++--- .../workbench_service_client.py | 9 ++- workbench-app/docs/MESSAGE_METADATA.md | 2 + .../Conversations/ConversationRemove.tsx | 61 ++++++++++++------- .../Conversations/InteractMessage.tsx | 8 +++ .../Conversations/MyConversations.tsx | 2 +- .../FrontDoor/Controls/ConversationList.tsx | 41 +++++++++++-- .../Controls/ConversationListOptions.tsx | 35 +++++++++-- .../components/FrontDoor/GlobalContent.tsx | 8 +-- workbench-app/src/libs/useHistoryUtility.ts | 8 +-- .../controller/assistant.py | 59 ++++++++---------- .../semantic_workbench_service/service.py | 17 +++--- 13 files changed, 216 insertions(+), 96 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..e7503644 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,41 @@ 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, + metadata={"parent_conversation_id": context.id}, + ) + ) + + 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 +208,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 +220,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 +279,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/docs/MESSAGE_METADATA.md b/workbench-app/docs/MESSAGE_METADATA.md index b615a1de..c17b93fa 100644 --- a/workbench-app/docs/MESSAGE_METADATA.md +++ b/workbench-app/docs/MESSAGE_METADATA.md @@ -6,6 +6,8 @@ The app has built-in support for a few metadata child properties, which can be u - `attribution`: A string that will be displayed after the sender of the message. The intent is to allow the sender to indicate the source of the message, possibly coming from an internal part of its system. +- `href`: If provided, the app will display the message as a hyperlink. The value of this property will be used as the URL of the hyperlink and use the React Router navigation system to navigate to the URL when the user clicks on the message. Will be ignored for messages of type `chat`. + - `debug`: A dictionary that can contain additional information that can be used for debugging purposes. If included, it will cause the app to display a button that will allow the user to see the contents of the dictionary in a popup for further inspection. - `footer_items`: A list of strings that will be displayed in the footer of the message. The intent is to allow the sender to include additional information that is not part of the message body, but is still relevant to the message. 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) => (