feat: Implement Conversation Duplication & UI Improvements (#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 <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Danny Avila 2024-12-18 11:10:34 -05:00 committed by GitHub
parent 649c7a6032
commit e8bde332c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 717 additions and 85 deletions

View file

@ -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.
@ -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,
};