From 2c8bbc1150f1f413e72624358c18b27b0a9661b5 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 16 Dec 2024 18:32:18 +0100 Subject: [PATCH] feat(conversation): add duplicate conversation functionality and UI integration --- api/server/routes/convos.js | 20 +++++- api/server/utils/import/fork.js | 70 +++++++++++++++++++ .../Chat/Input/Files/AttachFileMenu.tsx | 2 +- .../ConvoOptions/ConvoOptions.tsx | 38 +++++++++- client/src/data-provider/mutations.ts | 33 +++++++++ client/src/localization/languages/Eng.ts | 3 + packages/data-provider/src/api-endpoints.ts | 2 + packages/data-provider/src/data-service.ts | 6 ++ packages/data-provider/src/types.ts | 9 +++ packages/data-provider/src/types/mutations.ts | 5 ++ 10 files changed, 182 insertions(+), 6 deletions(-) diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 0aec01b8eed..a4d81e24e63 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -2,9 +2,9 @@ const multer = require('multer'); const express = require('express'); const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation'); +const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork'); const { storage, importFileFilter } = require('~/server/routes/files/multer'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); -const { forkConversation } = require('~/server/utils/import/fork'); const { importConversations } = require('~/server/utils/import'); const { createImportLimiters } = require('~/server/middleware'); const { deleteToolCalls } = require('~/models/ToolCall'); @@ -182,9 +182,25 @@ router.post('/fork', async (req, res) => { res.json(result); } catch (error) { - logger.error('Error forking conversation', error); + logger.error('Error forking conversation:', error); res.status(500).send('Error forking conversation'); } }); +router.post('/duplicate', async (req, res) => { + const { conversationId, title } = req.body; + + try { + const result = await duplicateConversation({ + userId: req.user.id, + conversationId, + title, + }); + res.status(201).json(result); + } catch (error) { + logger.error('Error duplicating conversation:', error); + res.status(500).send('Error duplicating conversation'); + } +}); + module.exports = router; diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index cb75d7863bb..22f05ab72b0 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -306,9 +306,79 @@ function splitAtTargetLevel(messages, targetMessageId) { return filteredMessages; } +/** + * Duplicates a conversation and all its messages. + * @param {object} params - The parameters for duplicating the conversation. + * @param {string} params.userId - The ID of the user duplicating the conversation. + * @param {string} params.conversationId - The ID of the conversation to duplicate. + * @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages. + */ +async function duplicateConversation({ userId, conversationId }) { + // Get original conversation + const originalConvo = await getConvo(userId, conversationId); + if (!originalConvo) { + throw new Error('Conversation not found'); + } + + // Get original messages + const originalMessages = await getMessages({ + user: userId, + conversationId, + }); + + const messagesToClone = getMessagesUpToTargetLevel( + originalMessages, + originalMessages[originalMessages.length - 1].messageId, + ); + + const idMapping = new Map(); + const importBatchBuilder = createImportBatchBuilder(userId); + importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); + + for (const message of messagesToClone) { + const newMessageId = uuidv4(); + idMapping.set(message.messageId, newMessageId); + + const clonedMessage = { + ...message, + messageId: newMessageId, + parentMessageId: + message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT + ? idMapping.get(message.parentMessageId) + : Constants.NO_PARENT, + }; + + importBatchBuilder.saveMessage(clonedMessage); + } + + const result = importBatchBuilder.finishConversation( + originalConvo.title + ' (Copy)', + new Date(), + originalConvo, + ); + await importBatchBuilder.saveBatch(); + logger.debug( + `user: ${userId} | New conversation "${ + originalConvo.title + ' (Copy)' + }" duplicated from conversation ID ${conversationId}`, + ); + + const conversation = await getConvo(userId, result.conversation.conversationId); + const messages = await getMessages({ + user: userId, + conversationId: conversation.conversationId, + }); + + return { + conversation, + messages, + }; +} + module.exports = { forkConversation, splitAtTargetLevel, getAllMessagesUpToParent, getMessagesUpToTargetLevel, + duplicateConversation, }; diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index a6854d4a704..7c1f67787be 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -82,7 +82,7 @@ const AttachFile = ({ isRTL, disabled, setToolResource, handleFileChange }: Atta return ( -
+
{ + if (data) { + navigateToConvo(data.conversation); + showToast({ + message: localize('com_ui_duplication_success'), + status: 'success', + }); + } + }, + onMutate: () => { + showToast({ + message: localize('com_ui_duplication_processing'), + status: 'info', + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_duplication_error'), + status: 'error', + }); + }, + }); const shareHandler = () => { setIsPopoverActive(false); @@ -35,7 +65,9 @@ export default function ConvoOptions({ const duplicateHandler = () => { setIsPopoverActive(false); - console.log('Duplicate conversation'); + duplicateConversation.mutate({ + conversationId, + }); }; const dropdownItems = [ diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index eb4b5dd8c9a..c4776475707 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -573,6 +573,39 @@ export const useDeleteConversationMutation = ( ); }; +export const useDuplicateConversationMutation = ( + options?: t.DeleteConversationOptions, +): UseMutationResult => { + const queryClient = useQueryClient(); + const { onSuccess, ..._options } = options || {}; + return useMutation( + (payload: t.TDuplicateConvoRequest) => dataService.duplicateConversation(payload), + { + onSuccess: (data, vars, context) => { + if (!vars.conversationId) { + return; + } + queryClient.setQueryData( + [QueryKeys.conversation, data.conversation.conversationId], + data.conversation, + ); + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return addConversation(convoData, data.conversation); + }); + queryClient.setQueryData( + [QueryKeys.messages, data.conversation.conversationId], + data.messages, + ); + onSuccess?.(data, vars, context); + }, + ..._options, + }, + ); +}; + export const useForkConvoMutation = ( options?: t.ForkConvoOptions, ): UseMutationResult => { diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 99de616bb79..caf4035bc32 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -297,6 +297,9 @@ export default { com_ui_mention: 'Mention an endpoint, assistant, or preset to quickly switch to it', com_ui_add_model_preset: 'Add a model or preset for an additional response', com_assistants_max_starters_reached: 'Max number of conversation starters reached', + com_ui_duplication_success: 'Successfully duplicated conversation', + com_ui_duplication_processing: 'Duplicating conversation...', + com_ui_duplication_error: 'There was an error duplicating the conversation', com_ui_regenerate: 'Regenerate', com_ui_continue: 'Continue', com_ui_edit: 'Edit', diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 46b67f2d40a..81dbf89ac1c 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -50,6 +50,8 @@ export const importConversation = () => `${conversationsRoot}/import`; export const forkConversation = () => `${conversationsRoot}/fork`; +export const duplicateConversation = () => `${conversationsRoot}/duplicate`; + export const search = (q: string, pageNumber: string) => `/api/search?q=${q}&pageNumber=${pageNumber}`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index d0c6a80818f..eca385686eb 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -559,6 +559,12 @@ export const getCustomConfigSpeech = (): Promise /* conversations */ +export function duplicateConversation( + payload: t.TDuplicateConvoRequest, +): Promise { + return request.post(endpoints.duplicateConversation(), payload); +} + export function forkConversation(payload: t.TForkConvoRequest): Promise { return request.post(endpoints.forkConversation(), payload); } diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 4244d73fa03..7d21fa9ae6f 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -203,6 +203,15 @@ export type TTagConversationRequest = { }; export type TTagConversationResponse = string[]; +export type TDuplicateConvoRequest = { + conversationId: string; +}; + +export type TDuplicateConvoResponse = { + conversation: TConversation; + messages: TMessage[]; +}; + export type TForkConvoRequest = { messageId: string; conversationId: string; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index f5d867d7252..7d3706cb6c4 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -150,6 +150,11 @@ export type DeleteConversationOptions = MutationOptions< types.TDeleteConversationRequest >; +export type DuplicateConvoOptions = MutationOptions< + types.TDuplicateConvoResponse, + types.TDuplicateConvoRequest +>; + export type ForkConvoOptions = MutationOptions; export type CreateSharedLinkOptions = MutationOptions<