Skip to content

Commit c2d72ee

Browse files
authored
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
1 parent 03a58b3 commit c2d72ee

File tree

13 files changed

+216
-96
lines changed

13 files changed

+216
-96
lines changed

libraries/python/assistant-extensions/assistant_extensions/workflows/_model.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@
44
from semantic_workbench_assistant.config import UISchema
55

66

7+
class UserMessage(BaseModel):
8+
class Config:
9+
json_schema_extra = {
10+
"required": ["status_label", "message"],
11+
}
12+
13+
status_label: Annotated[
14+
str,
15+
Field(
16+
description="The status label to be displayed when the message is sent to the assistant.",
17+
),
18+
] = ""
19+
20+
message: Annotated[
21+
str,
22+
Field(
23+
description="The message to be sent to the assistant.",
24+
),
25+
UISchema(widget="textarea"),
26+
] = ""
27+
28+
729
class UserProxyWorkflowDefinition(BaseModel):
830
class Config:
931
json_schema_extra = {
@@ -37,11 +59,10 @@ class Config:
3759
UISchema(widget="textarea"),
3860
] = ""
3961
user_messages: Annotated[
40-
list[str],
62+
list[UserMessage],
4163
Field(
4264
description="A list of user messages that will be sequentially sent to the assistant during the workflow.",
4365
),
44-
UISchema(schema={"items": {"widget": "textarea"}}),
4566
] = []
4667

4768

libraries/python/assistant-extensions/assistant_extensions/workflows/runners/_user_proxy.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ConversationMessage,
99
MessageSender,
1010
MessageType,
11+
NewConversation,
1112
NewConversationMessage,
1213
UpdateParticipant,
1314
)
@@ -101,7 +102,7 @@ async def run(
101102
)
102103

103104
# duplicate the current conversation and get the context
104-
workflow_context = await self.duplicate_conversation(context)
105+
workflow_context = await self.duplicate_conversation(context, workflow_definition)
105106

106107
# set the current workflow id
107108
workflow_state = WorkflowState(
@@ -156,21 +157,41 @@ async def _listen_for_events(
156157
continue
157158
await self._on_assistant_message(context, workflow_state, message)
158159

159-
async def duplicate_conversation(self, context: ConversationContext) -> ConversationContext:
160+
async def duplicate_conversation(
161+
self, context: ConversationContext, workflow_definition: UserProxyWorkflowDefinition
162+
) -> ConversationContext:
160163
"""
161164
Duplicate the current conversation
162165
"""
163166

167+
title = f"Workflow: {workflow_definition.name} [{context.title}]"
168+
164169
# duplicate the current conversation
165-
response = await context._workbench_client.duplicate_conversation()
170+
response = await context._workbench_client.duplicate_conversation(
171+
new_conversation=NewConversation(
172+
title=title,
173+
metadata={"parent_conversation_id": context.id},
174+
)
175+
)
176+
177+
conversation_id = response.conversation_ids[0]
166178

167179
# create a new conversation context
168180
workflow_context = ConversationContext(
169-
id=str(response.conversation_ids[0]),
170-
title="Workflow",
181+
id=str(conversation_id),
182+
title=title,
171183
assistant=context.assistant,
172184
)
173185

186+
# send link to chat for the new conversation
187+
await context.send_messages(
188+
NewConversationMessage(
189+
content=f"New conversation: {title}",
190+
message_type=MessageType.command_response,
191+
metadata={"attribution": "workflows:user_proxy", "href": f"/{conversation_id}"},
192+
)
193+
)
194+
174195
# return the new conversation context
175196
return workflow_context
176197

@@ -187,7 +208,7 @@ async def _start_step(self, context: ConversationContext, workflow_state: Workfl
187208
await workflow_state.context.send_messages(
188209
NewConversationMessage(
189210
sender=workflow_state.send_as,
190-
content=user_message,
211+
content=user_message.message,
191212
message_type=MessageType.chat,
192213
metadata={"attribution": "user"},
193214
)
@@ -199,7 +220,7 @@ async def _start_step(self, context: ConversationContext, workflow_state: Workfl
199220
# )
200221
await context.update_participant_me(
201222
UpdateParticipant(
202-
status=f"Workflow {workflow_state.definition.name}: Step {workflow_state.current_step}, awaiting assistant response..."
223+
status=f"Workflow {workflow_state.definition.name} [Step {workflow_state.current_step} - {user_message.status_label}]: awaiting assistant response..."
203224
)
204225
)
205226

@@ -258,7 +279,7 @@ async def _send_final_response(
258279
NewConversationMessage(
259280
content=assistant_response.content,
260281
message_type=MessageType.chat,
261-
metadata={"attribution": "system"},
282+
metadata={"attribution": "workflows:user_proxy"},
262283
)
263284
)
264285

libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,14 @@ async def delete_conversation(self) -> None:
117117
return
118118
http_response.raise_for_status()
119119

120-
async def duplicate_conversation(self) -> workbench_model.ConversationImportResult:
120+
async def duplicate_conversation(
121+
self, new_conversation: workbench_model.NewConversation
122+
) -> workbench_model.ConversationImportResult:
121123
async with self._client as client:
122-
http_response = await client.post(f"/conversations/duplicate?id={self._conversation_id}")
124+
http_response = await client.post(
125+
f"/conversations/{self._conversation_id}",
126+
json=new_conversation.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
127+
)
123128
http_response.raise_for_status()
124129
return workbench_model.ConversationImportResult.model_validate(http_response.json())
125130

workbench-app/docs/MESSAGE_METADATA.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The app has built-in support for a few metadata child properties, which can be u
66

77
- `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.
88

9+
- `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`.
10+
911
- `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.
1012

1113
- `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.

workbench-app/src/components/Conversations/ConversationRemove.tsx

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,25 @@ const useConversationRemoveControls = () => {
1717
const [submitted, setSubmitted] = React.useState(false);
1818

1919
const handleRemove = React.useCallback(
20-
async (conversationId: string, participantId: string, onRemove?: () => void) => {
20+
async (conversations: Conversation[], participantId: string, onRemove?: () => void) => {
2121
if (submitted) {
2222
return;
2323
}
2424
setSubmitted(true);
2525

2626
try {
27-
if (activeConversationId === conversationId) {
28-
// Clear the active conversation if it is the one being removed
29-
dispatch(setActiveConversationId(undefined));
30-
}
27+
for (const conversation of conversations) {
28+
const conversationId = conversation.id;
29+
if (activeConversationId === conversationId) {
30+
// Clear the active conversation if it is the one being removed
31+
dispatch(setActiveConversationId(undefined));
32+
}
3133

32-
await removeConversationParticipant({
33-
conversationId,
34-
participantId,
35-
});
34+
await removeConversationParticipant({
35+
conversationId,
36+
participantId,
37+
});
38+
}
3639
onRemove?.();
3740
} finally {
3841
setSubmitted(false);
@@ -42,16 +45,21 @@ const useConversationRemoveControls = () => {
4245
);
4346

4447
const removeConversationForm = React.useCallback(
45-
() => <p>Are you sure you want to remove this conversation from your list?</p>,
48+
(hasMultipleConversations: boolean) =>
49+
hasMultipleConversations ? (
50+
<p>Are you sure you want to remove these conversations from your list ?</p>
51+
) : (
52+
<p>Are you sure you want to remove this conversation from your list ?</p>
53+
),
4654
[],
4755
);
4856

4957
const removeConversationButton = React.useCallback(
50-
(conversationId: string, participantId: string, onRemove?: () => void) => (
58+
(conversations: Conversation[], participantId: string, onRemove?: () => void) => (
5159
<DialogTrigger disableButtonEnhancement>
5260
<Button
5361
appearance="primary"
54-
onClick={() => handleRemove(conversationId, participantId, onRemove)}
62+
onClick={() => handleRemove(conversations, participantId, onRemove)}
5563
disabled={submitted}
5664
>
5765
{submitted ? 'Removing...' : 'Remove'}
@@ -68,51 +76,58 @@ const useConversationRemoveControls = () => {
6876
};
6977

7078
interface ConversationRemoveDialogProps {
71-
conversationId: string;
79+
conversations: Conversation | Conversation[];
7280
participantId: string;
7381
onRemove: () => void;
7482
onCancel: () => void;
7583
}
7684

7785
export const ConversationRemoveDialog: React.FC<ConversationRemoveDialogProps> = (props) => {
78-
const { conversationId, participantId, onRemove, onCancel } = props;
86+
const { conversations, participantId, onRemove, onCancel } = props;
7987
const { removeConversationForm, removeConversationButton } = useConversationRemoveControls();
8088

89+
const hasMultipleConversations = Array.isArray(conversations);
90+
const conversationsToRemove = hasMultipleConversations ? conversations : [conversations];
91+
8192
return (
8293
<DialogControl
8394
open={true}
8495
onOpenChange={onCancel}
85-
title="Remove Conversation"
86-
content={removeConversationForm()}
87-
additionalActions={[removeConversationButton(conversationId, participantId, onRemove)]}
96+
title={hasMultipleConversations ? 'Remove Conversations' : 'Remove Conversation'}
97+
content={removeConversationForm(hasMultipleConversations)}
98+
additionalActions={[removeConversationButton(conversationsToRemove, participantId, onRemove)]}
8899
/>
89100
);
90101
};
91102

92103
interface ConversationRemoveProps {
93-
conversation: Conversation;
104+
conversations: Conversation | Conversation[];
94105
participantId: string;
95106
onRemove?: () => void;
96107
iconOnly?: boolean;
97108
asToolbarButton?: boolean;
98109
}
99110

100111
export const ConversationRemove: React.FC<ConversationRemoveProps> = (props) => {
101-
const { conversation, onRemove, iconOnly, asToolbarButton, participantId } = props;
112+
const { conversations, onRemove, iconOnly, asToolbarButton, participantId } = props;
102113
const { removeConversationForm, removeConversationButton } = useConversationRemoveControls();
103114

115+
const hasMultipleConversations = Array.isArray(conversations);
116+
const conversationsToRemove = hasMultipleConversations ? conversations : [conversations];
117+
const description = hasMultipleConversations ? 'Remove Conversations' : 'Remove Conversation';
118+
104119
return (
105120
<CommandButton
106-
description="Remove Conversation"
121+
description={description}
107122
icon={<PlugDisconnected24Regular />}
108123
iconOnly={iconOnly}
109124
asToolbarButton={asToolbarButton}
110125
label="Remove"
111126
dialogContent={{
112-
title: 'Remove Conversation',
113-
content: removeConversationForm(),
127+
title: description,
128+
content: removeConversationForm(hasMultipleConversations),
114129
closeLabel: 'Cancel',
115-
additionalActions: [removeConversationButton(conversation.id, participantId, onRemove)],
130+
additionalActions: [removeConversationButton(conversationsToRemove, participantId, onRemove)],
116131
}}
117132
/>
118133
);

workbench-app/src/components/Conversations/InteractMessage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
TextBulletListSquareSparkleRegular,
3131
} from '@fluentui/react-icons';
3232
import React from 'react';
33+
import { Link } from 'react-router-dom';
3334
import { useConversationUtility } from '../../libs/useConversationUtility';
3435
import { useParticipantUtility } from '../../libs/useParticipantUtility';
3536
import { Utility } from '../../libs/Utility';
@@ -261,6 +262,7 @@ export const InteractMessage: React.FC<InteractMessageProps> = (props) => {
261262
);
262263

263264
const getRenderedMessage = React.useCallback(() => {
265+
let allowLink = true;
264266
let renderedContent: JSX.Element;
265267
if (message.messageType === 'notice') {
266268
renderedContent = (
@@ -299,11 +301,17 @@ export const InteractMessage: React.FC<InteractMessageProps> = (props) => {
299301
</div>
300302
);
301303
} else if (isUser) {
304+
allowLink = false;
302305
renderedContent = <UserMessage>{content}</UserMessage>;
303306
} else {
307+
allowLink = false;
304308
renderedContent = <CopilotMessage>{content}</CopilotMessage>;
305309
}
306310

311+
if (message.metadata?.href && allowLink) {
312+
renderedContent = <Link to={message.metadata?.href}>{renderedContent}</Link>;
313+
}
314+
307315
const attachmentList =
308316
message.filenames && message.filenames.length > 0 ? (
309317
<AttachmentList className={classes.attachments}>

workbench-app/src/components/Conversations/MyConversations.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const MyConversations: React.FC<MyConversationsProps> = (props) => {
6363
<ConversationDuplicate conversationId={conversation.id} iconOnly />
6464
<ConversationShare conversation={conversation} iconOnly />
6565
<ConversationRemove
66-
conversation={conversation}
66+
conversations={conversation}
6767
participantId={participantId}
6868
iconOnly
6969
/>

0 commit comments

Comments
 (0)