From e8bde332c2d09798b107fcfeb1ef4a711932e0f1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 18 Dec 2024 11:10:34 -0500 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat:=20Implement=20Conversatio?= =?UTF-8?q?n=20Duplication=20&=20UI=20Improvements=20(#5036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): enhance conversation components and add duplication - feat: add conversation duplication functionality - fix: resolve OGDialogTemplate display issues - style: improve mobile dropdown component design - chore: standardize shared link title formatting * style: update active item background color in select-item * feat(conversation): add duplicate conversation functionality and UI integration * feat(conversation): enable title renaming on double-click and improve input focus styles * fix(conversation): remove "(Copy)" suffix from duplicated conversation title in logging * fix(RevokeKeysButton): correct className duration property for smoother transitions * refactor(conversation): ensure proper parent-child relationships and timestamps when message cloning --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com> --- api/models/convoStructure.spec.js | 90 +++++ api/server/routes/convos.js | 20 +- api/server/utils/import/fork.js | 135 +++++++- api/server/utils/import/fork.spec.js | 319 +++++++++++++++++- .../Chat/Input/Files/AttachFileMenu.tsx | 2 +- client/src/components/Conversations/Convo.tsx | 36 +- .../ConvoOptions/ConvoOptions.tsx | 70 +++- .../ConvoOptions/SharedLinkButton.tsx | 3 +- client/src/components/Nav/Nav.tsx | 13 +- client/src/components/Nav/SearchBar.tsx | 2 +- .../Nav/SettingsTabs/Data/SharedLinkTable.tsx | 2 +- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 2 +- .../src/components/Prompts/AdminSettings.tsx | 3 +- .../SidePanel/Agents/AdminSettings.tsx | 3 +- client/src/components/ui/Dropdown.tsx | 2 +- client/src/components/ui/DropdownPopup.tsx | 9 +- client/src/components/ui/OGDialogTemplate.tsx | 20 +- client/src/data-provider/mutations.ts | 37 ++ client/src/localization/languages/Eng.ts | 6 +- client/src/style.css | 4 +- 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 + 24 files changed, 716 insertions(+), 84 deletions(-) diff --git a/api/models/convoStructure.spec.js b/api/models/convoStructure.spec.js index 31c7eda24b9..e672e0fa1ca 100644 --- a/api/models/convoStructure.spec.js +++ b/api/models/convoStructure.spec.js @@ -220,4 +220,94 @@ describe('Conversation Structure Tests', () => { } expect(currentNode.children.length).toBe(0); // Last message should have no children }); + + test('Random order dates between parent and children messages', async () => { + const userId = 'testUser'; + const conversationId = 'testConversation'; + + // Create messages with deliberately out-of-order timestamps but sequential creation + const messages = [ + { + messageId: 'parent', + parentMessageId: null, + text: 'Parent Message', + createdAt: new Date('2023-01-01T00:00:00Z'), // Make parent earliest + }, + { + messageId: 'child1', + parentMessageId: 'parent', + text: 'Child Message 1', + createdAt: new Date('2023-01-01T00:01:00Z'), + }, + { + messageId: 'child2', + parentMessageId: 'parent', + text: 'Child Message 2', + createdAt: new Date('2023-01-01T00:02:00Z'), + }, + { + messageId: 'grandchild1', + parentMessageId: 'child1', + text: 'Grandchild Message 1', + createdAt: new Date('2023-01-01T00:03:00Z'), + }, + ]; + + // Add common properties to all messages + messages.forEach((msg) => { + msg.conversationId = conversationId; + msg.user = userId; + msg.isCreatedByUser = false; + msg.error = false; + msg.unfinished = false; + }); + + // Save messages with overrideTimestamp set to true + await bulkSaveMessages(messages, true); + + // Retrieve messages + const retrievedMessages = await getMessages({ conversationId, user: userId }); + + // Debug log to see what's being returned + console.log( + 'Retrieved Messages:', + retrievedMessages.map((msg) => ({ + messageId: msg.messageId, + parentMessageId: msg.parentMessageId, + createdAt: msg.createdAt, + })), + ); + + // Build tree + const tree = buildTree({ messages: retrievedMessages }); + + // Debug log to see the tree structure + console.log( + 'Tree structure:', + tree.map((root) => ({ + messageId: root.messageId, + children: root.children.map((child) => ({ + messageId: child.messageId, + children: child.children.map((grandchild) => ({ + messageId: grandchild.messageId, + })), + })), + })), + ); + + // Verify the structure before making assertions + expect(retrievedMessages.length).toBe(4); // Should have all 4 messages + + // Check if messages are properly linked + const parentMsg = retrievedMessages.find((msg) => msg.messageId === 'parent'); + expect(parentMsg.parentMessageId).toBeNull(); // Parent should have null parentMessageId + + const childMsg1 = retrievedMessages.find((msg) => msg.messageId === 'child1'); + expect(childMsg1.parentMessageId).toBe('parent'); + + // Then check tree structure + expect(tree.length).toBe(1); // Should have only one root message + expect(tree[0].messageId).toBe('parent'); + expect(tree[0].children.length).toBe(2); // Should have two children + }); }); 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..5aa2599fe36 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -6,6 +6,69 @@ const { getConvo } = require('~/models/Conversation'); const { getMessages } = require('~/models/Message'); const logger = require('~/config/winston'); +/** + * Helper function to clone messages with proper parent-child relationships and timestamps + * @param {TMessage[]} messagesToClone - Original messages to clone + * @param {ImportBatchBuilder} importBatchBuilder - Instance of ImportBatchBuilder + * @returns {Map} Map of original messageIds to new messageIds + */ +function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) { + const idMapping = new Map(); + + // First pass: create ID mapping and sort messages by parentMessageId + const sortedMessages = [...messagesToClone].sort((a, b) => { + if (a.parentMessageId === Constants.NO_PARENT) { + return -1; + } + if (b.parentMessageId === Constants.NO_PARENT) { + return 1; + } + return 0; + }); + + // Helper function to ensure date object + const ensureDate = (dateValue) => { + if (!dateValue) { + return new Date(); + } + return dateValue instanceof Date ? dateValue : new Date(dateValue); + }; + + // Second pass: clone messages while maintaining proper timestamps + for (const message of sortedMessages) { + const newMessageId = uuidv4(); + idMapping.set(message.messageId, newMessageId); + + const parentId = + message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT + ? idMapping.get(message.parentMessageId) + : Constants.NO_PARENT; + + // If this message has a parent, ensure its timestamp is after the parent's + let createdAt = ensureDate(message.createdAt); + if (parentId !== Constants.NO_PARENT) { + const parentMessage = importBatchBuilder.messages.find((msg) => msg.messageId === parentId); + if (parentMessage) { + const parentDate = ensureDate(parentMessage.createdAt); + if (createdAt <= parentDate) { + createdAt = new Date(parentDate.getTime() + 1); + } + } + } + + const clonedMessage = { + ...message, + messageId: newMessageId, + parentMessageId: parentId, + createdAt, + }; + + importBatchBuilder.saveMessage(clonedMessage); + } + + return idMapping; +} + /** * * @param {object} params - The parameters for the importer. @@ -65,23 +128,7 @@ async function forkConversation({ messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId); } - const idMapping = new Map(); - - 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); - } + cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); const result = importBatchBuilder.finishConversation( newTitle || originalConvo.title, @@ -306,9 +353,63 @@ 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 importBatchBuilder = createImportBatchBuilder(userId); + importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); + + cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); + + const result = importBatchBuilder.finishConversation( + originalConvo.title, + new Date(), + originalConvo, + ); + await importBatchBuilder.saveBatch(); + logger.debug( + `user: ${userId} | New conversation "${originalConvo.title}" 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, + duplicateConversation, getAllMessagesUpToParent, getMessagesUpToTargetLevel, + cloneMessagesWithTimestamps, }; diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index 003f83ab2ff..4520e977bfb 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -25,9 +25,11 @@ const { splitAtTargetLevel, getAllMessagesUpToParent, getMessagesUpToTargetLevel, + cloneMessagesWithTimestamps, } = require('./fork'); const { getConvo, bulkSaveConvos } = require('~/models/Conversation'); const { getMessages, bulkSaveMessages } = require('~/models/Message'); +const { createImportBatchBuilder } = require('./importBatchBuilder'); const BaseClient = require('~/app/clients/BaseClient'); /** @@ -104,7 +106,8 @@ describe('forkConversation', () => { expect(bulkSaveMessages).toHaveBeenCalledWith( expect.arrayContaining( expectedMessagesTexts.map((text) => expect.objectContaining({ text })), - ), true, + ), + true, ); }); @@ -122,7 +125,8 @@ describe('forkConversation', () => { expect(bulkSaveMessages).toHaveBeenCalledWith( expect.arrayContaining( expectedMessagesTexts.map((text) => expect.objectContaining({ text })), - ), true, + ), + true, ); }); @@ -141,7 +145,8 @@ describe('forkConversation', () => { expect(bulkSaveMessages).toHaveBeenCalledWith( expect.arrayContaining( expectedMessagesTexts.map((text) => expect.objectContaining({ text })), - ), true, + ), + true, ); }); @@ -160,7 +165,8 @@ describe('forkConversation', () => { expect(bulkSaveMessages).toHaveBeenCalledWith( expect.arrayContaining( expectedMessagesTexts.map((text) => expect.objectContaining({ text })), - ), true, + ), + true, ); }); @@ -572,3 +578,308 @@ describe('splitAtTargetLevel', () => { expect(result.length).toBe(0); }); }); + +describe('cloneMessagesWithTimestamps', () => { + test('should maintain proper timestamp order between parent and child messages', () => { + // Create messages with out-of-order timestamps + const messagesToClone = [ + { + messageId: 'parent', + parentMessageId: Constants.NO_PARENT, + text: 'Parent Message', + createdAt: '2023-01-01T00:02:00Z', // Later timestamp + }, + { + messageId: 'child1', + parentMessageId: 'parent', + text: 'Child Message 1', + createdAt: '2023-01-01T00:01:00Z', // Earlier timestamp + }, + { + messageId: 'child2', + parentMessageId: 'parent', + text: 'Child Message 2', + createdAt: '2023-01-01T00:03:00Z', + }, + ]; + + const importBatchBuilder = createImportBatchBuilder('testUser'); + importBatchBuilder.startConversation(); + + cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); + + // Verify timestamps are properly ordered + const clonedMessages = importBatchBuilder.messages; + expect(clonedMessages.length).toBe(3); + + // Find cloned messages (they'll have new IDs) + const parent = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT); + const children = clonedMessages.filter((msg) => msg.parentMessageId === parent.messageId); + + // Verify parent timestamp is earlier than all children + children.forEach((child) => { + expect(new Date(child.createdAt).getTime()).toBeGreaterThan( + new Date(parent.createdAt).getTime(), + ); + }); + }); + + test('should handle multi-level message chains', () => { + const messagesToClone = [ + { + messageId: 'root', + parentMessageId: Constants.NO_PARENT, + text: 'Root', + createdAt: '2023-01-01T00:03:00Z', // Latest + }, + { + messageId: 'parent', + parentMessageId: 'root', + text: 'Parent', + createdAt: '2023-01-01T00:01:00Z', // Earliest + }, + { + messageId: 'child', + parentMessageId: 'parent', + text: 'Child', + createdAt: '2023-01-01T00:02:00Z', // Middle + }, + ]; + + const importBatchBuilder = createImportBatchBuilder('testUser'); + importBatchBuilder.startConversation(); + + cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); + + const clonedMessages = importBatchBuilder.messages; + expect(clonedMessages.length).toBe(3); + + // Verify the chain of timestamps + const root = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT); + const parent = clonedMessages.find((msg) => msg.parentMessageId === root.messageId); + const child = clonedMessages.find((msg) => msg.parentMessageId === parent.messageId); + + expect(new Date(parent.createdAt).getTime()).toBeGreaterThan( + new Date(root.createdAt).getTime(), + ); + expect(new Date(child.createdAt).getTime()).toBeGreaterThan( + new Date(parent.createdAt).getTime(), + ); + }); + + test('should handle messages with identical timestamps', () => { + const sameTimestamp = '2023-01-01T00:00:00Z'; + const messagesToClone = [ + { + messageId: 'parent', + parentMessageId: Constants.NO_PARENT, + text: 'Parent', + createdAt: sameTimestamp, + }, + { + messageId: 'child', + parentMessageId: 'parent', + text: 'Child', + createdAt: sameTimestamp, + }, + ]; + + const importBatchBuilder = createImportBatchBuilder('testUser'); + importBatchBuilder.startConversation(); + + cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); + + const clonedMessages = importBatchBuilder.messages; + const parent = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT); + const child = clonedMessages.find((msg) => msg.parentMessageId === parent.messageId); + + expect(new Date(child.createdAt).getTime()).toBeGreaterThan( + new Date(parent.createdAt).getTime(), + ); + }); + + test('should preserve original timestamps when already properly ordered', () => { + const messagesToClone = [ + { + messageId: 'parent', + parentMessageId: Constants.NO_PARENT, + text: 'Parent', + createdAt: '2023-01-01T00:00:00Z', + }, + { + messageId: 'child', + parentMessageId: 'parent', + text: 'Child', + createdAt: '2023-01-01T00:01:00Z', + }, + ]; + + const importBatchBuilder = createImportBatchBuilder('testUser'); + importBatchBuilder.startConversation(); + + cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); + + const clonedMessages = importBatchBuilder.messages; + const parent = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT); + const child = clonedMessages.find((msg) => msg.parentMessageId === parent.messageId); + + expect(parent.createdAt).toEqual(new Date(messagesToClone[0].createdAt)); + expect(child.createdAt).toEqual(new Date(messagesToClone[1].createdAt)); + }); + + test('should handle complex multi-branch scenario with out-of-order timestamps', () => { + const complexMessages = [ + // Branch 1: Root -> A -> (B, C) -> D + { + messageId: 'root1', + parentMessageId: Constants.NO_PARENT, + text: 'Root 1', + createdAt: '2023-01-01T00:05:00Z', // Root is later than children + }, + { + messageId: 'A1', + parentMessageId: 'root1', + text: 'A1', + createdAt: '2023-01-01T00:02:00Z', + }, + { + messageId: 'B1', + parentMessageId: 'A1', + text: 'B1', + createdAt: '2023-01-01T00:01:00Z', // Earlier than parent + }, + { + messageId: 'C1', + parentMessageId: 'A1', + text: 'C1', + createdAt: '2023-01-01T00:03:00Z', + }, + { + messageId: 'D1', + parentMessageId: 'B1', + text: 'D1', + createdAt: '2023-01-01T00:04:00Z', + }, + + // Branch 2: Root -> (X, Y, Z) where Z has children but X is latest + { + messageId: 'root2', + parentMessageId: Constants.NO_PARENT, + text: 'Root 2', + createdAt: '2023-01-01T00:06:00Z', + }, + { + messageId: 'X2', + parentMessageId: 'root2', + text: 'X2', + createdAt: '2023-01-01T00:09:00Z', // Latest of siblings + }, + { + messageId: 'Y2', + parentMessageId: 'root2', + text: 'Y2', + createdAt: '2023-01-01T00:07:00Z', + }, + { + messageId: 'Z2', + parentMessageId: 'root2', + text: 'Z2', + createdAt: '2023-01-01T00:08:00Z', + }, + { + messageId: 'Z2Child', + parentMessageId: 'Z2', + text: 'Z2 Child', + createdAt: '2023-01-01T00:04:00Z', // Earlier than all parents + }, + + // Branch 3: Root with alternating early/late timestamps + { + messageId: 'root3', + parentMessageId: Constants.NO_PARENT, + text: 'Root 3', + createdAt: '2023-01-01T00:15:00Z', // Latest of all + }, + { + messageId: 'E3', + parentMessageId: 'root3', + text: 'E3', + createdAt: '2023-01-01T00:10:00Z', + }, + { + messageId: 'F3', + parentMessageId: 'E3', + text: 'F3', + createdAt: '2023-01-01T00:14:00Z', // Later than parent + }, + { + messageId: 'G3', + parentMessageId: 'F3', + text: 'G3', + createdAt: '2023-01-01T00:11:00Z', // Earlier than parent + }, + { + messageId: 'H3', + parentMessageId: 'G3', + text: 'H3', + createdAt: '2023-01-01T00:13:00Z', + }, + ]; + + const importBatchBuilder = createImportBatchBuilder('testUser'); + importBatchBuilder.startConversation(); + + cloneMessagesWithTimestamps(complexMessages, importBatchBuilder); + + const clonedMessages = importBatchBuilder.messages; + console.debug( + 'Complex multi-branch scenario\nOriginal messages:\n', + printMessageTree(complexMessages), + ); + console.debug('Cloned messages:\n', printMessageTree(clonedMessages)); + + // Helper function to verify timestamp order + const verifyTimestampOrder = (parentId, messages) => { + const parent = messages.find((msg) => msg.messageId === parentId); + const children = messages.filter((msg) => msg.parentMessageId === parentId); + + children.forEach((child) => { + const parentTime = new Date(parent.createdAt).getTime(); + const childTime = new Date(child.createdAt).getTime(); + expect(childTime).toBeGreaterThan(parentTime); + // Recursively verify child's children + verifyTimestampOrder(child.messageId, messages); + }); + }; + + // Verify each branch + const roots = clonedMessages.filter((msg) => msg.parentMessageId === Constants.NO_PARENT); + roots.forEach((root) => verifyTimestampOrder(root.messageId, clonedMessages)); + + // Additional specific checks + const getMessageByText = (text) => clonedMessages.find((msg) => msg.text === text); + + // Branch 1 checks + const root1 = getMessageByText('Root 1'); + const b1 = getMessageByText('B1'); + const d1 = getMessageByText('D1'); + expect(new Date(b1.createdAt).getTime()).toBeGreaterThan(new Date(root1.createdAt).getTime()); + expect(new Date(d1.createdAt).getTime()).toBeGreaterThan(new Date(b1.createdAt).getTime()); + + // Branch 2 checks + const root2 = getMessageByText('Root 2'); + const x2 = getMessageByText('X2'); + const z2Child = getMessageByText('Z2 Child'); + const z2 = getMessageByText('Z2'); + expect(new Date(x2.createdAt).getTime()).toBeGreaterThan(new Date(root2.createdAt).getTime()); + expect(new Date(z2Child.createdAt).getTime()).toBeGreaterThan(new Date(z2.createdAt).getTime()); + + // Branch 3 checks + const f3 = getMessageByText('F3'); + const g3 = getMessageByText('G3'); + expect(new Date(g3.createdAt).getTime()).toBeGreaterThan(new Date(f3.createdAt).getTime()); + + // Verify all messages are present + expect(clonedMessages.length).toBe(complexMessages.length); + }); +}); 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 ( -
+
setTitleInput(e.target.value)} onKeyDown={handleKeyDown} @@ -199,7 +200,17 @@ export default function Conversation({ size={20} context="menu-item" /> -
{title}
+
{ + e.preventDefault(); + e.stopPropagation(); + setTitleInput(title); + setRenaming(true); + }} + > + {title} +
{isActiveConvo ? (
) : ( @@ -215,16 +226,17 @@ export default function Conversation({ : 'hidden group-focus-within:flex group-hover:flex', )} > - + {!renaming && ( + + )}
); diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index e4f39c09ec8..7a66d135c4c 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -1,9 +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 { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react'; import { useGetStartupConfig } from 'librechat-data-provider/react-query'; import type { MouseEvent } from 'react'; -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'; @@ -12,7 +14,6 @@ import { cn } from '~/utils'; export default function ConvoOptions({ conversationId, title, - renaming, retainView, renameHandler, isPopoverActive, @@ -21,7 +22,6 @@ export default function ConvoOptions({ }: { conversationId: string | null; title: string | null; - renaming: boolean; retainView: () => void; renameHandler: (e: MouseEvent) => void; isPopoverActive: boolean; @@ -29,10 +29,37 @@ export default function ConvoOptions({ isActiveConvo: boolean; }) { const localize = useLocalize(); + const { index } = useChatContext(); const { data: startupConfig } = useGetStartupConfig(); + 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 != null) { + 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); @@ -44,27 +71,39 @@ export default function ConvoOptions({ setShowDeleteDialog(true); }; + const duplicateHandler = () => { + setIsPopoverActive(false); + duplicateConversation.mutate({ + conversationId: conversationId ?? '', + }); + }; + const dropdownItems = [ + { + label: localize('com_ui_share'), + onClick: shareHandler, + icon: , + show: startupConfig && startupConfig.sharedLinksEnabled, + }, { label: localize('com_ui_rename'), onClick: renameHandler, - icon: , + icon: , }, { - label: localize('com_ui_share'), - onClick: shareHandler, - icon: , - show: startupConfig && startupConfig.sharedLinksEnabled, + label: localize('com_ui_duplicate'), + onClick: duplicateHandler, + icon: , }, { label: localize('com_ui_archive'), onClick: archiveHandler, - icon: , + icon: , }, { label: localize('com_ui_delete'), onClick: deleteHandler, - icon: , + icon: , }, ]; @@ -76,7 +115,7 @@ export default function ConvoOptions({ isOpen={isPopoverActive} setIsOpen={setIsPopoverActive} trigger={ - - + } items={dropdownItems} menuId={menuId} diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index 50a143a53f6..fbed2ae1288 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -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({ @@ -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 && ( <> diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index 9f5ac601c79..a2eba54276a 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -177,11 +177,14 @@ const Nav = ({ )} {hasAccessToBookmarks === true && ( - + <> +
+ + )} } diff --git a/client/src/components/Nav/SearchBar.tsx b/client/src/components/Nav/SearchBar.tsx index 1eef13722c9..677036ab74e 100644 --- a/client/src/components/Nav/SearchBar.tsx +++ b/client/src/components/Nav/SearchBar.tsx @@ -73,7 +73,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) = } { diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx index aa48f1d2f99..ce2c795d7e5 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx @@ -88,7 +88,7 @@ function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) { diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 9bd707f1979..9921ecbfe90 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -21,7 +21,7 @@ export default function SharedLinks() { title={localize('com_nav_shared_links')} className="max-w-[1000px]" showCancelButton={false} - main={} + main={} />
diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index 838d94b2813..d1a5bcafcae 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -166,8 +166,7 @@ const AdminSettings = () => { } items={roleDropdownItems} - className="border border-border-light bg-surface-primary" - itemClassName="hover:bg-surface-tertiary items-center justify-center" + itemClassName="items-center justify-center" sameWidth={true} />
diff --git a/client/src/components/SidePanel/Agents/AdminSettings.tsx b/client/src/components/SidePanel/Agents/AdminSettings.tsx index d1fd697d002..2b38a72e1d5 100644 --- a/client/src/components/SidePanel/Agents/AdminSettings.tsx +++ b/client/src/components/SidePanel/Agents/AdminSettings.tsx @@ -166,8 +166,7 @@ const AdminSettings = () => { } items={roleDropdownItems} - className="border border-border-light bg-surface-primary" - itemClassName="hover:bg-surface-tertiary items-center justify-center" + itemClassName="items-center justify-center" sameWidth={true} /> diff --git a/client/src/components/ui/Dropdown.tsx b/client/src/components/ui/Dropdown.tsx index 73aef17afca..785cef36de6 100644 --- a/client/src/components/ui/Dropdown.tsx +++ b/client/src/components/ui/Dropdown.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import * as Select from '@ariakit/react/select'; -import { cn } from '~/utils/'; import type { Option } from '~/common'; +import { cn } from '~/utils/'; interface DropdownProps { value: string; diff --git a/client/src/components/ui/DropdownPopup.tsx b/client/src/components/ui/DropdownPopup.tsx index 244ae3fce14..db1f8e406e4 100644 --- a/client/src/components/ui/DropdownPopup.tsx +++ b/client/src/components/ui/DropdownPopup.tsx @@ -45,10 +45,7 @@ const DropdownPopup: React.FC = ({ {trigger} = ({ = ({ }} > {item.icon != null && ( -