diff --git a/workbench-app/src/components/App/DialogControl.tsx b/workbench-app/src/components/App/DialogControl.tsx index cb35b4b3..024c8c60 100644 --- a/workbench-app/src/components/App/DialogControl.tsx +++ b/workbench-app/src/components/App/DialogControl.tsx @@ -9,9 +9,20 @@ import { DialogSurface, DialogTitle, DialogTrigger, + makeStyles, + mergeClasses, + tokens, } from '@fluentui/react-components'; import React from 'react'; +const useClasses = makeStyles({ + dialogContent: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, +}); + export interface DialogControlContent { open?: boolean; defaultOpen?: boolean; @@ -42,13 +53,19 @@ export const DialogControl: React.FC = (props) => { onOpenChange, } = props; + const classes = useClasses(); + return ( {trigger} {title && {title}} - {content && {content}} + {content && ( + + {content} + + )} {!hideDismissButton && ( diff --git a/workbench-app/src/components/Assistants/AssistantDuplicate.tsx b/workbench-app/src/components/Assistants/AssistantDuplicate.tsx index ecb1c677..f7c4d7b1 100644 --- a/workbench-app/src/components/Assistants/AssistantDuplicate.tsx +++ b/workbench-app/src/components/Assistants/AssistantDuplicate.tsx @@ -27,7 +27,7 @@ export const AssistantDuplicate: React.FC = (props) => setSubmitted(true); try { - const newAssistantId = await workbenchService.duplicateAssistantAsync(assistant.id); + const newAssistantId = await workbenchService.exportThenImportAssistantAsync(assistant.id); onDuplicate?.(newAssistantId); } catch (error) { onDuplicateError?.(error as Error); diff --git a/workbench-app/src/components/Conversations/ConversationDuplicate.tsx b/workbench-app/src/components/Conversations/ConversationDuplicate.tsx index 22598585..ffc2ab74 100644 --- a/workbench-app/src/components/Conversations/ConversationDuplicate.tsx +++ b/workbench-app/src/components/Conversations/ConversationDuplicate.tsx @@ -1,35 +1,93 @@ // Copyright (c) Microsoft. All rights reserved. -import { Button, DialogOpenChangeData, DialogOpenChangeEvent, DialogTrigger } from '@fluentui/react-components'; +import { + Button, + DialogOpenChangeData, + DialogOpenChangeEvent, + DialogTrigger, + Field, + Input, + Radio, + RadioGroup, +} from '@fluentui/react-components'; import { SaveCopy24Regular } from '@fluentui/react-icons'; import React from 'react'; import { useNotify } from '../../libs/useNotify'; import { useWorkbenchService } from '../../libs/useWorkbenchService'; import { Utility } from '../../libs/Utility'; +import { useDuplicateConversationMutation } from '../../services/workbench'; import { CommandButton } from '../App/CommandButton'; import { DialogControl } from '../App/DialogControl'; +const enum AssistantParticipantOption { + SameAssistants = 'Include the same assistants in the new conversation.', + CloneAssistants = 'Create copies of the assistants in the new conversation.', +} + const useConversationDuplicateControls = (id: string) => { const workbenchService = useWorkbenchService(); + const [assistantParticipantOption, setAssistantParticipantOption] = React.useState( + AssistantParticipantOption.SameAssistants, + ); + const [duplicateConversation] = useDuplicateConversationMutation(); const [submitted, setSubmitted] = React.useState(false); + const [title, setTitle] = React.useState(''); - const duplicateConversation = React.useCallback( + const handleDuplicateConversation = React.useCallback( async (onDuplicate?: (conversationId: string) => Promise, onError?: (error: Error) => void) => { try { await Utility.withStatus(setSubmitted, async () => { - const duplicates = await workbenchService.duplicateConversationsAsync([id]); - await onDuplicate?.(duplicates[0]); + switch (assistantParticipantOption) { + case AssistantParticipantOption.SameAssistants: + const results = await duplicateConversation({ id, title }).unwrap(); + if (results.conversationIds.length === 0) { + throw new Error('No conversation ID returned'); + } + await onDuplicate?.(results.conversationIds[0]); + break; + case AssistantParticipantOption.CloneAssistants: + const duplicateIds = await workbenchService.exportThenImportConversationAsync([id]); + await onDuplicate?.(duplicateIds[0]); + break; + } }); } catch (error) { onError?.(error as Error); } }, - [id, workbenchService], + [assistantParticipantOption, duplicateConversation, id, title, workbenchService], ); const duplicateConversationForm = React.useCallback( - () =>

Are you sure you want to duplicate this conversation?

, - [], + () => ( + <> + + setTitle(data.value)} + required={true} + placeholder="Enter a title for the duplicated conversation" + /> + + + setAssistantParticipantOption(data.value as AssistantParticipantOption)} + required={true} + > + + + + + + ), + [assistantParticipantOption, title], ); const duplicateConversationButton = React.useCallback( @@ -37,13 +95,13 @@ const useConversationDuplicateControls = (id: string) => { ), - [duplicateConversation, submitted], + [handleDuplicateConversation, submitted], ); return { diff --git a/workbench-app/src/libs/useWorkbenchService.ts b/workbench-app/src/libs/useWorkbenchService.ts index e08b1eb1..c11ad6f8 100644 --- a/workbench-app/src/libs/useWorkbenchService.ts +++ b/workbench-app/src/libs/useWorkbenchService.ts @@ -267,7 +267,7 @@ export const useWorkbenchService = () => { [dispatch, environment.url, tryFetchAsync], ); - const duplicateConversationsAsync = React.useCallback( + const exportThenImportConversationAsync = React.useCallback( async (conversationIds: string[]) => { const { blob, filename } = await exportConversationsAsync(conversationIds); const result = await importConversationsAsync(new File([blob], filename)); @@ -289,7 +289,7 @@ export const useWorkbenchService = () => { [tryFetchFileAsync], ); - const duplicateAssistantAsync = React.useCallback( + const exportThenImportAssistantAsync = React.useCallback( async (assistantId: string) => { const { blob, filename } = await exportAssistantAsync(assistantId); const result = await importConversationsAsync(new File([blob], filename)); @@ -351,9 +351,9 @@ export const useWorkbenchService = () => { exportTranscriptAsync, exportConversationsAsync, importConversationsAsync, - duplicateConversationsAsync, + exportThenImportConversationAsync, exportAssistantAsync, - duplicateAssistantAsync, + exportThenImportAssistantAsync, getAssistantServiceInfoAsync, getAssistantServiceInfosAsync, }; diff --git a/workbench-app/src/routes/ShareRedeem.tsx b/workbench-app/src/routes/ShareRedeem.tsx index ddd47ab1..488b3186 100644 --- a/workbench-app/src/routes/ShareRedeem.tsx +++ b/workbench-app/src/routes/ShareRedeem.tsx @@ -88,7 +88,7 @@ export const ShareRedeem: React.FC = () => { const redemption = await redeemShare(conversationShare.id).unwrap(); // duplicate it - const duplicatedConversationIds = await workbenchService.duplicateConversationsAsync([ + const duplicatedConversationIds = await workbenchService.exportThenImportConversationAsync([ redemption.conversationId, ]); diff --git a/workbench-app/src/services/workbench/conversation.ts b/workbench-app/src/services/workbench/conversation.ts index 1b5c0817..5ebdf2d7 100644 --- a/workbench-app/src/services/workbench/conversation.ts +++ b/workbench-app/src/services/workbench/conversation.ts @@ -25,6 +25,18 @@ export const conversationApi = workbenchApi.injectEndpoints({ invalidatesTags: ['Conversation'], transformResponse: (response: any) => transformResponseToConversation(response), }), + duplicateConversation: builder.mutation< + { conversationIds: string[]; assistantIds: string[] }, + Pick + >({ + query: (body) => ({ + url: `/conversations/${body.id}`, + method: 'POST', + body: transformConversationForRequest(body), + }), + invalidatesTags: ['Conversation'], + transformResponse: (response: any) => transformResponseToImportResult(response), + }), updateConversation: builder.mutation>({ query: (body) => ({ url: `/conversations/${body.id}`, @@ -163,6 +175,7 @@ export const updateGetConversationMessagesQueryData = (conversationId: string, d export const { useCreateConversationMutation, + useDuplicateConversationMutation, useUpdateConversationMutation, useGetConversationsQuery, useGetAssistantConversationsQuery, @@ -203,6 +216,17 @@ const transformResponseToConversation = (response: any): Conversation => { } }; +const transformResponseToImportResult = (response: any): { conversationIds: string[]; assistantIds: string[] } => { + try { + return { + conversationIds: response.conversation_ids, + assistantIds: response.assistant_ids, + }; + } catch (error) { + throw new Error(`Failed to transform import result response: ${error}`); + } +}; + const transformResponseToConversationMessages = (response: any): ConversationMessage[] => { try { return response.messages.map(transformResponseToMessage); diff --git a/workbench-service/semantic_workbench_service/controller/assistant.py b/workbench-service/semantic_workbench_service/controller/assistant.py index 45c306f6..0e3ad241 100644 --- a/workbench-service/semantic_workbench_service/controller/assistant.py +++ b/workbench-service/semantic_workbench_service/controller/assistant.py @@ -873,22 +873,62 @@ async def duplicate_conversation( .where(db.ConversationMessage.conversation_id == conversation_id) .order_by(col(db.ConversationMessage.sequence)) ) + message_id_old_to_new = {} for message in messages: + new_message_id = uuid.uuid4() + message_id_old_to_new[message.message_id] = new_message_id new_message = db.ConversationMessage( # Do not set 'sequence'; let the database assign it - message_id=uuid.uuid4(), # Generate a new unique message ID + **message.model_dump(exclude={"message_id", "conversation_id", "sequence"}), + message_id=new_message_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, - message_type=message.message_type, - content=message.content, - content_type=message.content_type, - meta_data=message.meta_data.copy(), - filenames=message.filenames.copy(), ) session.add(new_message) + # Copy message debug data from the original conversation + for old_message_id, new_message_id in message_id_old_to_new.items(): + message_debugs = await session.exec( + select(db.ConversationMessageDebug).where(db.ConversationMessageDebug.message_id == old_message_id) + ) + for debug in message_debugs: + new_debug = db.ConversationMessageDebug( + **debug.model_dump(exclude={"message_id"}), + message_id=new_message_id, + ) + session.add(new_debug) + + # Copy File entries associated with the conversation + files = await session.exec( + select(db.File) + .where(db.File.conversation_id == original_conversation.conversation_id) + .order_by(col(db.File.created_datetime).asc()) + ) + + file_id_old_to_new = {} + for file in files: + new_file_id = uuid.uuid4() + file_id_old_to_new[file.file_id] = new_file_id + new_file = db.File( + **file.model_dump(exclude={"file_id", "conversation_id"}), + file_id=new_file_id, + conversation_id=conversation.conversation_id, + ) + session.add(new_file) + + # Copy FileVersion entries associated with the files + for old_file_id, new_file_id in file_id_old_to_new.items(): + file_versions = await session.exec( + select(db.FileVersion) + .where(db.FileVersion.file_id == old_file_id) + .order_by(col(db.FileVersion.version).asc()) + ) + for version in file_versions: + new_version = db.FileVersion( + **version.model_dump(exclude={"file_id"}), + file_id=new_file_id, + ) + session.add(new_version) + # Copy files associated with the conversation original_files_path = self._file_storage.path_for( namespace=str(original_conversation.conversation_id), filename=""