Skip to content

Commit

Permalink
✨ feat: Implement Conversation Duplication & UI Improvements (danny-a…
Browse files Browse the repository at this point in the history
…vila#5036)

* 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 <[email protected]>
  • Loading branch information
2 people authored and olivierhub committed Dec 20, 2024
1 parent 5838cd4 commit 6b21251
Show file tree
Hide file tree
Showing 24 changed files with 716 additions and 84 deletions.
90 changes: 90 additions & 0 deletions api/models/convoStructure.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
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;
135 changes: 118 additions & 17 deletions api/server/utils/import/fork.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>} 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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Loading

0 comments on commit 6b21251

Please sign in to comment.