mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00:15 +01:00
✨ 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:
parent
649c7a6032
commit
e8bde332c2
24 changed files with 717 additions and 85 deletions
|
|
@ -220,4 +220,94 @@ describe('Conversation Structure Tests', () => {
|
||||||
}
|
}
|
||||||
expect(currentNode.children.length).toBe(0); // Last message should have no children
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ const multer = require('multer');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
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 { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||||
const { forkConversation } = require('~/server/utils/import/fork');
|
|
||||||
const { importConversations } = require('~/server/utils/import');
|
const { importConversations } = require('~/server/utils/import');
|
||||||
const { createImportLimiters } = require('~/server/middleware');
|
const { createImportLimiters } = require('~/server/middleware');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
|
|
@ -182,9 +182,25 @@ router.post('/fork', async (req, res) => {
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error forking conversation', error);
|
logger.error('Error forking conversation:', error);
|
||||||
res.status(500).send('Error forking conversation');
|
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,69 @@ const { getConvo } = require('~/models/Conversation');
|
||||||
const { getMessages } = require('~/models/Message');
|
const { getMessages } = require('~/models/Message');
|
||||||
const logger = require('~/config/winston');
|
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.
|
* @param {object} params - The parameters for the importer.
|
||||||
|
|
@ -65,23 +128,7 @@ async function forkConversation({
|
||||||
messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId);
|
messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idMapping = new Map();
|
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
|
||||||
|
|
||||||
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(
|
const result = importBatchBuilder.finishConversation(
|
||||||
newTitle || originalConvo.title,
|
newTitle || originalConvo.title,
|
||||||
|
|
@ -306,9 +353,63 @@ function splitAtTargetLevel(messages, targetMessageId) {
|
||||||
return filteredMessages;
|
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 = {
|
module.exports = {
|
||||||
forkConversation,
|
forkConversation,
|
||||||
splitAtTargetLevel,
|
splitAtTargetLevel,
|
||||||
|
duplicateConversation,
|
||||||
getAllMessagesUpToParent,
|
getAllMessagesUpToParent,
|
||||||
getMessagesUpToTargetLevel,
|
getMessagesUpToTargetLevel,
|
||||||
|
cloneMessagesWithTimestamps,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ const {
|
||||||
splitAtTargetLevel,
|
splitAtTargetLevel,
|
||||||
getAllMessagesUpToParent,
|
getAllMessagesUpToParent,
|
||||||
getMessagesUpToTargetLevel,
|
getMessagesUpToTargetLevel,
|
||||||
|
cloneMessagesWithTimestamps,
|
||||||
} = require('./fork');
|
} = require('./fork');
|
||||||
const { getConvo, bulkSaveConvos } = require('~/models/Conversation');
|
const { getConvo, bulkSaveConvos } = require('~/models/Conversation');
|
||||||
const { getMessages, bulkSaveMessages } = require('~/models/Message');
|
const { getMessages, bulkSaveMessages } = require('~/models/Message');
|
||||||
|
const { createImportBatchBuilder } = require('./importBatchBuilder');
|
||||||
const BaseClient = require('~/app/clients/BaseClient');
|
const BaseClient = require('~/app/clients/BaseClient');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -104,7 +106,8 @@ describe('forkConversation', () => {
|
||||||
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
||||||
), true,
|
),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -122,7 +125,8 @@ describe('forkConversation', () => {
|
||||||
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
||||||
), true,
|
),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -141,7 +145,8 @@ describe('forkConversation', () => {
|
||||||
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
||||||
), true,
|
),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,7 +165,8 @@ describe('forkConversation', () => {
|
||||||
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
expect(bulkSaveMessages).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
|
||||||
), true,
|
),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -572,3 +578,308 @@ describe('splitAtTargetLevel', () => {
|
||||||
expect(result.length).toBe(0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ const AttachFile = ({ isRTL, disabled, setToolResource, handleFileChange }: Atta
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
|
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
|
||||||
<div className="relative">
|
<div className="relative select-none">
|
||||||
<DropdownPopup
|
<DropdownPopup
|
||||||
menuId="attach-file-menu"
|
menuId="attach-file-menu"
|
||||||
isOpen={isPopoverActive}
|
isOpen={isPopoverActive}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export default function Conversation({
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (currentConvoId === conversationId || isPopoverActive) {
|
if (currentConvoId === conversationId || isPopoverActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +156,7 @@ export default function Conversation({
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
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 ?? ''}
|
value={titleInput ?? ''}
|
||||||
onChange={(e) => setTitleInput(e.target.value)}
|
onChange={(e) => setTitleInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|
@ -199,7 +200,17 @@ export default function Conversation({
|
||||||
size={20}
|
size={20}
|
||||||
context="menu-item"
|
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 ? (
|
{isActiveConvo ? (
|
||||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
|
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -215,16 +226,17 @@ export default function Conversation({
|
||||||
: 'hidden group-focus-within:flex group-hover:flex',
|
: 'hidden group-focus-within:flex group-hover:flex',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ConvoOptions
|
{!renaming && (
|
||||||
title={title}
|
<ConvoOptions
|
||||||
renaming={renaming}
|
title={title}
|
||||||
retainView={retainView}
|
retainView={retainView}
|
||||||
renameHandler={renameHandler}
|
renameHandler={renameHandler}
|
||||||
isActiveConvo={isActiveConvo}
|
isActiveConvo={isActiveConvo}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
isPopoverActive={isPopoverActive}
|
isPopoverActive={isPopoverActive}
|
||||||
setIsPopoverActive={setIsPopoverActive}
|
setIsPopoverActive={setIsPopoverActive}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useState, useId } from 'react';
|
import { useState, useId } from 'react';
|
||||||
import * as Ariakit from '@ariakit/react';
|
import * as Menu from '@ariakit/react/menu';
|
||||||
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
|
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
|
||||||
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||||
import type { MouseEvent } from 'react';
|
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 { DropdownPopup } from '~/components/ui';
|
||||||
import DeleteButton from './DeleteButton';
|
import DeleteButton from './DeleteButton';
|
||||||
import ShareButton from './ShareButton';
|
import ShareButton from './ShareButton';
|
||||||
|
|
@ -12,7 +14,6 @@ import { cn } from '~/utils';
|
||||||
export default function ConvoOptions({
|
export default function ConvoOptions({
|
||||||
conversationId,
|
conversationId,
|
||||||
title,
|
title,
|
||||||
renaming,
|
|
||||||
retainView,
|
retainView,
|
||||||
renameHandler,
|
renameHandler,
|
||||||
isPopoverActive,
|
isPopoverActive,
|
||||||
|
|
@ -21,7 +22,6 @@ export default function ConvoOptions({
|
||||||
}: {
|
}: {
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
renaming: boolean;
|
|
||||||
retainView: () => void;
|
retainView: () => void;
|
||||||
renameHandler: (e: MouseEvent) => void;
|
renameHandler: (e: MouseEvent) => void;
|
||||||
isPopoverActive: boolean;
|
isPopoverActive: boolean;
|
||||||
|
|
@ -29,10 +29,37 @@ export default function ConvoOptions({
|
||||||
isActiveConvo: boolean;
|
isActiveConvo: boolean;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const { index } = useChatContext();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
|
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
|
||||||
|
const { navigateToConvo } = useNavigateToConvo(index);
|
||||||
|
const { showToast } = useToastContext();
|
||||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = 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 = () => {
|
const shareHandler = () => {
|
||||||
setIsPopoverActive(false);
|
setIsPopoverActive(false);
|
||||||
|
|
@ -44,27 +71,39 @@ export default function ConvoOptions({
|
||||||
setShowDeleteDialog(true);
|
setShowDeleteDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const duplicateHandler = () => {
|
||||||
|
setIsPopoverActive(false);
|
||||||
|
duplicateConversation.mutate({
|
||||||
|
conversationId: conversationId ?? '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
{
|
|
||||||
label: localize('com_ui_rename'),
|
|
||||||
onClick: renameHandler,
|
|
||||||
icon: <Pen className="icon-md mr-2 text-text-secondary" />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: localize('com_ui_share'),
|
label: localize('com_ui_share'),
|
||||||
onClick: shareHandler,
|
onClick: shareHandler,
|
||||||
icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
|
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
|
||||||
show: startupConfig && startupConfig.sharedLinksEnabled,
|
show: startupConfig && startupConfig.sharedLinksEnabled,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: localize('com_ui_rename'),
|
||||||
|
onClick: renameHandler,
|
||||||
|
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: localize('com_ui_duplicate'),
|
||||||
|
onClick: duplicateHandler,
|
||||||
|
icon: <Copy className="icon-sm mr-2 text-text-primary" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: localize('com_ui_archive'),
|
label: localize('com_ui_archive'),
|
||||||
onClick: archiveHandler,
|
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'),
|
label: localize('com_ui_delete'),
|
||||||
onClick: deleteHandler,
|
onClick: deleteHandler,
|
||||||
icon: <Trash className="icon-md mr-2 text-text-secondary" />,
|
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -76,7 +115,7 @@ export default function ConvoOptions({
|
||||||
isOpen={isPopoverActive}
|
isOpen={isPopoverActive}
|
||||||
setIsOpen={setIsPopoverActive}
|
setIsOpen={setIsPopoverActive}
|
||||||
trigger={
|
trigger={
|
||||||
<Ariakit.MenuButton
|
<Menu.MenuButton
|
||||||
id="conversation-menu-button"
|
id="conversation-menu-button"
|
||||||
aria-label={localize('com_nav_convo_menu_options')}
|
aria-label={localize('com_nav_convo_menu_options')}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -84,11 +123,10 @@ export default function ConvoOptions({
|
||||||
isActiveConvo === true
|
isActiveConvo === true
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
||||||
renaming === true ? 'pointer-events-none opacity-0' : '',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
||||||
</Ariakit.MenuButton>
|
</Menu.MenuButton>
|
||||||
}
|
}
|
||||||
items={dropdownItems}
|
items={dropdownItems}
|
||||||
menuId={menuId}
|
menuId={menuId}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useUpdateSharedLinkMutation } from '~/data-provider';
|
||||||
import { NotificationSeverity } from '~/common';
|
import { NotificationSeverity } from '~/common';
|
||||||
import { useToastContext } from '~/Providers';
|
import { useToastContext } from '~/Providers';
|
||||||
import { Spinner } from '~/components/svg';
|
import { Spinner } from '~/components/svg';
|
||||||
import { Button } from '~/components/ui';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export default function SharedLinkButton({
|
export default function SharedLinkButton({
|
||||||
|
|
@ -112,7 +111,7 @@ export default function SharedLinkButton({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handlers.handler();
|
handlers.handler();
|
||||||
}}
|
}}
|
||||||
className="btn btn-primary flex items-center"
|
className="btn btn-primary flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{isCopying && (
|
{isCopying && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -177,11 +177,14 @@ const Nav = ({
|
||||||
<SearchBar clearSearch={clearSearch} isSmallScreen={isSmallScreen} />
|
<SearchBar clearSearch={clearSearch} isSmallScreen={isSmallScreen} />
|
||||||
)}
|
)}
|
||||||
{hasAccessToBookmarks === true && (
|
{hasAccessToBookmarks === true && (
|
||||||
<BookmarkNav
|
<>
|
||||||
tags={tags}
|
<div className="mt-1.5" />
|
||||||
setTags={setTags}
|
<BookmarkNav
|
||||||
isSmallScreen={isSmallScreen}
|
tags={tags}
|
||||||
/>
|
setTags={setTags}
|
||||||
|
isSmallScreen={isSmallScreen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
||||||
}
|
}
|
||||||
<input
|
<input
|
||||||
type="text"
|
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}
|
value={text}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
title={localize('com_ui_delete_conversation')}
|
title={localize('com_ui_delete_shared_link')}
|
||||||
className="max-w-[450px]"
|
className="max-w-[450px]"
|
||||||
main={
|
main={
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default function SharedLinks() {
|
||||||
title={localize('com_nav_shared_links')}
|
title={localize('com_nav_shared_links')}
|
||||||
className="max-w-[1000px]"
|
className="max-w-[1000px]"
|
||||||
showCancelButton={false}
|
showCancelButton={false}
|
||||||
main={<ShareLinkTable />}
|
main={<ShareLinkTable className="w-full" />}
|
||||||
/>
|
/>
|
||||||
</OGDialog>
|
</OGDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,7 @@ const AdminSettings = () => {
|
||||||
</Ariakit.MenuButton>
|
</Ariakit.MenuButton>
|
||||||
}
|
}
|
||||||
items={roleDropdownItems}
|
items={roleDropdownItems}
|
||||||
className="border border-border-light bg-surface-primary"
|
itemClassName="items-center justify-center"
|
||||||
itemClassName="hover:bg-surface-tertiary items-center justify-center"
|
|
||||||
sameWidth={true}
|
sameWidth={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,7 @@ const AdminSettings = () => {
|
||||||
</Ariakit.MenuButton>
|
</Ariakit.MenuButton>
|
||||||
}
|
}
|
||||||
items={roleDropdownItems}
|
items={roleDropdownItems}
|
||||||
className="border border-border-light bg-surface-primary"
|
itemClassName="items-center justify-center"
|
||||||
itemClassName="hover:bg-surface-tertiary items-center justify-center"
|
|
||||||
sameWidth={true}
|
sameWidth={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import * as Select from '@ariakit/react/select';
|
import * as Select from '@ariakit/react/select';
|
||||||
import { cn } from '~/utils/';
|
|
||||||
import type { Option } from '~/common';
|
import type { Option } from '~/common';
|
||||||
|
import { cn } from '~/utils/';
|
||||||
|
|
||||||
interface DropdownProps {
|
interface DropdownProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
||||||
{trigger}
|
{trigger}
|
||||||
<Ariakit.Menu
|
<Ariakit.Menu
|
||||||
id={menuId}
|
id={menuId}
|
||||||
className={cn(
|
className={cn('popover-ui z-50', className)}
|
||||||
'absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
gutter={gutter}
|
gutter={gutter}
|
||||||
modal={modal}
|
modal={modal}
|
||||||
sameWidth={sameWidth}
|
sameWidth={sameWidth}
|
||||||
|
|
@ -62,7 +59,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
||||||
<Ariakit.MenuItem
|
<Ariakit.MenuItem
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover',
|
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||||
itemClassName,
|
itemClassName,
|
||||||
)}
|
)}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
|
@ -75,7 +72,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon != null && (
|
{item.icon != null && (
|
||||||
<span className={cn('mr-2 h-5 w-5', iconClassName)} aria-hidden="true">
|
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -59,29 +59,33 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
|
||||||
overlayClassName={overlayClassName}
|
overlayClassName={overlayClassName}
|
||||||
showCloseButton={showCloseButton}
|
showCloseButton={showCloseButton}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('border-none bg-background text-foreground', className ?? '')}
|
className={cn('w-11/12 border-none bg-background text-foreground', className ?? '')}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<OGDialogHeader className={cn(headerClassName ?? '')}>
|
<OGDialogHeader className={cn(headerClassName ?? '')}>
|
||||||
<OGDialogTitle>{title}</OGDialogTitle>
|
<OGDialogTitle>{title}</OGDialogTitle>
|
||||||
{description && <OGDialogDescription className="">{description}</OGDialogDescription>}
|
{description && (
|
||||||
|
<OGDialogDescription className="items-center justify-center">
|
||||||
|
{description}
|
||||||
|
</OGDialogDescription>
|
||||||
|
)}
|
||||||
</OGDialogHeader>
|
</OGDialogHeader>
|
||||||
<div className={cn('px-0', mainClassName)}>{main != null ? main : null}</div>
|
<div className={cn('px-0 py-2', mainClassName)}>{main != null ? main : null}</div>
|
||||||
<OGDialogFooter className={footerClassName}>
|
<OGDialogFooter className={footerClassName}>
|
||||||
<div>{leftButtons != null ? leftButtons : null}</div>
|
<div>{leftButtons != null ? <div className="mt-3 sm:mt-0">{leftButtons}</div> : null}</div>
|
||||||
<div className="flex h-auto gap-3">
|
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
|
||||||
|
{buttons != null ? buttons : null}
|
||||||
{showCancelButton && (
|
{showCancelButton && (
|
||||||
<OGDialogClose className="btn btn-neutral border-token-border-light relative rounded-lg text-sm ring-offset-2 focus:ring-2 focus:ring-black dark:ring-offset-0">
|
<OGDialogClose className="btn btn-neutral border-token-border-light relative justify-center rounded-lg text-sm ring-offset-2 focus:ring-2 focus:ring-black dark:ring-offset-0 max-sm:order-last max-sm:w-full sm:order-first">
|
||||||
{Cancel}
|
{Cancel}
|
||||||
</OGDialogClose>
|
</OGDialogClose>
|
||||||
)}
|
)}
|
||||||
{buttons != null ? buttons : null}
|
|
||||||
{selection ? (
|
{selection ? (
|
||||||
<OGDialogClose
|
<OGDialogClose
|
||||||
onClick={selectHandler}
|
onClick={selectHandler}
|
||||||
className={`${
|
className={`${
|
||||||
selectClasses ?? defaultSelect
|
selectClasses ?? defaultSelect
|
||||||
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm`}
|
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm max-sm:order-first max-sm:w-full sm:order-none`}
|
||||||
>
|
>
|
||||||
{selectText}
|
{selectText}
|
||||||
</OGDialogClose>
|
</OGDialogClose>
|
||||||
|
|
|
||||||
|
|
@ -573,6 +573,43 @@ export const useDeleteConversationMutation = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDuplicateConversationMutation = (
|
||||||
|
options?: t.DuplicateConvoOptions,
|
||||||
|
): 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) => {
|
||||||
|
const originalId = vars.conversationId ?? '';
|
||||||
|
if (originalId.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data == null) {
|
||||||
|
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 = (
|
export const useForkConvoMutation = (
|
||||||
options?: t.ForkConvoOptions,
|
options?: t.ForkConvoOptions,
|
||||||
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
|
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,9 @@ export default {
|
||||||
com_ui_mention: 'Mention an endpoint, assistant, or preset to quickly switch to it',
|
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_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_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_regenerate: 'Regenerate',
|
||||||
com_ui_continue: 'Continue',
|
com_ui_continue: 'Continue',
|
||||||
com_ui_edit: 'Edit',
|
com_ui_edit: 'Edit',
|
||||||
|
|
@ -392,6 +395,7 @@ export default {
|
||||||
'Are you sure you want to delete this Assistant? This cannot be undone.',
|
'Are you sure you want to delete this Assistant? This cannot be undone.',
|
||||||
com_ui_rename: 'Rename',
|
com_ui_rename: 'Rename',
|
||||||
com_ui_archive: 'Archive',
|
com_ui_archive: 'Archive',
|
||||||
|
com_ui_duplicate: 'Duplicate',
|
||||||
com_ui_archive_error: 'Failed to archive conversation',
|
com_ui_archive_error: 'Failed to archive conversation',
|
||||||
com_ui_unarchive: 'Unarchive',
|
com_ui_unarchive: 'Unarchive',
|
||||||
com_ui_unarchive_error: 'Failed to unarchive conversation',
|
com_ui_unarchive_error: 'Failed to unarchive conversation',
|
||||||
|
|
@ -430,7 +434,6 @@ export default {
|
||||||
com_ui_no_bookmarks: 'it seems like you have no bookmarks yet. Click on a chat and add a new one',
|
com_ui_no_bookmarks: 'it seems like you have no bookmarks yet. Click on a chat and add a new one',
|
||||||
com_ui_no_conversation_id: 'No conversation ID found',
|
com_ui_no_conversation_id: 'No conversation ID found',
|
||||||
com_ui_add_multi_conversation: 'Add multi-conversation',
|
com_ui_add_multi_conversation: 'Add multi-conversation',
|
||||||
com_ui_duplicate: 'Duplicate',
|
|
||||||
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
|
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
|
||||||
com_auth_error_login:
|
com_auth_error_login:
|
||||||
'Unable to login with the information provided. Please check your credentials and try again.',
|
'Unable to login with the information provided. Please check your credentials and try again.',
|
||||||
|
|
@ -743,6 +746,7 @@ export default {
|
||||||
com_nav_export_recursive: 'Recursive',
|
com_nav_export_recursive: 'Recursive',
|
||||||
com_nav_export_conversation: 'Export conversation',
|
com_nav_export_conversation: 'Export conversation',
|
||||||
com_nav_export: 'Export',
|
com_nav_export: 'Export',
|
||||||
|
com_ui_delete_shared_link: 'Delete shared link?',
|
||||||
com_nav_shared_links: 'Shared links',
|
com_nav_shared_links: 'Shared links',
|
||||||
com_nav_shared_links_manage: 'Manage',
|
com_nav_shared_links_manage: 'Manage',
|
||||||
com_nav_shared_links_empty: 'You have no shared links.',
|
com_nav_shared_links_empty: 'You have no shared links.',
|
||||||
|
|
|
||||||
|
|
@ -2371,7 +2371,7 @@ button.scroll-convo {
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-ui:where(.dark, .dark *) {
|
.popover-ui:where(.dark, .dark *) {
|
||||||
background-color: hsl(var(--background));
|
background-color: hsl(var(--secondary));
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.25), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.25), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
@ -2392,7 +2392,7 @@ button.scroll-convo {
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-item[data-active-item] {
|
.select-item[data-active-item] {
|
||||||
background-color: hsl(var(--accent));
|
background-color: var(--surface-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ export const importConversation = () => `${conversationsRoot}/import`;
|
||||||
|
|
||||||
export const forkConversation = () => `${conversationsRoot}/fork`;
|
export const forkConversation = () => `${conversationsRoot}/fork`;
|
||||||
|
|
||||||
|
export const duplicateConversation = () => `${conversationsRoot}/duplicate`;
|
||||||
|
|
||||||
export const search = (q: string, pageNumber: string) =>
|
export const search = (q: string, pageNumber: string) =>
|
||||||
`/api/search?q=${q}&pageNumber=${pageNumber}`;
|
`/api/search?q=${q}&pageNumber=${pageNumber}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,12 @@ export const getCustomConfigSpeech = (): Promise<t.TCustomConfigSpeechResponse>
|
||||||
|
|
||||||
/* conversations */
|
/* 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> {
|
export function forkConversation(payload: t.TForkConvoRequest): Promise<t.TForkConvoResponse> {
|
||||||
return request.post(endpoints.forkConversation(), payload);
|
return request.post(endpoints.forkConversation(), payload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,15 @@ export type TTagConversationRequest = {
|
||||||
};
|
};
|
||||||
export type TTagConversationResponse = string[];
|
export type TTagConversationResponse = string[];
|
||||||
|
|
||||||
|
export type TDuplicateConvoRequest = {
|
||||||
|
conversationId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDuplicateConvoResponse = {
|
||||||
|
conversation: TConversation;
|
||||||
|
messages: TMessage[];
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
export type TForkConvoRequest = {
|
export type TForkConvoRequest = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,11 @@ export type DeleteConversationOptions = MutationOptions<
|
||||||
types.TDeleteConversationRequest
|
types.TDeleteConversationRequest
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type DuplicateConvoOptions = MutationOptions<
|
||||||
|
types.TDuplicateConvoResponse,
|
||||||
|
types.TDuplicateConvoRequest
|
||||||
|
>;
|
||||||
|
|
||||||
export type ForkConvoOptions = MutationOptions<types.TForkConvoResponse, types.TForkConvoRequest>;
|
export type ForkConvoOptions = MutationOptions<types.TForkConvoResponse, types.TForkConvoRequest>;
|
||||||
|
|
||||||
export type CreateSharedLinkOptions = MutationOptions<
|
export type CreateSharedLinkOptions = MutationOptions<
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue