Skip to content

Commit

Permalink
workflow-lite, duplicate conversation, conversation list updates (#265)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
bkrabach authored Nov 26, 2024
1 parent 03a58b3 commit c2d72ee
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 96 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"}}),
] = []


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
ConversationMessage,
MessageSender,
MessageType,
NewConversation,
NewConversationMessage,
UpdateParticipant,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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"},
)
Expand All @@ -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..."
)
)

Expand Down Expand Up @@ -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"},
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
2 changes: 2 additions & 0 deletions workbench-app/docs/MESSAGE_METADATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 38 additions & 23 deletions workbench-app/src/components/Conversations/ConversationRemove.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -42,16 +45,21 @@ const useConversationRemoveControls = () => {
);

const removeConversationForm = React.useCallback(
() => <p>Are you sure you want to remove this conversation from your list?</p>,
(hasMultipleConversations: boolean) =>
hasMultipleConversations ? (
<p>Are you sure you want to remove these conversations from your list ?</p>
) : (
<p>Are you sure you want to remove this conversation from your list ?</p>
),
[],
);

const removeConversationButton = React.useCallback(
(conversationId: string, participantId: string, onRemove?: () => void) => (
(conversations: Conversation[], participantId: string, onRemove?: () => void) => (
<DialogTrigger disableButtonEnhancement>
<Button
appearance="primary"
onClick={() => handleRemove(conversationId, participantId, onRemove)}
onClick={() => handleRemove(conversations, participantId, onRemove)}
disabled={submitted}
>
{submitted ? 'Removing...' : 'Remove'}
Expand All @@ -68,51 +76,58 @@ const useConversationRemoveControls = () => {
};

interface ConversationRemoveDialogProps {
conversationId: string;
conversations: Conversation | Conversation[];
participantId: string;
onRemove: () => void;
onCancel: () => void;
}

export const ConversationRemoveDialog: React.FC<ConversationRemoveDialogProps> = (props) => {
const { conversationId, participantId, onRemove, onCancel } = props;
const { conversations, participantId, onRemove, onCancel } = props;
const { removeConversationForm, removeConversationButton } = useConversationRemoveControls();

const hasMultipleConversations = Array.isArray(conversations);
const conversationsToRemove = hasMultipleConversations ? conversations : [conversations];

return (
<DialogControl
open={true}
onOpenChange={onCancel}
title="Remove Conversation"
content={removeConversationForm()}
additionalActions={[removeConversationButton(conversationId, participantId, onRemove)]}
title={hasMultipleConversations ? 'Remove Conversations' : 'Remove Conversation'}
content={removeConversationForm(hasMultipleConversations)}
additionalActions={[removeConversationButton(conversationsToRemove, participantId, onRemove)]}
/>
);
};

interface ConversationRemoveProps {
conversation: Conversation;
conversations: Conversation | Conversation[];
participantId: string;
onRemove?: () => void;
iconOnly?: boolean;
asToolbarButton?: boolean;
}

export const ConversationRemove: React.FC<ConversationRemoveProps> = (props) => {
const { conversation, onRemove, iconOnly, asToolbarButton, participantId } = props;
const { conversations, onRemove, iconOnly, asToolbarButton, participantId } = props;
const { removeConversationForm, removeConversationButton } = useConversationRemoveControls();

const hasMultipleConversations = Array.isArray(conversations);
const conversationsToRemove = hasMultipleConversations ? conversations : [conversations];
const description = hasMultipleConversations ? 'Remove Conversations' : 'Remove Conversation';

return (
<CommandButton
description="Remove Conversation"
description={description}
icon={<PlugDisconnected24Regular />}
iconOnly={iconOnly}
asToolbarButton={asToolbarButton}
label="Remove"
dialogContent={{
title: 'Remove Conversation',
content: removeConversationForm(),
title: description,
content: removeConversationForm(hasMultipleConversations),
closeLabel: 'Cancel',
additionalActions: [removeConversationButton(conversation.id, participantId, onRemove)],
additionalActions: [removeConversationButton(conversationsToRemove, participantId, onRemove)],
}}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
TextBulletListSquareSparkleRegular,
} from '@fluentui/react-icons';
import React from 'react';
import { Link } from 'react-router-dom';
import { useConversationUtility } from '../../libs/useConversationUtility';
import { useParticipantUtility } from '../../libs/useParticipantUtility';
import { Utility } from '../../libs/Utility';
Expand Down Expand Up @@ -261,6 +262,7 @@ export const InteractMessage: React.FC<InteractMessageProps> = (props) => {
);

const getRenderedMessage = React.useCallback(() => {
let allowLink = true;
let renderedContent: JSX.Element;
if (message.messageType === 'notice') {
renderedContent = (
Expand Down Expand Up @@ -299,11 +301,17 @@ export const InteractMessage: React.FC<InteractMessageProps> = (props) => {
</div>
);
} else if (isUser) {
allowLink = false;
renderedContent = <UserMessage>{content}</UserMessage>;
} else {
allowLink = false;
renderedContent = <CopilotMessage>{content}</CopilotMessage>;
}

if (message.metadata?.href && allowLink) {
renderedContent = <Link to={message.metadata?.href}>{renderedContent}</Link>;
}

const attachmentList =
message.filenames && message.filenames.length > 0 ? (
<AttachmentList className={classes.attachments}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const MyConversations: React.FC<MyConversationsProps> = (props) => {
<ConversationDuplicate conversationId={conversation.id} iconOnly />
<ConversationShare conversation={conversation} iconOnly />
<ConversationRemove
conversation={conversation}
conversations={conversation}
participantId={participantId}
iconOnly
/>
Expand Down
Loading

0 comments on commit c2d72ee

Please sign in to comment.