Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat: Implement Conversation Duplication & UI Improvements #5011

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
55 changes: 40 additions & 15 deletions client/src/components/Conversations/Convo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useToastContext } from '~/Providers';
import { ConvoOptions } from './ConvoOptions';
import { cn } from '~/utils';
import store from '~/store';
import { useLocalize } from '~/hooks'
import { useLocalize } from '~/hooks';

type KeyEvent = KeyboardEvent<HTMLInputElement>;

Expand Down Expand Up @@ -54,6 +54,7 @@ export default function Conversation({
}

event.preventDefault();

if (currentConvoId === conversationId || isPopoverActive) {
return;
}
Expand Down Expand Up @@ -144,18 +145,30 @@ export default function Conversation({
<input
ref={inputRef}
type="text"
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight outline-none"
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
value={titleInput ?? ''}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`}
/>
<div className="flex gap-1">
<button onClick={cancelRename} aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}>
<X aria-hidden={true} className="transition-colors h-4 w-4 duration-200 ease-in-out hover:opacity-70" />
<button
onClick={cancelRename}
aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}
>
<X
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button>
<button onClick={onRename} aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}>
<Check aria-hidden={true} className="transition-colors h-4 w-4 duration-200 ease-in-out hover:opacity-70" />
<button
onClick={onRename}
aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}
>
<Check
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button>
</div>
</div>
Expand All @@ -176,7 +189,17 @@ export default function Conversation({
size={20}
context="menu-item"
/>
<div className="relative line-clamp-1 flex-1 grow overflow-hidden">{title}</div>
<div
className="relative line-clamp-1 flex-1 grow overflow-hidden"
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTitleInput(title);
setRenaming(true);
}}
>
{title}
</div>
{isActiveConvo ? (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
) : (
Expand All @@ -192,14 +215,16 @@ export default function Conversation({
: 'hidden group-focus-within:flex group-hover:flex',
)}
>
<ConvoOptions
conversation={conversation}
retainView={retainView}
renameHandler={renameHandler}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
isActiveConvo={isActiveConvo}
/>
{!renaming && (
<ConvoOptions
conversation={conversation}
retainView={retainView}
renameHandler={renameHandler}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
isActiveConvo={isActiveConvo}
/>
)}
</div>
</div>
);
Expand Down
68 changes: 55 additions & 13 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 Ariakit from '@ariakit/react';
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { ForkOptions } from 'librechat-data-provider';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused import, fixing this

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 @@ -33,27 +63,39 @@ export default function ConvoOptions({
setShowDeleteDialog(true);
};

const duplicateHandler = () => {
setIsPopoverActive(false);
duplicateConversation.mutate({
conversationId,
});
};

const dropdownItems = [
{
label: localize('com_ui_share'),
onClick: shareHandler,
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
},
{
label: localize('com_ui_rename'),
onClick: renameHandler,
icon: <Pen className="icon-md mr-2 text-text-secondary" />,
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_share'),
onClick: shareHandler,
icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
label: localize('com_ui_duplicate'),
onClick: duplicateHandler,
icon: <Copy className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_archive'),
onClick: archiveHandler,
icon: <Archive className="icon-md mr-2 text-text-secondary" />,
icon: <Archive className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_delete'),
onClick: deleteHandler,
icon: <Trash className="icon-md mr-2 text-text-secondary" />,
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
},
];

Expand All @@ -65,7 +107,7 @@ export default function ConvoOptions({
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<Ariakit.MenuButton
<Menu.MenuButton
id="conversation-menu-button"
aria-label={localize('com_nav_convo_menu_options')}
className={cn(
Expand All @@ -76,7 +118,7 @@ export default function ConvoOptions({
)}
>
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
</Ariakit.MenuButton>
</Menu.MenuButton>
}
items={dropdownItems}
menuId={menuId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useUpdateSharedLinkMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';

export default function SharedLinkButton({
Expand Down Expand Up @@ -112,7 +111,7 @@ export default function SharedLinkButton({
onClick={() => {
handlers.handler();
}}
className="btn btn-primary flex items-center"
className="btn btn-primary flex items-center justify-center"
>
{isCopying && (
<>
Expand Down
13 changes: 8 additions & 5 deletions client/src/components/Nav/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,14 @@ const Nav = ({
<SearchBar clearSearch={clearSearch} isSmallScreen={isSmallScreen} />
)}
{hasAccessToBookmarks === true && (
<BookmarkNav
tags={tags}
setTags={setTags}
isSmallScreen={isSmallScreen}
/>
<>
<div className="mt-1.5" />
<BookmarkNav
tags={tags}
setTags={setTags}
isSmallScreen={isSmallScreen}
/>
</>
)}
</>
}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Nav/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
}
<input
type="text"
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
value={text}
onChange={onChange}
onKeyDown={(e) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const RevokeKeysButton = ({
<OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
className="duration-200w flex w-full items-center justify-center rounded-lg transition-colors"
onClick={() => setOpen(true)}
disabled={disabled}
>
Expand Down
Loading