Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: nav optimization #5785

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5460466
✨ feat: improve Nav/Conversations/Convo/NewChat component performance
berry-13 Feb 9, 2025
f7ca209
✨ feat: implement cursor-based pagination for conversations API
berry-13 Feb 9, 2025
40bda47
πŸ”§ refactor: remove createdAt from conversation selection in API and t…
berry-13 Feb 9, 2025
ad56978
πŸ”§ refactor: include createdAt in conversation selection and update re…
berry-13 Feb 9, 2025
e8f56aa
✨ fix: search functionality and bugs with loadMoreConversations
berry-13 Feb 9, 2025
af600c4
feat: move ArchivedChats to cursor and DataTable standard
berry-13 Feb 10, 2025
f0e60c1
πŸ”§ refactor: add InfiniteQueryObserverResult type import in Nav component
berry-13 Feb 11, 2025
c6e76b9
feat: enhance conversation listing with pagination, sorting, and sear…
berry-13 Feb 16, 2025
a9d439b
πŸ”§ refactor: remove unnecessary comment regarding lodash/debounce in A…
berry-13 Feb 16, 2025
e760a92
πŸ”§ refactor: remove unused translation keys for archived chats and sea…
berry-13 Feb 16, 2025
878a7eb
πŸ”§ fix: Archived Chats, Delete Convo, Duplicate Convo
berry-13 Feb 18, 2025
caf05b9
πŸ”§ refactor: improve conversation components with layout adjustments a…
berry-13 Feb 19, 2025
cd7b28f
πŸ”§ refactor: simplify archive conversation mutation and improve unarch…
berry-13 Feb 19, 2025
0d3e02e
πŸ”§ refactor: decode search query parameter in conversation route; impr…
berry-13 Feb 19, 2025
0e80860
πŸ”§ refactor: remove unused translation key for empty archived chats
berry-13 Feb 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 96 additions & 53 deletions api/models/Conversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,13 @@ module.exports = {
*/
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
try {
if (metadata && metadata?.context) {
if (metadata?.context) {
logger.debug(`[saveConvo] ${metadata.context}`);
}

const messages = await getMessages({ conversationId }, '_id');
const update = { ...convo, messages, user: req.user.id };

if (newConversationId) {
update.conversationId = newConversationId;
}
Expand Down Expand Up @@ -141,75 +143,100 @@ module.exports = {
throw new Error('Failed to save conversations in bulk.');
}
},
getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false, tags) => {
const query = { user };
getConvosByCursor: async (
user,
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
) => {
const filters = [{ user }];

if (isArchived) {
query.isArchived = true;
filters.push({ isArchived: true });
} else {
query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }];
filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] });
}

if (Array.isArray(tags) && tags.length > 0) {
query.tags = { $in: tags };
filters.push({ tags: { $in: tags } });
}

query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }];
filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] });

if (search) {
try {
const meiliResults = await Conversation.meiliSearch(search);
const matchingIds = Array.isArray(meiliResults.hits)
? meiliResults.hits.map((result) => result.conversationId)
: [];
if (!matchingIds.length) {
return { conversations: [], nextCursor: null };
}
filters.push({ conversationId: { $in: matchingIds } });
} catch (error) {
logger.error('[getConvosByCursor] Error during meiliSearch', error);
return { message: 'Error during meiliSearch' };
}
}

if (cursor) {
filters.push({ updatedAt: { $lt: new Date(cursor) } });
}

const query = filters.length === 1 ? filters[0] : { $and: filters };

try {
const totalConvos = (await Conversation.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const convos = await Conversation.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('conversationId endpoint title createdAt updatedAt user')
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
.limit(limit + 1)
.lean();
return { conversations: convos, pages: totalPages, pageNumber, pageSize };

let nextCursor = null;
if (convos.length > limit) {
const lastConvo = convos.pop();
nextCursor = lastConvo.updatedAt.toISOString();
}

return { conversations: convos, nextCursor };
} catch (error) {
logger.error('[getConvosByPage] Error getting conversations', error);
logger.error('[getConvosByCursor] Error getting conversations', error);
return { message: 'Error getting conversations' };
}
},
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => {
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
try {
if (!convoIds || convoIds.length === 0) {
return { conversations: [], pages: 1, pageNumber, pageSize };
if (!convoIds?.length) {
return { conversations: [], nextCursor: null, convoMap: {} };
}

const cache = {};
const convoMap = {};
const promises = [];

convoIds.forEach((convo) =>
promises.push(
Conversation.findOne({
user,
conversationId: convo.conversationId,
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
}).lean(),
),
);
const conversationIds = convoIds.map((convo) => convo.conversationId);

const results = (await Promise.all(promises)).filter(Boolean);
const results = await Conversation.find({
user,
conversationId: { $in: conversationIds },
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
}).lean();

results.forEach((convo, i) => {
const page = Math.floor(i / pageSize) + 1;
if (!cache[page]) {
cache[page] = [];
}
cache[page].push(convo);
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));

let filtered = results;
if (cursor && cursor !== 'start') {
const cursorDate = new Date(cursor);
filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate);
}

const limited = filtered.slice(0, limit + 1);
let nextCursor = null;
if (limited.length > limit) {
const lastConvo = limited.pop();
nextCursor = lastConvo.updatedAt.toISOString();
}

const convoMap = {};
limited.forEach((convo) => {
convoMap[convo.conversationId] = convo;
});

const totalPages = Math.ceil(results.length / pageSize);
cache.pages = totalPages;
cache.pageSize = pageSize;
return {
cache,
conversations: cache[pageNumber] || [],
pages: totalPages || 1,
pageNumber,
pageSize,
convoMap,
};
return { conversations: limited, nextCursor, convoMap };
} catch (error) {
logger.error('[getConvosQueried] Error getting conversations', error);
return { message: 'Error fetching conversations' };
Expand Down Expand Up @@ -250,10 +277,26 @@ module.exports = {
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
*/
deleteConvos: async (user, filter) => {
let toRemove = await Conversation.find({ ...filter, user }).select('conversationId');
const ids = toRemove.map((instance) => instance.conversationId);
let deleteCount = await Conversation.deleteMany({ ...filter, user });
deleteCount.messages = await deleteMessages({ conversationId: { $in: ids } });
return deleteCount;
try {
const userFilter = { ...filter, user };

const conversations = await Conversation.find(userFilter).select('conversationId');
const conversationIds = conversations.map((c) => c.conversationId);

if (!conversationIds.length) {
throw new Error('Conversation not found or already deleted.');
}

const deleteConvoResult = await Conversation.deleteMany(userFilter);

const deleteMessagesResult = await deleteMessages({
conversationId: { $in: conversationIds },
});

return { ...deleteConvoResult, messages: deleteMessagesResult };
} catch (error) {
logger.error('[deleteConvos] Error deleting conversations and messages', error);
throw error;
}
},
};
71 changes: 44 additions & 27 deletions api/server/routes/convos.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
const multer = require('multer');
const express = require('express');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
const { storage, importFileFilter } = require('~/server/routes/files/multer');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { importConversations } = require('~/server/utils/import');
const { createImportLimiters } = require('~/server/middleware');
const { deleteToolCalls } = require('~/models/ToolCall');
const { isEnabled, sleep } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');

const assistantClients = {
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
[EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
Expand All @@ -20,28 +21,30 @@ const router = express.Router();
router.use(requireJwtAuth);

router.get('/', async (req, res) => {
let pageNumber = req.query.pageNumber || 1;
pageNumber = parseInt(pageNumber, 10);

if (isNaN(pageNumber) || pageNumber < 1) {
return res.status(400).json({ error: 'Invalid page number' });
}

let pageSize = req.query.pageSize || 25;
pageSize = parseInt(pageSize, 10);
const limit = parseInt(req.query.limit, 10) || 25;
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
const order = req.query.order || 'desc';

if (isNaN(pageSize) || pageSize < 1) {
return res.status(400).json({ error: 'Invalid page size' });
}
const isArchived = req.query.isArchived === 'true';
let tags;
if (req.query.tags) {
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
} else {
tags = undefined;
}

res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
try {
const result = await getConvosByCursor(req.user.id, {
cursor,
limit,
isArchived,
tags,
search,
order,
});
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: 'Error fetching conversations' });
}
});

router.get('/:conversationId', async (req, res) => {
Expand Down Expand Up @@ -76,22 +79,28 @@ router.post('/gen_title', async (req, res) => {
}
});

router.post('/clear', async (req, res) => {
router.delete('/', async (req, res) => {
let filter = {};
const { conversationId, source, thread_id, endpoint } = req.body.arg;
if (conversationId) {
filter = { conversationId };

// Prevent deletion of all conversations
if (!conversationId && !source && !thread_id && !endpoint) {
return res.status(400).json({
error: 'no parameters provided',
});
}

if (source === 'button' && !conversationId) {
if (conversationId) {
filter = { conversationId };
} else if (source === 'button') {
return res.status(200).send('No conversationId provided');
}

if (
typeof endpoint != 'undefined' &&
typeof endpoint !== 'undefined' &&
Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
) {
/** @type {{ openai: OpenAI}} */
/** @type {{ openai: OpenAI }} */
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
try {
const response = await openai.beta.threads.del(thread_id);
Expand All @@ -101,9 +110,6 @@ router.post('/clear', async (req, res) => {
}
}

// for debugging deletion source
// logger.debug('source:', source);

try {
const dbResponse = await deleteConvos(req.user.id, filter);
await deleteToolCalls(req.user.id, filter.conversationId);
Expand All @@ -114,6 +120,17 @@ router.post('/clear', async (req, res) => {
}
});

router.delete('/all', async (req, res) => {
try {
const dbResponse = await deleteConvos(req.user.id, {});
await deleteToolCalls(req.user.id);
res.status(201).json(dbResponse);
} catch (error) {
logger.error('Error clearing conversations', error);
res.status(500).send('Error clearing conversations');
}
});

router.post('/update', async (req, res) => {
const update = req.body.arg;

Expand Down
Loading
Loading