Skip to content

Commit

Permalink
feat(conversation): add duplicate conversation functionality and UI i…
Browse files Browse the repository at this point in the history
…ntegration
  • Loading branch information
berry-13 committed Dec 16, 2024
1 parent 1f4777d commit 2c8bbc1
Show file tree
Hide file tree
Showing 10 changed files with 182 additions and 6 deletions.
20 changes: 18 additions & 2 deletions api/server/routes/convos.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
70 changes: 70 additions & 0 deletions api/server/utils/import/fork.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
2 changes: 1 addition & 1 deletion client/src/components/Chat/Input/Files/AttachFileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const AttachFile = ({ isRTL, disabled, setToolResource, handleFileChange }: Atta

return (
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
<div className="relative">
<div className="relative select-none">
<DropdownPopup
menuId="attach-file-menu"
isOpen={isPopoverActive}
Expand Down
38 changes: 35 additions & 3 deletions client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useState, useId } from 'react';
import * as Menu from '@ariakit/react/menu';
import { ForkOptions } from 'librechat-data-provider';
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import { useLocalize, useArchiveHandler } from '~/hooks';
import { useLocalize, useArchiveHandler, useNavigateToConvo } from '~/hooks';
import { useToastContext, useChatContext } from '~/Providers';
import { useDuplicateConversationMutation } from '~/data-provider';
import { DropdownPopup } from '~/components/ui';
import DeleteButton from './DeleteButton';
import ShareButton from './ShareButton';
Expand All @@ -17,11 +20,38 @@ export default function ConvoOptions({
isActiveConvo,
}) {
const localize = useLocalize();
const { index } = useChatContext();
const { data: startupConfig } = useGetStartupConfig();
const { conversationId, title } = conversation;
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
const { navigateToConvo } = useNavigateToConvo(index);
const { showToast } = useToastContext();
const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const archiveHandler = useArchiveHandler(conversationId, true, retainView);

const duplicateConversation = useDuplicateConversationMutation({
onSuccess: (data) => {
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);
Expand All @@ -35,7 +65,9 @@ export default function ConvoOptions({

const duplicateHandler = () => {
setIsPopoverActive(false);
console.log('Duplicate conversation');
duplicateConversation.mutate({
conversationId,
});
};

const dropdownItems = [
Expand Down
33 changes: 33 additions & 0 deletions client/src/data-provider/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,39 @@ export const useDeleteConversationMutation = (
);
};

export const useDuplicateConversationMutation = (
options?: t.DeleteConversationOptions,
): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => {
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<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
if (!convoData) {
return convoData;
}
return addConversation(convoData, data.conversation);
});
queryClient.setQueryData<t.TMessage[]>(
[QueryKeys.messages, data.conversation.conversationId],
data.messages,
);
onSuccess?.(data, vars, context);
},
..._options,
},
);
};

export const useForkConvoMutation = (
options?: t.ForkConvoOptions,
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
Expand Down
3 changes: 3 additions & 0 deletions client/src/localization/languages/Eng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/data-provider/src/api-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;

Expand Down
6 changes: 6 additions & 0 deletions packages/data-provider/src/data-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,12 @@ export const getCustomConfigSpeech = (): Promise<t.TCustomConfigSpeechResponse>

/* conversations */

export function duplicateConversation(
payload: t.TDuplicateConvoRequest,
): Promise<t.TDuplicateConvoResponse> {
return request.post(endpoints.duplicateConversation(), payload);
}

export function forkConversation(payload: t.TForkConvoRequest): Promise<t.TForkConvoResponse> {
return request.post(endpoints.forkConversation(), payload);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/data-provider/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/data-provider/src/types/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ export type DeleteConversationOptions = MutationOptions<
types.TDeleteConversationRequest
>;

export type DuplicateConvoOptions = MutationOptions<
types.TDuplicateConvoResponse,
types.TDuplicateConvoRequest
>;

export type ForkConvoOptions = MutationOptions<types.TForkConvoResponse, types.TForkConvoRequest>;

export type CreateSharedLinkOptions = MutationOptions<
Expand Down

0 comments on commit 2c8bbc1

Please sign in to comment.