🌿 feat: Fork Messages/Conversations (#2617)

* typedef for ImportBatchBuilder

* feat: first pass, fork conversations

* feat: fork - getMessagesUpToTargetLevel

* fix: additional tests and fix getAllMessagesUpToParent

* chore: arrow function return

* refactor: fork 3 options

* chore: remove unused genbuttons

* chore: remove unused hover buttons code

* feat: fork first pass

* wip: fork remember setting

* style: user icon

* chore: move clear chats to data tab

* WIP: fork UI options

* feat: data-provider fork types/services/vars and use generic MutationOptions

* refactor: use single param for fork option, use enum, fix mongo errors, use Date.now(), add records flag for testing, use endpoint from original convo and messages, pass originalConvo to finishConversation

* feat: add fork mutation hook and consolidate type imports

* refactor: use enum

* feat: first pass, fork mutation

* chore: add enum for target level fork option

* chore: add enum for target level fork option

* show toast when checking remember selection

* feat: splitAtTarget

* feat: split at target option

* feat: navigate to new fork, show toasts, set result query data

* feat: hover info for all fork options

* refactor: add Messages settings tab

* fix(Fork): remember text info

* ci: test for single message and is target edge case

* feat: additional tests for getAllMessagesUpToParent

* ci: additional tests and cycle detection for getMessagesUpToTargetLevel

* feat: circular dependency checks for getAllMessagesUpToParent

* fix: getMessagesUpToTargetLevel circular dep. check

* ci: more tests for getMessagesForConversation

* style: hover text for checkbox fork items

* refactor: add statefulness to conversation import
This commit is contained in:
Danny Avila 2024-05-05 11:48:20 -04:00 committed by GitHub
parent c8baceac76
commit 25fceb78b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1831 additions and 523 deletions

View file

@ -569,11 +569,11 @@ class BaseClient {
* the message is considered a root message. * the message is considered a root message.
* *
* @param {Object} options - The options for the function. * @param {Object} options - The options for the function.
* @param {Array} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property. * @param {TMessage[]} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property.
* @param {string} options.parentMessageId - The ID of the parent message to start the traversal from. * @param {string} options.parentMessageId - The ID of the parent message to start the traversal from.
* @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. If provided, it will be applied to each message in the resulting array. * @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. If provided, it will be applied to each message in the resulting array.
* @param {boolean} [options.summary=false] - If set to true, the traversal modifies messages with 'summary' and 'summaryTokenCount' properties and stops at the message with a 'summary' property. * @param {boolean} [options.summary=false] - If set to true, the traversal modifies messages with 'summary' and 'summaryTokenCount' properties and stops at the message with a 'summary' property.
* @returns {Array} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'. * @returns {TMessage[]} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'.
*/ */
static getMessagesForConversation({ static getMessagesForConversation({
messages, messages,

View file

@ -2,6 +2,12 @@ const Conversation = require('./schema/convoSchema');
const { getMessages, deleteMessages } = require('./Message'); const { getMessages, deleteMessages } = require('./Message');
const logger = require('~/config/winston'); const logger = require('~/config/winston');
/**
* Retrieves a single conversation for a given user and conversation ID.
* @param {string} user - The user's ID.
* @param {string} conversationId - The conversation's ID.
* @returns {Promise<TConversation>} The conversation object.
*/
const getConvo = async (user, conversationId) => { const getConvo = async (user, conversationId) => {
try { try {
return await Conversation.findOne({ user, conversationId }).lean(); return await Conversation.findOne({ user, conversationId }).lean();

View file

@ -6,6 +6,7 @@ const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models
const { IMPORT_CONVERSATION_JOB_NAME } = require('~/server/utils/import/jobDefinition'); const { IMPORT_CONVERSATION_JOB_NAME } = require('~/server/utils/import/jobDefinition');
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 { createImportLimiters } = require('~/server/middleware'); const { createImportLimiters } = require('~/server/middleware');
const jobScheduler = require('~/server/utils/jobScheduler'); const jobScheduler = require('~/server/utils/jobScheduler');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
@ -131,6 +132,35 @@ router.post(
}, },
); );
/**
* POST /fork
* This route handles forking a conversation based on the TForkConvoRequest and responds with TForkConvoResponse.
* @route POST /fork
* @param {express.Request<{}, TForkConvoResponse, TForkConvoRequest>} req - Express request object.
* @param {express.Response<TForkConvoResponse>} res - Express response object.
* @returns {Promise<void>} - The response after forking the conversation.
*/
router.post('/fork', async (req, res) => {
try {
/** @type {TForkConvoRequest} */
const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;
const result = await forkConversation({
requestUserId: req.user.id,
originalConvoId: conversationId,
targetMessageId: messageId,
latestMessageId,
records: true,
splitAtTarget,
option,
});
res.json(result);
} catch (error) {
logger.error('Error forking conversation', error);
res.status(500).send('Error forking conversation');
}
});
// Get the status of an import job for polling // Get the status of an import job for polling
router.get('/import/jobs/:jobId', async (req, res) => { router.get('/import/jobs/:jobId', async (req, res) => {
try { try {

View file

@ -0,0 +1,314 @@
const { v4: uuidv4 } = require('uuid');
const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const BaseClient = require('~/app/clients/BaseClient');
const { getConvo } = require('~/models/Conversation');
const { getMessages } = require('~/models/Message');
const logger = require('~/config/winston');
/**
*
* @param {object} params - The parameters for the importer.
* @param {string} params.originalConvoId - The ID of the conversation to fork.
* @param {string} params.targetMessageId - The ID of the message to fork from.
* @param {string} params.requestUserId - The ID of the user making the request.
* @param {string} [params.newTitle] - Optional new title for the forked conversation uses old title if not provided
* @param {string} [params.option=''] - Optional flag for fork option
* @param {boolean} [params.records=false] - Optional flag for returning actual database records or resulting conversation and messages.
* @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level.
* @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true.
* @param {(userId: string) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @returns {Promise<TForkConvoResponse>} The response after forking the conversation.
*/
async function forkConversation({
originalConvoId,
targetMessageId: targetId,
requestUserId,
newTitle,
option = ForkOptions.TARGET_LEVEL,
records = false,
splitAtTarget = false,
latestMessageId,
builderFactory = createImportBatchBuilder,
}) {
try {
const originalConvo = await getConvo(requestUserId, originalConvoId);
let originalMessages = await getMessages({
user: requestUserId,
conversationId: originalConvoId,
});
let targetMessageId = targetId;
if (splitAtTarget && !latestMessageId) {
throw new Error('Latest `messageId` is required for forking from target message.');
} else if (splitAtTarget) {
originalMessages = splitAtTargetLevel(originalMessages, targetId);
targetMessageId = latestMessageId;
}
const importBatchBuilder = builderFactory(requestUserId);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
let messagesToClone = [];
if (option === ForkOptions.DIRECT_PATH) {
// Direct path only
messagesToClone = BaseClient.getMessagesForConversation({
messages: originalMessages,
parentMessageId: targetMessageId,
});
} else if (option === ForkOptions.INCLUDE_BRANCHES) {
// Direct path and siblings
messagesToClone = getAllMessagesUpToParent(originalMessages, targetMessageId);
} else if (option === ForkOptions.TARGET_LEVEL || !option) {
// Direct path, siblings, and all descendants
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);
}
const result = importBatchBuilder.finishConversation(
newTitle || originalConvo.title,
new Date(),
originalConvo,
);
await importBatchBuilder.saveBatch();
logger.debug(
`user: ${requestUserId} | New conversation "${
newTitle || originalConvo.title
}" forked from conversation ID ${originalConvoId}`,
);
if (!records) {
return result;
}
const conversation = await getConvo(requestUserId, result.conversation.conversationId);
const messages = await getMessages({
user: requestUserId,
conversationId: conversation.conversationId,
});
return {
conversation,
messages,
};
} catch (error) {
logger.error(
`user: ${requestUserId} | Error forking conversation from original ID ${originalConvoId}`,
error,
);
throw error;
}
}
/**
* Retrieves all messages up to the root from the target message.
* @param {TMessage[]} messages - The list of messages to search.
* @param {string} targetMessageId - The ID of the target message.
* @returns {TMessage[]} The list of messages up to the root from the target message.
*/
function getAllMessagesUpToParent(messages, targetMessageId) {
const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
if (!targetMessage) {
return [];
}
const pathToRoot = new Set();
const visited = new Set();
let current = targetMessage;
while (current) {
if (visited.has(current.messageId)) {
break;
}
visited.add(current.messageId);
pathToRoot.add(current.messageId);
const currentParentId = current.parentMessageId ?? Constants.NO_PARENT;
if (currentParentId === Constants.NO_PARENT) {
break;
}
current = messages.find((msg) => msg.messageId === currentParentId);
}
// Include all messages that are in the path or whose parent is in the path
// Exclude children of the target message
return messages.filter(
(msg) =>
(pathToRoot.has(msg.messageId) && msg.messageId !== targetMessageId) ||
(pathToRoot.has(msg.parentMessageId) && msg.parentMessageId !== targetMessageId) ||
msg.messageId === targetMessageId,
);
}
/**
* Retrieves all messages up to the root from the target message and its neighbors.
* @param {TMessage[]} messages - The list of messages to search.
* @param {string} targetMessageId - The ID of the target message.
* @returns {TMessage[]} The list of inclusive messages up to the root from the target message.
*/
function getMessagesUpToTargetLevel(messages, targetMessageId) {
if (messages.length === 1 && messages[0] && messages[0].messageId === targetMessageId) {
return messages;
}
// Create a map of parentMessageId to children messages
const parentToChildrenMap = new Map();
for (const message of messages) {
if (!parentToChildrenMap.has(message.parentMessageId)) {
parentToChildrenMap.set(message.parentMessageId, []);
}
parentToChildrenMap.get(message.parentMessageId).push(message);
}
// Retrieve the target message
const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
if (!targetMessage) {
logger.error('Target message not found.');
return [];
}
const visited = new Set();
const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage];
const results = new Set(currentLevel);
// Check if the target message is at the root level
if (
currentLevel.some((msg) => msg.messageId === targetMessageId) &&
targetMessage.parentMessageId === Constants.NO_PARENT
) {
return Array.from(results);
}
// Iterate level by level until the target is found
let targetFound = false;
while (!targetFound && currentLevel.length > 0) {
const nextLevel = [];
for (const node of currentLevel) {
if (visited.has(node.messageId)) {
logger.warn('Cycle detected in message tree');
continue;
}
visited.add(node.messageId);
const children = parentToChildrenMap.get(node.messageId) || [];
for (const child of children) {
if (visited.has(child.messageId)) {
logger.warn('Cycle detected in message tree');
continue;
}
nextLevel.push(child);
results.add(child);
if (child.messageId === targetMessageId) {
targetFound = true;
}
}
}
currentLevel = nextLevel;
}
return Array.from(results);
}
/**
* Splits the conversation at the targeted message level, including the target, its siblings, and all descendant messages.
* All target level messages have their parentMessageId set to the root.
* @param {TMessage[]} messages - The list of messages to analyze.
* @param {string} targetMessageId - The ID of the message to start the split from.
* @returns {TMessage[]} The list of messages at and below the target level.
*/
function splitAtTargetLevel(messages, targetMessageId) {
// Create a map of parentMessageId to children messages
const parentToChildrenMap = new Map();
for (const message of messages) {
if (!parentToChildrenMap.has(message.parentMessageId)) {
parentToChildrenMap.set(message.parentMessageId, []);
}
parentToChildrenMap.get(message.parentMessageId).push(message);
}
// Retrieve the target message
const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
if (!targetMessage) {
logger.error('Target message not found.');
return [];
}
// Initialize the search with root messages
const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
let currentLevel = [...rootMessages];
let currentLevelIndex = 0;
const levelMap = {};
// Map messages to their levels
rootMessages.forEach((msg) => {
levelMap[msg.messageId] = 0;
});
// Search for the target level
while (currentLevel.length > 0) {
const nextLevel = [];
for (const node of currentLevel) {
const children = parentToChildrenMap.get(node.messageId) || [];
for (const child of children) {
nextLevel.push(child);
levelMap[child.messageId] = currentLevelIndex + 1;
}
}
currentLevel = nextLevel;
currentLevelIndex++;
}
// Determine the target level
const targetLevel = levelMap[targetMessageId];
if (targetLevel === undefined) {
logger.error('Target level not found.');
return [];
}
// Filter messages at or below the target level
const filteredMessages = messages
.map((msg) => {
const messageLevel = levelMap[msg.messageId];
if (messageLevel < targetLevel) {
return null;
} else if (messageLevel === targetLevel) {
return {
...msg,
parentMessageId: Constants.NO_PARENT,
};
}
return msg;
})
.filter((msg) => msg !== null);
return filteredMessages;
}
module.exports = {
forkConversation,
splitAtTargetLevel,
getAllMessagesUpToParent,
getMessagesUpToTargetLevel,
};

View file

@ -0,0 +1,574 @@
const { Constants, ForkOptions } = require('librechat-data-provider');
jest.mock('~/models/Conversation', () => ({
getConvo: jest.fn(),
bulkSaveConvos: jest.fn(),
}));
jest.mock('~/models/Message', () => ({
getMessages: jest.fn(),
bulkSaveMessages: jest.fn(),
}));
let mockIdCounter = 0;
jest.mock('uuid', () => {
return {
v4: jest.fn(() => {
mockIdCounter++;
return mockIdCounter.toString();
}),
};
});
const {
forkConversation,
splitAtTargetLevel,
getAllMessagesUpToParent,
getMessagesUpToTargetLevel,
} = require('./fork');
const { getConvo, bulkSaveConvos } = require('~/models/Conversation');
const { getMessages, bulkSaveMessages } = require('~/models/Message');
const BaseClient = require('~/app/clients/BaseClient');
/**
*
* @param {TMessage[]} messages - The list of messages to visualize.
* @param {string | null} parentId - The parent message ID.
* @param {string} prefix - The prefix to use for each line.
* @returns
*/
function printMessageTree(messages, parentId = Constants.NO_PARENT, prefix = '') {
let treeVisual = '';
const childMessages = messages.filter((msg) => msg.parentMessageId === parentId);
for (let index = 0; index < childMessages.length; index++) {
const msg = childMessages[index];
const isLast = index === childMessages.length - 1;
const connector = isLast ? '└── ' : '├── ';
treeVisual += `${prefix}${connector}[${msg.messageId}]: ${
msg.parentMessageId !== Constants.NO_PARENT ? `Child of ${msg.parentMessageId}` : 'Root'
}\n`;
treeVisual += printMessageTree(messages, msg.messageId, prefix + (isLast ? ' ' : '| '));
}
return treeVisual;
}
const mockMessages = [
{
messageId: '0',
parentMessageId: Constants.NO_PARENT,
text: 'Root message 1',
createdAt: '2021-01-01',
},
{
messageId: '1',
parentMessageId: Constants.NO_PARENT,
text: 'Root message 2',
createdAt: '2021-01-01',
},
{ messageId: '2', parentMessageId: '1', text: 'Child of 1', createdAt: '2021-01-02' },
{ messageId: '3', parentMessageId: '1', text: 'Child of 1', createdAt: '2021-01-03' },
{ messageId: '4', parentMessageId: '2', text: 'Child of 2', createdAt: '2021-01-04' },
{ messageId: '5', parentMessageId: '2', text: 'Child of 2', createdAt: '2021-01-05' },
{ messageId: '6', parentMessageId: '3', text: 'Child of 3', createdAt: '2021-01-06' },
{ messageId: '7', parentMessageId: '3', text: 'Child of 3', createdAt: '2021-01-07' },
{ messageId: '8', parentMessageId: '7', text: 'Child of 7', createdAt: '2021-01-07' },
];
const mockConversation = { convoId: 'abc123', title: 'Original Title' };
describe('forkConversation', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIdCounter = 0;
getConvo.mockResolvedValue(mockConversation);
getMessages.mockResolvedValue(mockMessages);
bulkSaveConvos.mockResolvedValue(null);
bulkSaveMessages.mockResolvedValue(null);
});
test('should fork conversation without branches', async () => {
const result = await forkConversation({
originalConvoId: 'abc123',
targetMessageId: '3',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
});
console.debug('forkConversation: direct path\n', printMessageTree(result.messages));
// Reversed order due to setup in function
const expectedMessagesTexts = ['Child of 1', 'Root message 2'];
expect(getMessages).toHaveBeenCalled();
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining(
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
),
);
});
test('should fork conversation without branches (deeper)', async () => {
const result = await forkConversation({
originalConvoId: 'abc123',
targetMessageId: '8',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
});
console.debug('forkConversation: direct path (deeper)\n', printMessageTree(result.messages));
const expectedMessagesTexts = ['Child of 7', 'Child of 3', 'Child of 1', 'Root message 2'];
expect(getMessages).toHaveBeenCalled();
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining(
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
),
);
});
test('should fork conversation with branches', async () => {
const result = await forkConversation({
originalConvoId: 'abc123',
targetMessageId: '3',
requestUserId: 'user1',
option: ForkOptions.INCLUDE_BRANCHES,
});
console.debug('forkConversation: include branches\n', printMessageTree(result.messages));
const expectedMessagesTexts = ['Root message 2', 'Child of 1', 'Child of 1'];
expect(getMessages).toHaveBeenCalled();
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining(
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
),
);
});
test('should fork conversation up to target level', async () => {
const result = await forkConversation({
originalConvoId: 'abc123',
targetMessageId: '3',
requestUserId: 'user1',
option: ForkOptions.TARGET_LEVEL,
});
console.debug('forkConversation: target level\n', printMessageTree(result.messages));
const expectedMessagesTexts = ['Root message 1', 'Root message 2', 'Child of 1', 'Child of 1'];
expect(getMessages).toHaveBeenCalled();
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining(
expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
),
);
});
test('should handle errors during message fetching', async () => {
getMessages.mockRejectedValue(new Error('Failed to fetch messages'));
await expect(
forkConversation({
originalConvoId: 'abc123',
targetMessageId: '3',
requestUserId: 'user1',
}),
).rejects.toThrow('Failed to fetch messages');
});
});
const mockMessagesComplex = [
{ messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' },
{ messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' },
{ messageId: '5', parentMessageId: '7', text: 'Message 5' },
{ messageId: '6', parentMessageId: '7', text: 'Message 6' },
{ messageId: '9', parentMessageId: '8', text: 'Message 9' },
{ messageId: '2', parentMessageId: '5', text: 'Message 2' },
{ messageId: '3', parentMessageId: '5', text: 'Message 3' },
{ messageId: '1', parentMessageId: '6', text: 'Message 1' },
{ messageId: '4', parentMessageId: '6', text: 'Message 4' },
{ messageId: '10', parentMessageId: '3', text: 'Message 10' },
];
describe('getMessagesUpToTargetLevel', () => {
test('should get all messages up to target level', async () => {
const result = getMessagesUpToTargetLevel(mockMessagesComplex, '5');
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesUpToTargetLevel] should get all messages up to target level\n',
mappedResult,
);
console.debug('mockMessages\n', printMessageTree(mockMessagesComplex));
console.debug('result\n', printMessageTree(result));
expect(mappedResult).toEqual(['7', '8', '5', '6', '9']);
});
test('should get all messages if target is deepest level', async () => {
const result = getMessagesUpToTargetLevel(mockMessagesComplex, '10');
expect(result.length).toEqual(mockMessagesComplex.length);
});
test('should return target if only message', async () => {
const result = getMessagesUpToTargetLevel(
[mockMessagesComplex[mockMessagesComplex.length - 1]],
'10',
);
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesUpToTargetLevel] should return target if only message\n',
mappedResult,
);
console.debug('mockMessages\n', printMessageTree(mockMessages));
console.debug('result\n', printMessageTree(result));
expect(mappedResult).toEqual(['10']);
});
test('should return empty array if target message ID does not exist', async () => {
const result = getMessagesUpToTargetLevel(mockMessagesComplex, '123');
expect(result).toEqual([]);
});
test('should return correct messages when target is a root message', async () => {
const result = getMessagesUpToTargetLevel(mockMessagesComplex, '7');
const mappedResult = result.map((msg) => msg.messageId);
expect(mappedResult).toEqual(['7', '8']);
});
test('should correctly handle single message with non-matching ID', async () => {
const singleMessage = [
{ messageId: '30', parentMessageId: Constants.NO_PARENT, text: 'Message 30' },
];
const result = getMessagesUpToTargetLevel(singleMessage, '31');
expect(result).toEqual([]);
});
test('should correctly handle case with circular dependencies', async () => {
const circularMessages = [
{ messageId: '40', parentMessageId: '42', text: 'Message 40' },
{ messageId: '41', parentMessageId: '40', text: 'Message 41' },
{ messageId: '42', parentMessageId: '41', text: 'Message 42' },
];
const result = getMessagesUpToTargetLevel(circularMessages, '40');
const mappedResult = result.map((msg) => msg.messageId);
expect(new Set(mappedResult)).toEqual(new Set(['40', '41', '42']));
});
test('should return all messages when all are interconnected and target is deep in hierarchy', async () => {
const interconnectedMessages = [
{ messageId: '50', parentMessageId: Constants.NO_PARENT, text: 'Root Message' },
{ messageId: '51', parentMessageId: '50', text: 'Child Level 1' },
{ messageId: '52', parentMessageId: '51', text: 'Child Level 2' },
{ messageId: '53', parentMessageId: '52', text: 'Child Level 3' },
];
const result = getMessagesUpToTargetLevel(interconnectedMessages, '53');
const mappedResult = result.map((msg) => msg.messageId);
expect(mappedResult).toEqual(['50', '51', '52', '53']);
});
});
describe('getAllMessagesUpToParent', () => {
const mockMessages = [
{ messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' },
{ messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' },
{ messageId: '13', parentMessageId: '11', text: 'Message 13' },
{ messageId: '14', parentMessageId: '12', text: 'Message 14' },
{ messageId: '15', parentMessageId: '13', text: 'Message 15' },
{ messageId: '16', parentMessageId: '13', text: 'Message 16' },
{ messageId: '21', parentMessageId: '13', text: 'Message 21' },
{ messageId: '17', parentMessageId: '14', text: 'Message 17' },
{ messageId: '18', parentMessageId: '16', text: 'Message 18' },
{ messageId: '19', parentMessageId: '18', text: 'Message 19' },
{ messageId: '20', parentMessageId: '19', text: 'Message 20' },
];
test('should handle empty message list', async () => {
const result = getAllMessagesUpToParent([], '10');
expect(result).toEqual([]);
});
test('should handle target message not found', async () => {
const result = getAllMessagesUpToParent(mockMessages, 'invalid-id');
expect(result).toEqual([]);
});
test('should handle single level tree (no parents)', async () => {
const result = getAllMessagesUpToParent(
[
{ messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' },
{ messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' },
],
'11',
);
const mappedResult = result.map((msg) => msg.messageId);
expect(mappedResult).toEqual(['11']);
});
test('should correctly retrieve messages in a deeply nested structure', async () => {
const result = getAllMessagesUpToParent(mockMessages, '20');
const mappedResult = result.map((msg) => msg.messageId);
expect(mappedResult).toContain('11');
expect(mappedResult).toContain('13');
expect(mappedResult).toContain('16');
expect(mappedResult).toContain('18');
expect(mappedResult).toContain('19');
expect(mappedResult).toContain('20');
});
test('should return only the target message if it has no parent', async () => {
const result = getAllMessagesUpToParent(mockMessages, '11');
const mappedResult = result.map((msg) => msg.messageId);
expect(mappedResult).toEqual(['11']);
});
test('should handle messages without a parent ID defined', async () => {
const additionalMessages = [
...mockMessages,
{ messageId: '22', text: 'Message 22' }, // No parentMessageId field
];
const result = getAllMessagesUpToParent(additionalMessages, '22');
const mappedResult = result.map((msg) => msg.messageId);
expect(mappedResult).toEqual(['22']);
});
test('should retrieve all messages from the target to the root (including indirect ancestors)', async () => {
const result = getAllMessagesUpToParent(mockMessages, '18');
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getAllMessagesUpToParent] should retrieve all messages from the target to the root\n',
mappedResult,
);
console.debug('mockMessages\n', printMessageTree(mockMessages));
console.debug('result\n', printMessageTree(result));
expect(mappedResult).toEqual(['11', '13', '15', '16', '21', '18']);
});
test('should handle circular dependencies gracefully', () => {
const mockMessages = [
{ messageId: '1', parentMessageId: '2' },
{ messageId: '2', parentMessageId: '3' },
{ messageId: '3', parentMessageId: '1' },
];
const targetMessageId = '1';
const result = getAllMessagesUpToParent(mockMessages, targetMessageId);
const uniqueIds = new Set(result.map((msg) => msg.messageId));
expect(uniqueIds.size).toBe(result.length);
expect(result.map((msg) => msg.messageId).sort()).toEqual(['1', '2', '3'].sort());
});
test('should return target if only message', async () => {
const result = getAllMessagesUpToParent([mockMessages[mockMessages.length - 1]], '20');
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getAllMessagesUpToParent] should return target if only message\n',
mappedResult,
);
console.debug('mockMessages\n', printMessageTree(mockMessages));
console.debug('result\n', printMessageTree(result));
expect(mappedResult).toEqual(['20']);
});
});
describe('getMessagesForConversation', () => {
const mockMessages = [
{ messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' },
{ messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' },
{ messageId: '13', parentMessageId: '11', text: 'Message 13' },
{ messageId: '14', parentMessageId: '12', text: 'Message 14' },
{ messageId: '15', parentMessageId: '13', text: 'Message 15' },
{ messageId: '16', parentMessageId: '13', text: 'Message 16' },
{ messageId: '21', parentMessageId: '13', text: 'Message 21' },
{ messageId: '17', parentMessageId: '14', text: 'Message 17' },
{ messageId: '18', parentMessageId: '16', text: 'Message 18' },
{ messageId: '19', parentMessageId: '18', text: 'Message 19' },
{ messageId: '20', parentMessageId: '19', text: 'Message 20' },
];
test('should provide the direct path to the target without branches', async () => {
const result = BaseClient.getMessagesForConversation({
messages: mockMessages,
parentMessageId: '18',
});
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesForConversation] should provide the direct path to the target without branches\n',
mappedResult,
);
console.debug('mockMessages\n', printMessageTree(mockMessages));
console.debug('result\n', printMessageTree(result));
expect(new Set(mappedResult)).toEqual(new Set(['11', '13', '16', '18']));
});
test('should return target if only message', async () => {
const result = BaseClient.getMessagesForConversation({
messages: [mockMessages[mockMessages.length - 1]],
parentMessageId: '20',
});
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesForConversation] should return target if only message\n',
mappedResult,
);
console.debug('mockMessages\n', printMessageTree(mockMessages));
console.debug('result\n', printMessageTree(result));
expect(new Set(mappedResult)).toEqual(new Set(['20']));
});
test('should break on detecting a circular dependency', async () => {
const mockMessagesWithCycle = [
...mockMessagesComplex,
{ messageId: '100', parentMessageId: '101', text: 'Message 100' },
{ messageId: '101', parentMessageId: '100', text: 'Message 101' }, // introduces circular dependency
];
const result = BaseClient.getMessagesForConversation({
messages: mockMessagesWithCycle,
parentMessageId: '100',
});
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesForConversation] should break on detecting a circular dependency\n',
mappedResult,
);
expect(mappedResult).toEqual(['101', '100']);
});
// Testing with mockMessagesComplex
test('should correctly find the conversation path including root messages', async () => {
const result = BaseClient.getMessagesForConversation({
messages: mockMessagesComplex,
parentMessageId: '2',
});
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesForConversation] should correctly find the conversation path including root messages\n',
mappedResult,
);
expect(new Set(mappedResult)).toEqual(new Set(['7', '5', '2']));
});
// Testing summary feature
test('should stop at summary if option is enabled', async () => {
const messagesWithSummary = [
...mockMessagesComplex,
{ messageId: '11', parentMessageId: '7', text: 'Message 11', summary: 'Summary for 11' },
];
const result = BaseClient.getMessagesForConversation({
messages: messagesWithSummary,
parentMessageId: '11',
summary: true,
});
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesForConversation] should stop at summary if option is enabled\n',
mappedResult,
);
expect(mappedResult).toEqual(['11']); // Should include only the summarizing message
});
// Testing no parent condition
test('should return only the root message if no parent exists', async () => {
const result = BaseClient.getMessagesForConversation({
messages: mockMessagesComplex,
parentMessageId: '8',
});
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'[getMessagesForConversation] should return only the root message if no parent exists\n',
mappedResult,
);
expect(mappedResult).toEqual(['8']); // The message with no parent in the thread
});
});
describe('splitAtTargetLevel', () => {
/* const mockMessagesComplex = [
{ messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' },
{ messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' },
{ messageId: '5', parentMessageId: '7', text: 'Message 5' },
{ messageId: '6', parentMessageId: '7', text: 'Message 6' },
{ messageId: '9', parentMessageId: '8', text: 'Message 9' },
{ messageId: '2', parentMessageId: '5', text: 'Message 2' },
{ messageId: '3', parentMessageId: '5', text: 'Message 3' },
{ messageId: '1', parentMessageId: '6', text: 'Message 1' },
{ messageId: '4', parentMessageId: '6', text: 'Message 4' },
{ messageId: '10', parentMessageId: '3', text: 'Message 10' },
];
mockMessages
[7]: Root
| [5]: Child of 7
| | [2]: Child of 5
| | [3]: Child of 5
| | [10]: Child of 3
| [6]: Child of 7
| [1]: Child of 6
| [4]: Child of 6
[8]: Root
[9]: Child of 8
*/
test('should include target message level and all descendants (1/2)', () => {
console.debug('splitAtTargetLevel: mockMessages\n', printMessageTree(mockMessagesComplex));
const result = splitAtTargetLevel(mockMessagesComplex, '2');
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'splitAtTargetLevel: include target message level and all descendants (1/2)\n',
printMessageTree(result),
);
expect(mappedResult).toEqual(['2', '3', '1', '4', '10']);
});
test('should include target message level and all descendants (2/2)', () => {
console.debug('splitAtTargetLevel: mockMessages\n', printMessageTree(mockMessagesComplex));
const result = splitAtTargetLevel(mockMessagesComplex, '5');
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'splitAtTargetLevel: include target message level and all descendants (2/2)\n',
printMessageTree(result),
);
expect(mappedResult).toEqual(['5', '6', '9', '2', '3', '1', '4', '10']);
});
test('should handle when target message is root', () => {
const result = splitAtTargetLevel(mockMessagesComplex, '7');
console.debug('splitAtTargetLevel: target level is root message\n', printMessageTree(result));
expect(result.length).toBe(mockMessagesComplex.length);
});
test('should handle when target message is deepest, lonely child', () => {
const result = splitAtTargetLevel(mockMessagesComplex, '10');
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'splitAtTargetLevel: target message is deepest, lonely child\n',
printMessageTree(result),
);
expect(mappedResult).toEqual(['10']);
});
test('should handle when target level is last with many neighbors', () => {
const mockMessages = [
...mockMessagesComplex,
{ messageId: '11', parentMessageId: '10', text: 'Message 11' },
{ messageId: '12', parentMessageId: '10', text: 'Message 12' },
{ messageId: '13', parentMessageId: '10', text: 'Message 13' },
{ messageId: '14', parentMessageId: '10', text: 'Message 14' },
{ messageId: '15', parentMessageId: '4', text: 'Message 15' },
{ messageId: '16', parentMessageId: '15', text: 'Message 15' },
];
const result = splitAtTargetLevel(mockMessages, '11');
const mappedResult = result.map((msg) => msg.messageId);
console.debug(
'splitAtTargetLevel: should handle when target level is last with many neighbors\n',
printMessageTree(result),
);
expect(mappedResult).toEqual(['11', '12', '13', '14', '16']);
});
test('should handle non-existent target message', () => {
// Non-existent message ID
const result = splitAtTargetLevel(mockMessagesComplex, '99');
expect(result.length).toBe(0);
});
});

View file

@ -70,10 +70,12 @@ class ImportBatchBuilder {
* Finishes the current conversation and adds it to the batch. * Finishes the current conversation and adds it to the batch.
* @param {string} [title='Imported Chat'] - The title of the conversation. Defaults to 'Imported Chat'. * @param {string} [title='Imported Chat'] - The title of the conversation. Defaults to 'Imported Chat'.
* @param {Date} [createdAt] - The creation date of the conversation. * @param {Date} [createdAt] - The creation date of the conversation.
* @returns {object} The added conversation object. * @param {TConversation} [originalConvo] - The original conversation.
* @returns {{ conversation: TConversation, messages: TMessage[] }} The resulting conversation and messages.
*/ */
finishConversation(title, createdAt) { finishConversation(title, createdAt, originalConvo = {}) {
const convo = { const convo = {
...originalConvo,
user: this.requestUserId, user: this.requestUserId,
conversationId: this.conversationId, conversationId: this.conversationId,
title: title || 'Imported Chat', title: title || 'Imported Chat',
@ -81,11 +83,12 @@ class ImportBatchBuilder {
updatedAt: createdAt, updatedAt: createdAt,
overrideTimestamp: true, overrideTimestamp: true,
endpoint: this.endpoint, endpoint: this.endpoint,
model: openAISettings.model.default, model: originalConvo.model ?? openAISettings.model.default,
}; };
convo._id && delete convo._id;
this.conversations.push(convo); this.conversations.push(convo);
return convo; return { conversation: convo, messages: this.messages };
} }
/** /**
@ -114,7 +117,9 @@ class ImportBatchBuilder {
* @param {string} [messageDetails.messageId] - The ID of the current message. * @param {string} [messageDetails.messageId] - The ID of the current message.
* @param {boolean} messageDetails.isCreatedByUser - Indicates whether the message is created by the user. * @param {boolean} messageDetails.isCreatedByUser - Indicates whether the message is created by the user.
* @param {string} [messageDetails.model] - The model used for generating the message. * @param {string} [messageDetails.model] - The model used for generating the message.
* @param {string} [messageDetails.endpoint] - The endpoint used for generating the message.
* @param {string} [messageDetails.parentMessageId=this.lastMessageId] - The ID of the parent message. * @param {string} [messageDetails.parentMessageId=this.lastMessageId] - The ID of the parent message.
* @param {Partial<TMessage>} messageDetails.rest - Additional properties that may be included in the message.
* @returns {object} The saved message object. * @returns {object} The saved message object.
*/ */
saveMessage({ saveMessage({
@ -124,22 +129,26 @@ class ImportBatchBuilder {
model, model,
messageId, messageId,
parentMessageId = this.lastMessageId, parentMessageId = this.lastMessageId,
endpoint,
...rest
}) { }) {
const newMessageId = messageId ?? uuidv4(); const newMessageId = messageId ?? uuidv4();
const message = { const message = {
...rest,
parentMessageId, parentMessageId,
messageId: newMessageId, messageId: newMessageId,
conversationId: this.conversationId, conversationId: this.conversationId,
isCreatedByUser: isCreatedByUser, isCreatedByUser: isCreatedByUser,
model: model || this.model, model: model || this.model,
user: this.requestUserId, user: this.requestUserId,
endpoint: this.endpoint, endpoint: endpoint ?? this.endpoint,
unfinished: false, unfinished: false,
isEdited: false, isEdited: false,
error: false, error: false,
sender, sender,
text, text,
}; };
message._id && delete message._id;
this.lastMessageId = newMessageId; this.lastMessageId = newMessageId;
this.messages.push(message); this.messages.push(message);
return message; return message;

View file

@ -48,7 +48,7 @@ async function importChatBotUiConvo(
) { ) {
// this have been tested with chatbot-ui V1 export https://github.com/mckaywrigley/chatbot-ui/tree/b865b0555f53957e96727bc0bbb369c9eaecd83b#legacy-code // this have been tested with chatbot-ui V1 export https://github.com/mckaywrigley/chatbot-ui/tree/b865b0555f53957e96727bc0bbb369c9eaecd83b#legacy-code
try { try {
/** @type {import('./importBatchBuilder').ImportBatchBuilder} */ /** @type {ImportBatchBuilder} */
const importBatchBuilder = builderFactory(requestUserId); const importBatchBuilder = builderFactory(requestUserId);
for (const historyItem of jsonData.history) { for (const historyItem of jsonData.history) {
@ -83,7 +83,7 @@ async function importLibreChatConvo(
builderFactory = createImportBatchBuilder, builderFactory = createImportBatchBuilder,
) { ) {
try { try {
/** @type {import('./importBatchBuilder').ImportBatchBuilder} */ /** @type {ImportBatchBuilder} */
const importBatchBuilder = builderFactory(requestUserId); const importBatchBuilder = builderFactory(requestUserId);
importBatchBuilder.startConversation(EModelEndpoint.openAI); importBatchBuilder.startConversation(EModelEndpoint.openAI);
@ -163,7 +163,7 @@ async function importChatGptConvo(
* It directly manages the addition of messages for different roles and handles citations for assistant messages. * It directly manages the addition of messages for different roles and handles citations for assistant messages.
* *
* @param {ChatGPTConvo} conv - A single conversation object that contains multiple messages and other details. * @param {ChatGPTConvo} conv - A single conversation object that contains multiple messages and other details.
* @param {import('./importBatchBuilder').ImportBatchBuilder} importBatchBuilder - The batch builder instance used to manage and batch conversation data. * @param {ImportBatchBuilder} importBatchBuilder - The batch builder instance used to manage and batch conversation data.
* @param {string} requestUserId - The ID of the user who initiated the import process. * @param {string} requestUserId - The ID of the user who initiated the import process.
* @returns {void} * @returns {void}
*/ */

View file

@ -644,6 +644,12 @@
* @memberof typedefs * @memberof typedefs
*/ */
/**
* @exports ImportBatchBuilder
* @typedef {import('./server/utils/import/importBatchBuilder.js').ImportBatchBuilder} ImportBatchBuilder
* @memberof typedefs
*/
/** /**
* @exports Thread * @exports Thread
* @typedef {Object} Thread * @typedef {Object} Thread
@ -1257,3 +1263,17 @@
* @property {Object.<string, ChatGPTMapping>} mapping - Mapping of message nodes within the conversation. * @property {Object.<string, ChatGPTMapping>} mapping - Mapping of message nodes within the conversation.
* @memberof typedefs * @memberof typedefs
*/ */
/** Mutations */
/**
* @exports TForkConvoResponse
* @typedef {import('librechat-data-provider').TForkConvoResponse} TForkConvoResponse
* @memberof typedefs
*/
/**
* @exports TForkConvoRequest
* @typedef {import('librechat-data-provider').TForkConvoRequest} TForkConvoRequest
* @memberof typedefs
*/

View file

@ -1,86 +0,0 @@
import { useEffect, useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useMediaQuery, useGenerationsByLatest } from '~/hooks';
import Regenerate from '~/components/Input/Generations/Regenerate';
import Continue from '~/components/Input/Generations/Continue';
import Stop from '~/components/Input/Generations/Stop';
import { useChatContext } from '~/Providers';
import { cn } from '~/utils';
type GenerationButtonsProps = {
endpoint: string;
showPopover?: boolean;
opacityClass?: string;
};
export default function GenerationButtons({
endpoint,
showPopover = false,
opacityClass = 'full-opacity',
}: GenerationButtonsProps) {
const {
getMessages,
isSubmitting,
latestMessage,
handleContinue,
handleRegenerate,
handleStopGenerating,
} = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { continueSupported, regenerateEnabled } = useGenerationsByLatest({
endpoint,
message: latestMessage as TMessage,
isSubmitting,
latestMessage,
});
const [userStopped, setUserStopped] = useState(false);
const messages = getMessages();
const handleStop = (e: React.MouseEvent<HTMLButtonElement>) => {
setUserStopped(true);
handleStopGenerating(e);
};
useEffect(() => {
let timer: NodeJS.Timeout;
if (userStopped) {
timer = setTimeout(() => {
setUserStopped(false);
}, 200);
}
return () => {
clearTimeout(timer);
};
}, [userStopped]);
if (isSmallScreen) {
return null;
}
let button: React.ReactNode = null;
if (isSubmitting) {
button = <Stop onClick={handleStop} />;
} else if (userStopped || continueSupported) {
button = <Continue onClick={handleContinue} />;
} else if (messages && messages.length > 0 && regenerateEnabled) {
button = <Regenerate onClick={handleRegenerate} />;
}
return (
<div className="absolute bottom-0 right-0 z-[62]">
<div className="grow" />
<div className="flex items-center md:items-end">
<div
className={cn('option-buttons', showPopover ? '' : opacityClass)}
data-projection-id="173"
>
{button}
</div>
</div>
</div>
);
}

View file

@ -3,6 +3,7 @@ import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider'; import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg'; import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerationsByLatest, useLocalize } from '~/hooks'; import { useGenerationsByLatest, useLocalize } from '~/hooks';
import { Fork } from '~/components/Conversations';
import { cn } from '~/utils'; import { cn } from '~/utils';
type THoverButtons = { type THoverButtons = {
@ -34,7 +35,8 @@ export default function HoverButtons({
const { endpoint: _endpoint, endpointType } = conversation ?? {}; const { endpoint: _endpoint, endpointType } = conversation ?? {};
const endpoint = endpointType ?? _endpoint; const endpoint = endpointType ?? _endpoint;
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerationsByLatest({ const { hideEditButton, regenerateEnabled, continueSupported, forkingSupported } =
useGenerationsByLatest({
isEditing, isEditing,
isSubmitting, isSubmitting,
message, message,
@ -113,6 +115,13 @@ export default function HoverButtons({
<ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" /> <ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button> </button>
) : null} ) : null}
<Fork
isLast={isLast}
messageId={message.messageId}
conversationId={conversation.conversationId}
forkingSupported={forkingSupported}
latestMessage={latestMessage}
/>
</div> </div>
); );
} }

View file

@ -0,0 +1,331 @@
import { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import { GitFork, InfoIcon } from 'lucide-react';
import * as Popover from '@radix-ui/react-popover';
import { ForkOptions, TMessage } from 'librechat-data-provider';
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
import {
Checkbox,
HoverCard,
HoverCardTrigger,
HoverCardPortal,
HoverCardContent,
} from '~/components/ui';
import OptionHover from '~/components/SidePanel/Parameters/OptionHover';
import { useToastContext, useChatContext } from '~/Providers';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { ESide } from '~/common';
import { cn } from '~/utils';
import store from '~/store';
interface PopoverButtonProps {
children: React.ReactNode;
setting: string;
onClick: (setting: string) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<string>>;
sideOffset?: number;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode;
hoverTitle?: React.ReactNode;
hoverDescription?: React.ReactNode;
}
const optionLabels = {
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
default: 'com_ui_fork_from_message',
};
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
sideOffset = 30,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
}) => {
return (
<HoverCard openDelay={200}>
<Popover.Close
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels.default);
}, 175);
}}
className="mx-1 max-w-14 flex-1 rounded-lg border-2 bg-white transition duration-300 ease-in-out hover:bg-black dark:border-gray-400 dark:bg-gray-700/95 dark:text-gray-400 hover:dark:border-gray-200 hover:dark:text-gray-200"
type="button"
>
{children}
</Popover.Close>
{(hoverInfo || hoverTitle || hoverDescription) && (
<HoverCardPortal>
<HoverCardContent
side="right"
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={sideOffset}
>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
)}
</HoverCard>
);
};
export default function Fork({
isLast,
messageId,
conversationId,
forkingSupported,
latestMessage,
}: {
isLast?: boolean;
messageId: string;
conversationId: string | null;
forkingSupported?: boolean;
latestMessage: TMessage | null;
}) {
const localize = useLocalize();
const { index } = useChatContext();
const { showToast } = useToastContext();
const [remember, setRemember] = useState(false);
const { navigateToConvo } = useNavigateToConvo(index);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberForkOption);
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
if (data) {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_fork_success'),
status: 'success',
});
}
},
onMutate: () => {
showToast({
message: localize('com_ui_fork_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_fork_error'),
status: 'error',
});
},
});
if (!forkingSupported || !conversationId || !messageId) {
return null;
}
const onClick = (option: string) => {
if (remember) {
setRememberGlobal(true);
setForkSetting(option);
}
forkConvo.mutate({
messageId,
conversationId,
option,
splitAtTarget,
latestMessageId: latestMessage?.messageId,
});
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
'data-[state=open]:active data-[state=open]:bg-gray-200 data-[state=open]:text-gray-700 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast ? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId: latestMessage?.messageId,
});
}
}}
type="button"
title={localize('com_ui_continue')}
>
<GitFork className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
</Popover.Trigger>
<Popover.Portal>
<div dir="ltr">
<Popover.Content
side="top"
role="menu"
className="bg-token-surface-primary flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg bg-white p-2 px-3 shadow-lg dark:bg-gray-700/95"
style={{ outline: 'none', pointerEvents: 'auto', boxSizing: 'border-box' }}
tabIndex={-1}
sideOffset={5}
align="center"
>
<div className="flex h-6 w-full items-center justify-center text-sm dark:text-gray-200">
{localize(activeSetting)}
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
side="right"
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={19}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-gray-600 dark:text-gray-300">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', localize('com_ui_fork_split_target'))}
</span>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
sideOffset={155}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
}
hoverDescription={localize('com_ui_fork_info_visible')}
>
<HoverCardTrigger asChild>
<GitCommit className="h-full w-full rotate-90 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={90}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<HoverCardTrigger asChild>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={25}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(optionLabels[ForkOptions.TARGET_LEVEL])} (${localize(
'com_endpoint_default',
)})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<HoverCardTrigger asChild>
<ListTree className="h-full w-full p-2" />
</HoverCardTrigger>
</PopoverButton>
</div>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
checked={splitAtTarget}
onCheckedChange={(checked: boolean) => setSplitAtTarget(checked)}
className="m-2 transition duration-300 ease-in-out"
/>
{localize('com_ui_fork_split_target')}
</div>
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_start"
langCode={true}
sideOffset={20}
/>
</HoverCard>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
checked={remember}
onCheckedChange={(checked: boolean) => {
if (checked) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
setRemember(checked);
}}
className="m-2 transition duration-300 ease-in-out"
/>
{localize('com_ui_fork_remember')}
</div>
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_remember"
langCode={true}
sideOffset={20}
/>
</HoverCard>
</Popover.Content>
</div>
</Popover.Portal>
</Popover.Root>
);
}

View file

@ -1,3 +1,4 @@
export { default as Fork } from './Fork';
export { default as Pages } from './Pages'; export { default as Pages } from './Pages';
export { default as Conversation } from './Conversation'; export { default as Conversation } from './Conversation';
export { default as RenameButton } from './RenameButton'; export { default as RenameButton } from './RenameButton';

View file

@ -1,48 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { cn, removeFocusOutlines } from '~/utils/';
type GenerationButtonsProps = {
showPopover: boolean;
opacityClass: string;
};
export default function GenerationButtons({ showPopover, opacityClass }: GenerationButtonsProps) {
return (
<div className="absolute bottom-4 right-0 z-[62]">
<div className="grow"></div>
<div className="flex items-center md:items-end">
<div
className={cn('option-buttons', showPopover ? '' : opacityClass)}
data-projection-id="173"
>
{/* <button
className={cn(
'custom-btn btn-neutral relative -z-0 whitespace-nowrap border-0 md:border',
removeFocusOutlines,
)}
>
<div className="flex w-full items-center justify-center gap-2">
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3 flex-shrink-0"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="1 4 1 10 7 10"></polyline>
<polyline points="23 20 23 14 17 14"></polyline>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
Regenerate
</div>
</button> */}
</div>
</div>
</div>
);
}

View file

@ -1,101 +0,0 @@
import { useState } from 'react';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerations, useLocalize } from '~/hooks';
import { cn } from '~/utils';
type THoverButtons = {
isEditing: boolean;
enterEdit: (cancel?: boolean) => void;
copyToClipboard: (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => void;
conversation: TConversation | null;
isSubmitting: boolean;
message: TMessage;
regenerate: () => void;
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
export default function HoverButtons({
isEditing,
enterEdit,
copyToClipboard,
conversation,
isSubmitting,
message,
regenerate,
handleContinue,
}: THoverButtons) {
const localize = useLocalize();
const { endpoint } = conversation ?? {};
const [isCopied, setIsCopied] = useState(false);
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
});
if (!conversation) {
return null;
}
const { isCreatedByUser } = message;
const onEdit = () => {
if (isEditing) {
return enterEdit(true);
}
enterEdit();
};
return (
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:right-0 lg:top-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
<button
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon />
</button>
<button
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
isCreatedByUser ? '' : 'active',
)}
onClick={() => copyToClipboard(setIsCopied)}
type="button"
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark /> : <Clipboard />}
</button>
{regenerateEnabled ? (
<button
className="hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible"
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon className="hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
{continueSupported ? (
<button
className="hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible "
onClick={handleContinue}
type="button"
title={localize('com_ui_continue')}
>
<ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
</div>
);
}

View file

@ -79,11 +79,11 @@ function NavLinks() {
<div <div
style={{ style={{
backgroundColor: 'rgb(121, 137, 255)', backgroundColor: 'rgb(121, 137, 255)',
width: '20px', width: '32px',
height: '20px', height: '32px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px', boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}} }}
className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 text-white" className="relative flex items-center justify-center rounded-full p-1 text-white"
> >
<UserIcon /> <UserIcon />
</div> </div>

View file

@ -1,9 +1,10 @@
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import { MessageSquare } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider'; import { SettingsTabValues } from 'librechat-data-provider';
import type { TDialogProps } from '~/common'; import type { TDialogProps } from '~/common';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
import { GearIcon, DataIcon, UserIcon, ExperimentIcon } from '~/components/svg'; import { GearIcon, DataIcon, UserIcon, ExperimentIcon } from '~/components/svg';
import { General, Beta, Data, Account } from './SettingsTabs'; import { General, Messages, Beta, Data, Account } from './SettingsTabs';
import { useMediaQuery, useLocalize } from '~/hooks'; import { useMediaQuery, useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -54,6 +55,20 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<GearIcon /> <GearIcon />
{localize('com_nav_setting_general')} {localize('com_nav_setting_general')}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.MESSAGES}
style={{ userSelect: 'none' }}
>
<MessageSquare className="icon-sm" />
{localize('com_endpoint_messages')}
</Tabs.Trigger>
<Tabs.Trigger <Tabs.Trigger
className={cn( className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600', 'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
@ -98,6 +113,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
<General /> <General />
<Messages />
<Beta /> <Beta />
<Data /> <Data />
<Account /> <Account />

View file

@ -40,7 +40,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
' btn btn-danger relative border-none bg-red-700 text-white hover:bg-red-800 dark:hover:bg-red-800', ' btn btn-danger relative min-w-[70px] border-none bg-red-700 text-white hover:bg-red-800 dark:hover:bg-red-800',
className, className,
)} )}
> >

View file

@ -0,0 +1,29 @@
import type { TDangerButtonProps } from '~/common';
import DangerButton from '../DangerButton';
export const ClearChatsButton = ({
confirmClear,
className = '',
showText = true,
mutation,
onClick,
}: Pick<
TDangerButtonProps,
'confirmClear' | 'mutation' | 'className' | 'showText' | 'onClick'
>) => {
return (
<DangerButton
id="clearConvosBtn"
mutation={mutation}
confirmClear={confirmClear}
className={className}
showText={showText}
infoTextCode="com_nav_clear_all_chats"
actionTextCode="com_ui_clear"
confirmActionTextCode="com_nav_confirm_clear"
dataTestIdInitial="clear-convos-initial"
dataTestIdConfirm="clear-convos-confirm"
onClick={onClick}
/>
);
};

View file

@ -2,7 +2,7 @@ import 'test/matchMedia.mock';
import React from 'react'; import React from 'react';
import { render, fireEvent } from '@testing-library/react'; import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect'; import '@testing-library/jest-dom/extend-expect';
import { ClearChatsButton } from './General'; import { ClearChatsButton } from './ClearChats';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
describe('ClearChatsButton', () => { describe('ClearChatsButton', () => {

View file

@ -1,13 +1,15 @@
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import { import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation, useRevokeUserKeyMutation,
useRevokeAllUserKeysMutation,
useClearConversationsMutation,
} from 'librechat-data-provider/react-query'; } from 'librechat-data-provider/react-query';
import { SettingsTabValues } from 'librechat-data-provider'; import { SettingsTabValues } from 'librechat-data-provider';
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { useOnClickOutside } from '~/hooks'; import { useConversation, useConversations, useOnClickOutside } from '~/hooks';
import DangerButton from '../DangerButton';
import ImportConversations from './ImportConversations'; import ImportConversations from './ImportConversations';
import { ClearChatsButton } from './ClearChats';
import DangerButton from '../DangerButton';
export const RevokeKeysButton = ({ export const RevokeKeysButton = ({
showText = true, showText = true,
@ -20,42 +22,43 @@ export const RevokeKeysButton = ({
all?: boolean; all?: boolean;
disabled?: boolean; disabled?: boolean;
}) => { }) => {
const [confirmClear, setConfirmClear] = useState(false); const [confirmRevoke, setConfirmRevoke] = useState(false);
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const contentRef = useRef(null); const revokeKeysMutation = useRevokeAllUserKeysMutation();
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []); const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeContentRef = useRef(null);
useOnClickOutside(revokeContentRef, () => confirmRevoke && setConfirmRevoke(false), []);
const revokeAllUserKeys = useCallback(() => { const revokeAllUserKeys = useCallback(() => {
if (confirmClear) { if (confirmRevoke) {
revokeKeysMutation.mutate({}); revokeKeysMutation.mutate({});
setConfirmClear(false); setConfirmRevoke(false);
} else { } else {
setConfirmClear(true); setConfirmRevoke(true);
} }
}, [confirmClear, revokeKeysMutation]); }, [confirmRevoke, revokeKeysMutation]);
const revokeUserKey = useCallback(() => { const revokeUserKey = useCallback(() => {
if (!endpoint) { if (!endpoint) {
return; return;
} else if (confirmClear) { } else if (confirmRevoke) {
revokeKeyMutation.mutate({}); revokeKeyMutation.mutate({});
setConfirmClear(false); setConfirmRevoke(false);
} else { } else {
setConfirmClear(true); setConfirmRevoke(true);
} }
}, [confirmClear, revokeKeyMutation, endpoint]); }, [confirmRevoke, revokeKeyMutation, endpoint]);
const onClick = all ? revokeAllUserKeys : revokeUserKey; const onClick = all ? revokeAllUserKeys : revokeUserKey;
return ( return (
<DangerButton <DangerButton
ref={contentRef} ref={revokeContentRef}
showText={showText} showText={showText}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
confirmClear={confirmClear} confirmClear={confirmRevoke}
id={'revoke-all-user-keys'} id={'revoke-all-user-keys'}
actionTextCode={'com_ui_revoke'} actionTextCode={'com_ui_revoke'}
infoTextCode={'com_ui_revoke_info'} infoTextCode={'com_ui_revoke_info'}
@ -67,18 +70,54 @@ export const RevokeKeysButton = ({
}; };
function Data() { function Data() {
const dataTabRef = useRef(null);
const [confirmClearConvos, setConfirmClearConvos] = useState(false);
useOnClickOutside(dataTabRef, () => confirmClearConvos && setConfirmClearConvos(false), []);
const { newConversation } = useConversation();
const { refreshConversations } = useConversations();
const clearConvosMutation = useClearConversationsMutation();
const clearConvos = () => {
if (confirmClearConvos) {
console.log('Clearing conversations...');
setConfirmClearConvos(false);
clearConvosMutation.mutate(
{},
{
onSuccess: () => {
newConversation();
refreshConversations();
},
},
);
} else {
setConfirmClearConvos(true);
}
};
return ( return (
<Tabs.Content <Tabs.Content
value={SettingsTabValues.DATA} value={SettingsTabValues.DATA}
role="tabpanel" role="tabpanel"
className="w-full md:min-h-[300px]" className="w-full md:min-h-[300px]"
ref={dataTabRef}
> >
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50"> <div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<RevokeKeysButton all={true} /> <ImportConversations />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ImportConversations /> <RevokeKeysButton all={true} />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ClearChatsButton
confirmClear={confirmClearConvos}
onClick={clearConvos}
showText={true}
mutation={clearConvosMutation}
/>
</div> </div>
</div> </div>
</Tabs.Content> </Tabs.Content>

View file

@ -1,15 +1,17 @@
import { useState } from 'react';
import { Import } from 'lucide-react'; import { Import } from 'lucide-react';
import { cn } from '~/utils';
import { useUploadConversationsMutation } from '~/data-provider'; import { useUploadConversationsMutation } from '~/data-provider';
import { useLocalize, useConversations } from '~/hooks'; import { useLocalize, useConversations } from '~/hooks';
import { useState } from 'react';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { cn } from '~/utils';
function ImportConversations() { function ImportConversations() {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [, setErrors] = useState<string[]>([]); const [, setErrors] = useState<string[]>([]);
const [allowImport, setAllowImport] = useState(true);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]); const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
const { refreshConversations } = useConversations(); const { refreshConversations } = useConversations();
@ -17,9 +19,11 @@ function ImportConversations() {
onSuccess: () => { onSuccess: () => {
refreshConversations(); refreshConversations();
showToast({ message: localize('com_ui_import_conversation_success') }); showToast({ message: localize('com_ui_import_conversation_success') });
setAllowImport(true);
}, },
onError: (error) => { onError: (error) => {
console.error('Error: ', error); console.error('Error: ', error);
setAllowImport(true);
setError( setError(
(error as { response: { data: { message?: string } } })?.response?.data?.message ?? (error as { response: { data: { message?: string } } })?.response?.data?.message ??
'An error occurred while uploading the file.', 'An error occurred while uploading the file.',
@ -33,6 +37,9 @@ function ImportConversations() {
showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' }); showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' });
} }
}, },
onMutate: () => {
setAllowImport(false);
},
}); });
const startUpload = async (file: File) => { const startUpload = async (file: File) => {
@ -43,8 +50,6 @@ function ImportConversations() {
}; };
const handleFiles = async (_file: File) => { const handleFiles = async (_file: File) => {
console.log('Handling files...');
/* Process files */ /* Process files */
try { try {
await startUpload(_file); await startUpload(_file);
@ -55,7 +60,6 @@ function ImportConversations() {
}; };
const handleFileChange = (event) => { const handleFileChange = (event) => {
console.log('file change');
const file = event.target.files[0]; const file = event.target.files[0];
if (file) { if (file) {
handleFiles(file); handleFiles(file);
@ -67,12 +71,17 @@ function ImportConversations() {
<span>{localize('com_ui_import_conversation_info')}</span> <span>{localize('com_ui_import_conversation_info')}</span>
<label <label
htmlFor={'import-conversations-file'} htmlFor={'import-conversations-file'}
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500" className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-3 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
> >
<Import className="mr-1 flex w-[22px] items-center stroke-1" /> {allowImport ? (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
) : (
<Spinner className="mr-1 w-4" />
)}
<span>{localize('com_ui_import_conversation')}</span> <span>{localize('com_ui_import_conversation')}</span>
<input <input
id={'import-conversations-file'} id={'import-conversations-file'}
disabled={!allowImport}
value="" value=""
type="file" type="file"
className={cn('hidden')} className={cn('hidden')}

View file

@ -1,21 +1,11 @@
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider'; import { SettingsTabValues } from 'librechat-data-provider';
import React, { useState, useContext, useCallback, useRef } from 'react'; import React, { useContext, useCallback, useRef } from 'react';
import { useClearConversationsMutation } from 'librechat-data-provider/react-query';
import {
ThemeContext,
useLocalize,
useOnClickOutside,
useConversation,
useConversations,
useLocalStorage,
} from '~/hooks';
import type { TDangerButtonProps } from '~/common'; import type { TDangerButtonProps } from '~/common';
import { ThemeContext, useLocalize, useLocalStorage } from '~/hooks';
import HideSidePanelSwitch from './HideSidePanelSwitch'; import HideSidePanelSwitch from './HideSidePanelSwitch';
import AutoScrollSwitch from './AutoScrollSwitch'; import AutoScrollSwitch from './AutoScrollSwitch';
import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch';
import { Dropdown } from '~/components/ui'; import { Dropdown } from '~/components/ui';
import DangerButton from '../DangerButton'; import DangerButton from '../DangerButton';
import store from '~/store'; import store from '~/store';
@ -119,33 +109,11 @@ export const LangSelector = ({
function General() { function General() {
const { theme, setTheme } = useContext(ThemeContext); const { theme, setTheme } = useContext(ThemeContext);
const clearConvosMutation = useClearConversationsMutation();
const [confirmClear, setConfirmClear] = useState(false);
const [langcode, setLangcode] = useRecoilState(store.lang); const [langcode, setLangcode] = useRecoilState(store.lang);
const [selectedLang, setSelectedLang] = useLocalStorage('selectedLang', langcode); const [selectedLang, setSelectedLang] = useLocalStorage('selectedLang', langcode);
const { newConversation } = useConversation();
const { refreshConversations } = useConversations();
const contentRef = useRef(null); const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
const clearConvos = () => {
if (confirmClear) {
console.log('Clearing conversations...');
setConfirmClear(false);
clearConvosMutation.mutate(
{},
{
onSuccess: () => {
newConversation();
refreshConversations();
},
},
);
} else {
setConfirmClear(true);
}
};
const changeTheme = useCallback( const changeTheme = useCallback(
(value: string) => { (value: string) => {
@ -183,28 +151,14 @@ function General() {
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<LangSelector langcode={selectedLang} onChange={changeLang} /> <LangSelector langcode={selectedLang} onChange={changeLang} />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<AutoScrollSwitch /> <AutoScrollSwitch />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ShowCodeSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<HideSidePanelSwitch /> <HideSidePanelSwitch />
</div> </div>
{/* Clear Chats should be last */} {/* <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> </div> */}
<ClearChatsButton
confirmClear={confirmClear}
onClick={clearConvos}
showText={true}
mutation={clearConvosMutation}
/>
</div>
</div> </div>
</Tabs.Content> </Tabs.Content>
); );

View file

@ -0,0 +1,59 @@
import { useRecoilState } from 'recoil';
import { ForkOptions } from 'librechat-data-provider';
import { Dropdown, Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export const ForkSettings = () => {
const localize = useLocalize();
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [remember, setRemember] = useRecoilState<boolean>(store.rememberForkOption);
const forkOptions = [
{ value: ForkOptions.DIRECT_PATH, display: localize('com_ui_fork_visible') },
{ value: ForkOptions.INCLUDE_BRANCHES, display: localize('com_ui_fork_branches') },
{ value: ForkOptions.TARGET_LEVEL, display: localize('com_ui_fork_all_target') },
];
return (
<>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_change_default')} </div>
<Dropdown
value={forkSetting}
onChange={setForkSetting}
options={forkOptions}
width={200}
testId="fork-setting-dropdown"
/>
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_default')} </div>
<Switch
id="rememberForkOption"
checked={remember}
onCheckedChange={setRemember}
className="ml-4 mt-2"
data-testid="rememberForkOption"
/>
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_split_target_setting')} </div>
<Switch
id="splitAtTarget"
checked={splitAtTarget}
onCheckedChange={setSplitAtTarget}
className="ml-4 mt-2"
data-testid="splitAtTarget"
/>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,28 @@
import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch';
import { ForkSettings } from './ForkSettings';
function Messages() {
return (
<Tabs.Content
value={SettingsTabValues.MESSAGES}
role="tabpanel"
className="w-full md:min-h-[300px]"
>
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ShowCodeSwitch />
</div>
<ForkSettings />
</div>
</Tabs.Content>
);
}
export default memo(Messages);

View file

@ -1,4 +1,5 @@
export { default as General } from './General/General'; export { default as General } from './General/General';
export { default as Messages } from './Messages/Messages';
export { ClearChatsButton } from './General/General'; export { ClearChatsButton } from './General/General';
export { default as Data } from './Data/Data'; export { default as Data } from './Data/Data';
export { default as Beta } from './Beta/Beta'; export { default as Beta } from './Beta/Beta';

View file

@ -6,15 +6,20 @@ import { ESide } from '~/common';
type TOptionHoverProps = { type TOptionHoverProps = {
description: string; description: string;
langCode?: boolean; langCode?: boolean;
sideOffset?: number;
side: ESide; side: ESide;
}; };
function OptionHover({ side, description, langCode }: TOptionHoverProps) { function OptionHover({ side, description, langCode, sideOffset = 30 }: TOptionHoverProps) {
const localize = useLocalize(); const localize = useLocalize();
const text = langCode ? localize(description) : description; const text = langCode ? localize(description) : description;
return ( return (
<HoverCardPortal> <HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80 dark:bg-gray-700" sideOffset={30}> <HoverCardContent
side={side}
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={sideOffset}
>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">{text}</p> <p className="text-sm text-gray-600 dark:text-gray-300">{text}</p>
</div> </div>

View file

@ -2,44 +2,13 @@ import { LocalStorageKeys } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { UseMutationResult } from '@tanstack/react-query'; import type { UseMutationResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import type { import {
TFile, addConversation,
BatchFile, updateConversation,
TFileUpload, deleteConversation,
TImportStartResponse, updateConvoFields,
AssistantListResponse, } from '~/utils';
UploadMutationOptions,
UploadConversationsMutationOptions,
DeleteFilesResponse,
DeleteFilesBody,
DeleteMutationOptions,
UpdatePresetOptions,
DeletePresetOptions,
PresetDeleteResponse,
LogoutOptions,
TPreset,
UploadAvatarOptions,
AvatarUploadResponse,
TConversation,
Assistant,
AssistantCreateParams,
AssistantUpdateParams,
UploadAssistantAvatarOptions,
AssistantAvatarVariables,
CreateAssistantMutationOptions,
UpdateAssistantMutationOptions,
DeleteAssistantMutationOptions,
DeleteAssistantBody,
DeleteConversationOptions,
UpdateActionOptions,
UpdateActionVariables,
UpdateActionResponse,
DeleteActionOptions,
DeleteActionVariables,
Action,
} from 'librechat-data-provider';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider'; import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import { updateConversation, deleteConversation, updateConvoFields } from '~/utils';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import store from '~/store'; import store from '~/store';
@ -55,7 +24,7 @@ export const useGenTitleMutation = (): UseMutationResult<
onSuccess: (response, vars) => { onSuccess: (response, vars) => {
queryClient.setQueryData( queryClient.setQueryData(
[QueryKeys.conversation, vars.conversationId], [QueryKeys.conversation, vars.conversationId],
(convo: TConversation | undefined) => { (convo: t.TConversation | undefined) => {
if (!convo) { if (!convo) {
return convo; return convo;
} }
@ -69,7 +38,7 @@ export const useGenTitleMutation = (): UseMutationResult<
return updateConvoFields(convoData, { return updateConvoFields(convoData, {
conversationId: vars.conversationId, conversationId: vars.conversationId,
title: response.title, title: response.title,
} as TConversation); } as t.TConversation);
}); });
document.title = response.title; document.title = response.title;
}, },
@ -102,7 +71,7 @@ export const useUpdateConversationMutation = (
}; };
export const useDeleteConversationMutation = ( export const useDeleteConversationMutation = (
options?: DeleteConversationOptions, options?: t.DeleteConversationOptions,
): UseMutationResult< ): UseMutationResult<
t.TDeleteConversationResponse, t.TDeleteConversationResponse,
unknown, unknown,
@ -133,9 +102,41 @@ export const useDeleteConversationMutation = (
); );
}; };
export const useUploadConversationsMutation = (_options?: UploadConversationsMutationOptions) => { export const useForkConvoMutation = (
options?: t.ForkConvoOptions,
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { onSuccess, onError } = _options || {}; const { onSuccess, ..._options } = options || {};
return useMutation((payload: t.TForkConvoRequest) => dataService.forkConversation(payload), {
onSuccess: (data, vars, context) => {
if (!vars.conversationId) {
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 useUploadConversationsMutation = (
_options?: t.MutationOptions<t.TImportJobStatus, FormData>,
) => {
const queryClient = useQueryClient();
const { onSuccess, onError, onMutate } = _options || {};
// returns the job status or reason of failure // returns the job status or reason of failure
const checkJobStatus = async (jobId) => { const checkJobStatus = async (jobId) => {
@ -182,7 +183,8 @@ export const useUploadConversationsMutation = (_options?: UploadConversationsMut
} }
}, pollInterval); }, pollInterval);
}; };
return useMutation<TImportStartResponse, unknown, FormData>({
return useMutation<t.TImportStartResponse, unknown, FormData>({
mutationFn: (formData: FormData) => dataService.importConversationsFile(formData), mutationFn: (formData: FormData) => dataService.importConversationsFile(formData),
onSuccess: (data, variables, context) => { onSuccess: (data, variables, context) => {
queryClient.invalidateQueries([QueryKeys.allConversations]); queryClient.invalidateQueries([QueryKeys.allConversations]);
@ -213,13 +215,14 @@ export const useUploadConversationsMutation = (_options?: UploadConversationsMut
onError(err, variables, context); onError(err, variables, context);
} }
}, },
onMutate,
}); });
}; };
export const useUploadFileMutation = ( export const useUploadFileMutation = (
_options?: UploadMutationOptions, _options?: t.UploadMutationOptions,
): UseMutationResult< ): UseMutationResult<
TFileUpload, // response data t.TFileUpload, // response data
unknown, // error unknown, // error
FormData, // request FormData, // request
unknown // context unknown // context
@ -238,7 +241,7 @@ export const useUploadFileMutation = (
}, },
...(options || {}), ...(options || {}),
onSuccess: (data, formData, context) => { onSuccess: (data, formData, context) => {
queryClient.setQueryData<TFile[] | undefined>([QueryKeys.files], (_files) => [ queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (_files) => [
data, data,
...(_files ?? []), ...(_files ?? []),
]); ]);
@ -251,7 +254,7 @@ export const useUploadFileMutation = (
return; return;
} }
queryClient.setQueryData<AssistantListResponse>( queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery], [QueryKeys.assistants, defaultOrderQuery],
(prev) => { (prev) => {
if (!prev) { if (!prev) {
@ -278,26 +281,26 @@ export const useUploadFileMutation = (
}; };
export const useDeleteFilesMutation = ( export const useDeleteFilesMutation = (
_options?: DeleteMutationOptions, _options?: t.DeleteMutationOptions,
): UseMutationResult< ): UseMutationResult<
DeleteFilesResponse, // response data t.DeleteFilesResponse, // response data
unknown, // error unknown, // error
DeleteFilesBody, // request t.DeleteFilesBody, // request
unknown // context unknown // context
> => { > => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { onSuccess, ...options } = _options || {}; const { onSuccess, ...options } = _options || {};
return useMutation([MutationKeys.fileDelete], { return useMutation([MutationKeys.fileDelete], {
mutationFn: (body: DeleteFilesBody) => dataService.deleteFiles(body.files, body.assistant_id), mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body.files, body.assistant_id),
...(options || {}), ...(options || {}),
onSuccess: (data, ...args) => { onSuccess: (data, ...args) => {
queryClient.setQueryData<TFile[] | undefined>([QueryKeys.files], (cachefiles) => { queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
const { files: filesDeleted } = args[0]; const { files: filesDeleted } = args[0];
const fileMap = filesDeleted.reduce((acc, file) => { const fileMap = filesDeleted.reduce((acc, file) => {
acc.set(file.file_id, file); acc.set(file.file_id, file);
return acc; return acc;
}, new Map<string, BatchFile>()); }, new Map<string, t.BatchFile>());
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id)); return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
}); });
@ -307,36 +310,36 @@ export const useDeleteFilesMutation = (
}; };
export const useUpdatePresetMutation = ( export const useUpdatePresetMutation = (
options?: UpdatePresetOptions, options?: t.UpdatePresetOptions,
): UseMutationResult< ): UseMutationResult<
TPreset, // response data t.TPreset, // response data
unknown, unknown,
TPreset, t.TPreset,
unknown unknown
> => { > => {
return useMutation([MutationKeys.updatePreset], { return useMutation([MutationKeys.updatePreset], {
mutationFn: (preset: TPreset) => dataService.updatePreset(preset), mutationFn: (preset: t.TPreset) => dataService.updatePreset(preset),
...(options || {}), ...(options || {}),
}); });
}; };
export const useDeletePresetMutation = ( export const useDeletePresetMutation = (
options?: DeletePresetOptions, options?: t.DeletePresetOptions,
): UseMutationResult< ): UseMutationResult<
PresetDeleteResponse, // response data t.PresetDeleteResponse, // response data
unknown, unknown,
TPreset | undefined, t.TPreset | undefined,
unknown unknown
> => { > => {
return useMutation([MutationKeys.deletePreset], { return useMutation([MutationKeys.deletePreset], {
mutationFn: (preset: TPreset | undefined) => dataService.deletePreset(preset), mutationFn: (preset: t.TPreset | undefined) => dataService.deletePreset(preset),
...(options || {}), ...(options || {}),
}); });
}; };
/* login/logout */ /* login/logout */
export const useLogoutUserMutation = ( export const useLogoutUserMutation = (
options?: LogoutOptions, options?: t.LogoutOptions,
): UseMutationResult<unknown, unknown, undefined, unknown> => { ): UseMutationResult<unknown, unknown, undefined, unknown> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setDefaultPreset = useSetRecoilState(store.defaultPreset); const setDefaultPreset = useSetRecoilState(store.defaultPreset);
@ -362,9 +365,9 @@ export const useLogoutUserMutation = (
/* Avatar upload */ /* Avatar upload */
export const useUploadAvatarMutation = ( export const useUploadAvatarMutation = (
options?: UploadAvatarOptions, options?: t.UploadAvatarOptions,
): UseMutationResult< ): UseMutationResult<
AvatarUploadResponse, // response data t.AvatarUploadResponse, // response data
unknown, // error unknown, // error
FormData, // request FormData, // request
unknown // context unknown // context
@ -383,16 +386,16 @@ export const useUploadAvatarMutation = (
* Create a new assistant * Create a new assistant
*/ */
export const useCreateAssistantMutation = ( export const useCreateAssistantMutation = (
options?: CreateAssistantMutationOptions, options?: t.CreateAssistantMutationOptions,
): UseMutationResult<Assistant, Error, AssistantCreateParams> => { ): UseMutationResult<t.Assistant, Error, t.AssistantCreateParams> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation( return useMutation(
(newAssistantData: AssistantCreateParams) => dataService.createAssistant(newAssistantData), (newAssistantData: t.AssistantCreateParams) => dataService.createAssistant(newAssistantData),
{ {
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (newAssistant, variables, context) => { onSuccess: (newAssistant, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([ const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants, QueryKeys.assistants,
defaultOrderQuery, defaultOrderQuery,
]); ]);
@ -403,10 +406,13 @@ export const useCreateAssistantMutation = (
const currentAssistants = [newAssistant, ...JSON.parse(JSON.stringify(listRes.data))]; const currentAssistants = [newAssistant, ...JSON.parse(JSON.stringify(listRes.data))];
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], { queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
{
...listRes, ...listRes,
data: currentAssistants, data: currentAssistants,
}); },
);
return options?.onSuccess?.(newAssistant, variables, context); return options?.onSuccess?.(newAssistant, variables, context);
}, },
}, },
@ -417,17 +423,21 @@ export const useCreateAssistantMutation = (
* Hook for updating an assistant * Hook for updating an assistant
*/ */
export const useUpdateAssistantMutation = ( export const useUpdateAssistantMutation = (
options?: UpdateAssistantMutationOptions, options?: t.UpdateAssistantMutationOptions,
): UseMutationResult<Assistant, Error, { assistant_id: string; data: AssistantUpdateParams }> => { ): UseMutationResult<
t.Assistant,
Error,
{ assistant_id: string; data: t.AssistantUpdateParams }
> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation( return useMutation(
({ assistant_id, data }: { assistant_id: string; data: AssistantUpdateParams }) => ({ assistant_id, data }: { assistant_id: string; data: t.AssistantUpdateParams }) =>
dataService.updateAssistant(assistant_id, data), dataService.updateAssistant(assistant_id, data),
{ {
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (updatedAssistant, variables, context) => { onSuccess: (updatedAssistant, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([ const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants, QueryKeys.assistants,
defaultOrderQuery, defaultOrderQuery,
]); ]);
@ -436,7 +446,9 @@ export const useUpdateAssistantMutation = (
return options?.onSuccess?.(updatedAssistant, variables, context); return options?.onSuccess?.(updatedAssistant, variables, context);
} }
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], { queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
{
...listRes, ...listRes,
data: listRes.data.map((assistant) => { data: listRes.data.map((assistant) => {
if (assistant.id === variables.assistant_id) { if (assistant.id === variables.assistant_id) {
@ -444,7 +456,8 @@ export const useUpdateAssistantMutation = (
} }
return assistant; return assistant;
}), }),
}); },
);
return options?.onSuccess?.(updatedAssistant, variables, context); return options?.onSuccess?.(updatedAssistant, variables, context);
}, },
}, },
@ -455,17 +468,17 @@ export const useUpdateAssistantMutation = (
* Hook for deleting an assistant * Hook for deleting an assistant
*/ */
export const useDeleteAssistantMutation = ( export const useDeleteAssistantMutation = (
options?: DeleteAssistantMutationOptions, options?: t.DeleteAssistantMutationOptions,
): UseMutationResult<void, Error, DeleteAssistantBody> => { ): UseMutationResult<void, Error, t.DeleteAssistantBody> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation( return useMutation(
({ assistant_id, model }: DeleteAssistantBody) => ({ assistant_id, model }: t.DeleteAssistantBody) =>
dataService.deleteAssistant(assistant_id, model), dataService.deleteAssistant(assistant_id, model),
{ {
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (_data, variables, context) => { onSuccess: (_data, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([ const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants, QueryKeys.assistants,
defaultOrderQuery, defaultOrderQuery,
]); ]);
@ -476,10 +489,13 @@ export const useDeleteAssistantMutation = (
const data = listRes.data.filter((assistant) => assistant.id !== variables.assistant_id); const data = listRes.data.filter((assistant) => assistant.id !== variables.assistant_id);
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], { queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
{
...listRes, ...listRes,
data, data,
}); },
);
return options?.onSuccess?.(_data, variables, data); return options?.onSuccess?.(_data, variables, data);
}, },
@ -491,16 +507,16 @@ export const useDeleteAssistantMutation = (
* Hook for uploading an assistant avatar * Hook for uploading an assistant avatar
*/ */
export const useUploadAssistantAvatarMutation = ( export const useUploadAssistantAvatarMutation = (
options?: UploadAssistantAvatarOptions, options?: t.UploadAssistantAvatarOptions,
): UseMutationResult< ): UseMutationResult<
Assistant, // response data t.Assistant, // response data
unknown, // error unknown, // error
AssistantAvatarVariables, // request t.AssistantAvatarVariables, // request
unknown // context unknown // context
> => { > => {
return useMutation([MutationKeys.assistantAvatarUpload], { return useMutation([MutationKeys.assistantAvatarUpload], {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
mutationFn: ({ postCreation, ...variables }: AssistantAvatarVariables) => mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) =>
dataService.uploadAssistantAvatar(variables), dataService.uploadAssistantAvatar(variables),
...(options || {}), ...(options || {}),
}); });
@ -510,21 +526,21 @@ export const useUploadAssistantAvatarMutation = (
* Hook for updating Assistant Actions * Hook for updating Assistant Actions
*/ */
export const useUpdateAction = ( export const useUpdateAction = (
options?: UpdateActionOptions, options?: t.UpdateActionOptions,
): UseMutationResult< ): UseMutationResult<
UpdateActionResponse, // response data t.UpdateActionResponse, // response data
unknown, // error unknown, // error
UpdateActionVariables, // request t.UpdateActionVariables, // request
unknown // context unknown // context
> => { > => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation([MutationKeys.updateAction], { return useMutation([MutationKeys.updateAction], {
mutationFn: (variables: UpdateActionVariables) => dataService.updateAction(variables), mutationFn: (variables: t.UpdateActionVariables) => dataService.updateAction(variables),
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (updateActionResponse, variables, context) => { onSuccess: (updateActionResponse, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([ const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants, QueryKeys.assistants,
defaultOrderQuery, defaultOrderQuery,
]); ]);
@ -535,7 +551,7 @@ export const useUpdateAction = (
const updatedAssistant = updateActionResponse[1]; const updatedAssistant = updateActionResponse[1];
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], { queryClient.setQueryData<t.AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], {
...listRes, ...listRes,
data: listRes.data.map((assistant) => { data: listRes.data.map((assistant) => {
if (assistant.id === variables.assistant_id) { if (assistant.id === variables.assistant_id) {
@ -545,7 +561,7 @@ export const useUpdateAction = (
}), }),
}); });
queryClient.setQueryData<Action[]>([QueryKeys.actions], (prev) => { queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
return prev return prev
?.map((action) => { ?.map((action) => {
if (action.action_id === variables.action_id) { if (action.action_id === variables.action_id) {
@ -565,30 +581,30 @@ export const useUpdateAction = (
* Hook for deleting an Assistant Action * Hook for deleting an Assistant Action
*/ */
export const useDeleteAction = ( export const useDeleteAction = (
options?: DeleteActionOptions, options?: t.DeleteActionOptions,
): UseMutationResult< ): UseMutationResult<
void, // response data for a delete operation is typically void void, // response data for a delete operation is typically void
Error, // error type Error, // error type
DeleteActionVariables, // request variables t.DeleteActionVariables, // request variables
unknown // context unknown // context
> => { > => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation([MutationKeys.deleteAction], { return useMutation([MutationKeys.deleteAction], {
mutationFn: (variables: DeleteActionVariables) => mutationFn: (variables: t.DeleteActionVariables) =>
dataService.deleteAction(variables.assistant_id, variables.action_id, variables.model), dataService.deleteAction(variables.assistant_id, variables.action_id, variables.model),
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (_data, variables, context) => { onSuccess: (_data, variables, context) => {
let domain: string | undefined = ''; let domain: string | undefined = '';
queryClient.setQueryData<Action[]>([QueryKeys.actions], (prev) => { queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
return prev?.filter((action) => { return prev?.filter((action) => {
domain = action.metadata.domain; domain = action.metadata.domain;
return action.action_id !== variables.action_id; return action.action_id !== variables.action_id;
}); });
}); });
queryClient.setQueryData<AssistantListResponse>( queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery], [QueryKeys.assistants, defaultOrderQuery],
(prev) => { (prev) => {
if (!prev) { if (!prev) {

View file

@ -58,7 +58,10 @@ export default function useGenerationsByLatest({
!branchingSupported || !branchingSupported ||
(!isEditableEndpoint && !isCreatedByUser); (!isEditableEndpoint && !isCreatedByUser);
const forkingSupported = endpoint !== EModelEndpoint.assistants && !searchResult;
return { return {
forkingSupported,
continueSupported, continueSupported,
regenerateEnabled, regenerateEnabled,
hideEditButton, hideEditButton,

View file

@ -125,6 +125,35 @@ export default {
com_user_message: 'You', com_user_message: 'You',
com_ui_copy_to_clipboard: 'Copy to clipboard', com_ui_copy_to_clipboard: 'Copy to clipboard',
com_ui_copied_to_clipboard: 'Copied to clipboard', com_ui_copied_to_clipboard: 'Copied to clipboard',
com_ui_fork_info_1: 'Use this setting to fork messages with the desired behavior.',
com_ui_fork_info_2:
'"Forking" refers to creating a new conversation that start/end from specific messages in the current conversation, creating a copy according to the options selected.',
com_ui_fork_info_3:
'The "target message" refers to either the message this popup was opened from, or, if you check "{0}", the latest message in the conversation.',
com_ui_fork_info_visible:
'This option forks only the visible messages; in other words, the direct path to the target message, without any branches.',
com_ui_fork_info_branches:
'This option forks the visible messages, along with related branches; in other words, the direct path to the target message, including branches along the path.',
com_ui_fork_info_target:
'This option forks all messages leading up to the target message, including its neighbors; in other words, all message branches, whether or not they are visible or along the same path, are included.',
com_ui_fork_info_start:
'If checked, forking will commence from this message to the latest message in the conversation, according to the behavior selected above.',
com_ui_fork_info_remember:
'Check this to remember the options you select for future usage, making it quicker to fork conversations as preferred.',
com_ui_fork_success: 'Successfully forked conversation',
com_ui_fork_processing: 'Forking conversation...',
com_ui_fork_error: 'There was an error forking the conversation',
com_ui_fork_change_default: 'Change default fork option',
com_ui_fork_default: 'Use default fork option',
com_ui_fork_remember: 'Remember',
com_ui_fork_split_target_setting: 'Start fork from target message by default',
com_ui_fork_split_target: 'Start fork here',
com_ui_fork_remember_checked:
'Your selection will be remembered after usage. Change this at any time in the settings.',
com_ui_fork_all_target: 'Include all to/from here',
com_ui_fork_branches: 'Include related branches',
com_ui_fork_visible: 'Visible messages only',
com_ui_fork_from_message: 'Select a fork option',
com_ui_regenerate: 'Regenerate', com_ui_regenerate: 'Regenerate',
com_ui_continue: 'Continue', com_ui_continue: 'Continue',
com_ui_edit: 'Edit', com_ui_edit: 'Edit',
@ -232,6 +261,7 @@ export default {
'WARNING: Misuse of this feature can get you BANNED from using Bing! Click on \'System Message\' for full instructions and the default message if omitted, which is the \'Sydney\' preset that is considered safe.', 'WARNING: Misuse of this feature can get you BANNED from using Bing! Click on \'System Message\' for full instructions and the default message if omitted, which is the \'Sydney\' preset that is considered safe.',
com_endpoint_system_message: 'System Message', com_endpoint_system_message: 'System Message',
com_endpoint_message: 'Message', com_endpoint_message: 'Message',
com_endpoint_messages: 'Messages',
com_endpoint_message_not_appendable: 'Edit your message or Regenerate.', com_endpoint_message_not_appendable: 'Edit your message or Regenerate.',
com_endpoint_default_blank: 'default: blank', com_endpoint_default_blank: 'default: blank',
com_endpoint_default_false: 'default: false', com_endpoint_default_false: 'default: false',

View file

@ -1,5 +1,5 @@
import { atom } from 'recoil'; import { atom } from 'recoil';
import { SettingsViews } from 'librechat-data-provider'; import { SettingsViews, LocalStorageKeys } from 'librechat-data-provider';
import type { TOptionSettings } from '~/common'; import type { TOptionSettings } from '~/common';
const abortScroll = atom<boolean>({ const abortScroll = atom<boolean>({
@ -137,6 +137,63 @@ const LaTeXParsing = atom<boolean>({
] as const, ] as const,
}); });
const forkSetting = atom<string>({
key: LocalStorageKeys.FORK_SETTING,
default: '',
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(LocalStorageKeys.FORK_SETTING);
if (savedValue != null) {
setSelf(savedValue);
}
onSet((newValue: unknown) => {
if (typeof newValue === 'string') {
localStorage.setItem(LocalStorageKeys.FORK_SETTING, newValue.toString());
}
});
},
] as const,
});
const rememberForkOption = atom<boolean>({
key: LocalStorageKeys.REMEMBER_FORK_OPTION,
default: false,
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(LocalStorageKeys.REMEMBER_FORK_OPTION);
if (savedValue != null) {
setSelf(savedValue === 'true');
}
onSet((newValue: unknown) => {
if (typeof newValue === 'boolean') {
localStorage.setItem(LocalStorageKeys.REMEMBER_FORK_OPTION, newValue.toString());
}
});
},
] as const,
});
const splitAtTarget = atom<boolean>({
key: LocalStorageKeys.FORK_SPLIT_AT_TARGET,
default: false,
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(LocalStorageKeys.FORK_SPLIT_AT_TARGET);
if (savedValue != null) {
setSelf(savedValue === 'true');
}
onSet((newValue: unknown) => {
if (typeof newValue === 'boolean') {
localStorage.setItem(LocalStorageKeys.FORK_SPLIT_AT_TARGET, newValue.toString());
}
});
},
] as const,
});
const UsernameDisplay = atom<boolean>({ const UsernameDisplay = atom<boolean>({
key: 'UsernameDisplay', key: 'UsernameDisplay',
default: localStorage.getItem('UsernameDisplay') === 'true', default: localStorage.getItem('UsernameDisplay') === 'true',
@ -191,4 +248,7 @@ export default {
modularChat, modularChat,
LaTeXParsing, LaTeXParsing,
UsernameDisplay, UsernameDisplay,
forkSetting,
splitAtTarget,
rememberForkOption,
}; };

View file

@ -34,6 +34,8 @@ export const deleteConversation = () => `${conversationsRoot}/clear`;
export const importConversation = () => `${conversationsRoot}/import`; export const importConversation = () => `${conversationsRoot}/import`;
export const forkConversation = () => `${conversationsRoot}/fork`;
export const importConversationJobStatus = (jobId: string) => export const importConversationJobStatus = (jobId: string) =>
`${conversationsRoot}/import/jobs/${jobId}`; `${conversationsRoot}/import/jobs/${jobId}`;

View file

@ -653,6 +653,10 @@ export enum SettingsTabValues {
* Tab for General Settings * Tab for General Settings
*/ */
GENERAL = 'general', GENERAL = 'general',
/**
* Tab for Messages Settings
*/
MESSAGES = 'messages',
/** /**
* Tab for Beta Features * Tab for Beta Features
*/ */
@ -698,6 +702,21 @@ export enum LocalStorageKeys {
FILES_TO_DELETE = 'filesToDelete', FILES_TO_DELETE = 'filesToDelete',
/** Prefix key for the last selected assistant ID by index */ /** Prefix key for the last selected assistant ID by index */
ASST_ID_PREFIX = 'assistant_id__', ASST_ID_PREFIX = 'assistant_id__',
/** Key for the last selected fork setting */
FORK_SETTING = 'forkSetting',
/** Key for remembering the last selected option, instead of manually selecting */
REMEMBER_FORK_OPTION = 'rememberForkOption',
/** Key for remembering the split at target fork option modifier */
FORK_SPLIT_AT_TARGET = 'splitAtTarget',
}
export enum ForkOptions {
/** Key for direct path option */
DIRECT_PATH = 'directPath',
/** Key for including branches */
INCLUDE_BRANCHES = 'includeBranches',
/** Key for target level fork (default) */
TARGET_LEVEL = '',
} }
/** /**

View file

@ -261,6 +261,10 @@ export const deleteAction = async (
/* conversations */ /* conversations */
export function forkConversation(payload: t.TForkConvoRequest): Promise<t.TForkConvoResponse> {
return request.post(endpoints.forkConversation(), payload);
}
export function deleteConversation(payload: t.TDeleteConversationRequest) { export function deleteConversation(payload: t.TDeleteConversationRequest) {
//todo: this should be a DELETE request //todo: this should be a DELETE request
return request.post(endpoints.deleteConversation(), { arg: payload }); return request.post(endpoints.deleteConversation(), { arg: payload });

View file

@ -125,6 +125,19 @@ export type TDeleteConversationResponse = {
}; };
}; };
export type TForkConvoRequest = {
messageId: string;
conversationId: string;
option?: string;
splitAtTarget?: boolean;
latestMessageId?: string;
};
export type TForkConvoResponse = {
conversation: TConversation;
messages: TMessage[];
};
export type TSearchResults = { export type TSearchResults = {
conversations: TConversation[]; conversations: TConversation[];
messages: TMessage[]; messages: TMessage[];

View file

@ -1,4 +1,4 @@
import { TPreset, TDeleteConversationResponse, TDeleteConversationRequest } from '../types'; import type * as types from '../types';
import { import {
Assistant, Assistant,
AssistantCreateParams, AssistantCreateParams,
@ -9,6 +9,18 @@ import {
Action, Action,
} from './assistants'; } from './assistants';
export type MutationOptions<
Response,
Request,
Context = unknown,
Error = unknown,
Snapshot = void,
> = {
onSuccess?: (data: Response, variables: Request, context?: Context) => void;
onMutate?: (variables: Request) => Snapshot | Promise<Snapshot>;
onError?: (error: Error, variables: Request, context?: Context, snapshot?: Snapshot) => void;
};
export type TGenTitleRequest = { export type TGenTitleRequest = {
conversationId: string; conversationId: string;
}; };
@ -22,27 +34,11 @@ export type PresetDeleteResponse = {
deletedCount: number; deletedCount: number;
}; };
export type UpdatePresetOptions = { export type UpdatePresetOptions = MutationOptions<types.TPreset, types.TPreset>;
onSuccess?: (data: TPreset, variables: TPreset, context?: unknown) => void;
onMutate?: (variables: TPreset) => void | Promise<unknown>;
onError?: (error: unknown, variables: TPreset, context?: unknown) => void;
};
export type DeletePresetOptions = { export type DeletePresetOptions = MutationOptions<PresetDeleteResponse, types.TPreset | undefined>;
onSuccess?: (
data: PresetDeleteResponse,
variables: TPreset | undefined,
context?: unknown,
) => void;
onMutate?: (variables: TPreset | undefined) => void | Promise<unknown>;
onError?: (error: unknown, variables: TPreset | undefined, context?: unknown) => void;
};
export type LogoutOptions = { export type LogoutOptions = MutationOptions<unknown, undefined>;
onSuccess?: (data: unknown, variables: undefined, context?: unknown) => void;
onMutate?: (variables: undefined) => void | Promise<unknown>;
onError?: (error: unknown, variables: undefined, context?: unknown) => void;
};
export type AssistantAvatarVariables = { export type AssistantAvatarVariables = {
assistant_id: string; assistant_id: string;
@ -59,53 +55,26 @@ export type UpdateActionVariables = {
model: string; model: string;
}; };
export type UploadAssistantAvatarOptions = { export type UploadAssistantAvatarOptions = MutationOptions<Assistant, AssistantAvatarVariables>;
onSuccess?: (data: Assistant, variables: AssistantAvatarVariables, context?: unknown) => void;
onMutate?: (variables: AssistantAvatarVariables) => void | Promise<unknown>;
onError?: (error: unknown, variables: AssistantAvatarVariables, context?: unknown) => void;
};
export type CreateAssistantMutationOptions = { export type CreateAssistantMutationOptions = MutationOptions<Assistant, AssistantCreateParams>;
onSuccess?: (data: Assistant, variables: AssistantCreateParams, context?: unknown) => void;
onMutate?: (variables: AssistantCreateParams) => void | Promise<unknown>;
onError?: (error: unknown, variables: AssistantCreateParams, context?: unknown) => void;
};
export type UpdateAssistantMutationOptions = { export type UpdateAssistantVariables = {
onSuccess?: (
data: Assistant,
variables: { assistant_id: string; data: AssistantUpdateParams },
context?: unknown,
) => void;
onMutate?: (variables: {
assistant_id: string; assistant_id: string;
data: AssistantUpdateParams; data: AssistantUpdateParams;
}) => void | Promise<unknown>;
onError?: (
error: unknown,
variables: { assistant_id: string; data: AssistantUpdateParams },
context?: unknown,
) => void;
}; };
export type UpdateAssistantMutationOptions = MutationOptions<Assistant, UpdateAssistantVariables>;
export type DeleteAssistantBody = { assistant_id: string; model: string }; export type DeleteAssistantBody = { assistant_id: string; model: string };
export type DeleteAssistantMutationOptions = { export type DeleteAssistantMutationOptions = MutationOptions<
onSuccess?: (data: void, variables: { assistant_id: string }, context?: unknown) => void; void,
onMutate?: (variables: { assistant_id: string }) => void | Promise<unknown>; Pick<DeleteAssistantBody, 'assistant_id'>
onError?: (error: unknown, variables: { assistant_id: string }, context?: unknown) => void; >;
};
export type UpdateActionResponse = [AssistantDocument, Assistant, Action]; export type UpdateActionResponse = [AssistantDocument, Assistant, Action];
export type UpdateActionOptions = { export type UpdateActionOptions = MutationOptions<UpdateActionResponse, UpdateActionVariables>;
onSuccess?: (
data: UpdateActionResponse,
variables: UpdateActionVariables,
context?: unknown,
) => void;
onMutate?: (variables: UpdateActionVariables) => void | Promise<unknown>;
onError?: (error: unknown, variables: UpdateActionVariables, context?: unknown) => void;
};
export type DeleteActionVariables = { export type DeleteActionVariables = {
assistant_id: string; assistant_id: string;
@ -113,18 +82,11 @@ export type DeleteActionVariables = {
model: string; model: string;
}; };
export type DeleteActionOptions = { export type DeleteActionOptions = MutationOptions<void, DeleteActionVariables>;
onSuccess?: (data: void, variables: DeleteActionVariables, context?: unknown) => void;
onMutate?: (variables: DeleteActionVariables) => void | Promise<unknown>;
onError?: (error: unknown, variables: DeleteActionVariables, context?: unknown) => void;
};
export type DeleteConversationOptions = { export type DeleteConversationOptions = MutationOptions<
onSuccess?: ( types.TDeleteConversationResponse,
data: TDeleteConversationResponse, types.TDeleteConversationRequest
variables: TDeleteConversationRequest, >;
context?: unknown,
) => void; export type ForkConvoOptions = MutationOptions<types.TForkConvoResponse, types.TForkConvoRequest>;
onMutate?: (variables: TDeleteConversationRequest) => void | Promise<unknown>;
onError?: (error: unknown, variables: TDeleteConversationRequest, context?: unknown) => void;
};