Skip to content

feat: nav optimization #5785

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

Merged
merged 63 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
143d15e
✨ feat: improve Nav/Conversations/Convo/NewChat component performance
berry-13 Feb 9, 2025
576ce91
✨ feat: implement cursor-based pagination for conversations API
berry-13 Feb 9, 2025
b02707f
🔧 refactor: remove createdAt from conversation selection in API and t…
berry-13 Feb 9, 2025
a5f100c
🔧 refactor: include createdAt in conversation selection and update re…
berry-13 Feb 9, 2025
57cff68
✨ fix: search functionality and bugs with loadMoreConversations
berry-13 Feb 9, 2025
bd97d2b
feat: move ArchivedChats to cursor and DataTable standard
berry-13 Feb 10, 2025
ace89f2
🔧 refactor: add InfiniteQueryObserverResult type import in Nav component
berry-13 Feb 11, 2025
5fa4225
feat: enhance conversation listing with pagination, sorting, and sear…
berry-13 Feb 16, 2025
24860ed
🔧 refactor: remove unnecessary comment regarding lodash/debounce in A…
berry-13 Feb 16, 2025
1b79810
🔧 refactor: remove unused translation keys for archived chats and sea…
berry-13 Feb 16, 2025
92e3494
🔧 fix: Archived Chats, Delete Convo, Duplicate Convo
berry-13 Feb 18, 2025
7b383a3
🔧 refactor: improve conversation components with layout adjustments a…
berry-13 Feb 19, 2025
618d7da
🔧 refactor: simplify archive conversation mutation and improve unarch…
berry-13 Feb 19, 2025
03e5b2c
🔧 refactor: decode search query parameter in conversation route; impr…
berry-13 Feb 19, 2025
28655e1
🔧 refactor: remove unused translation key for empty archived chats
berry-13 Feb 19, 2025
ddd289c
🚀 fix: `archivedConversation` query key not updated correctly while a…
berry-13 Mar 5, 2025
7f92952
🧠 feat: Bedrock Anthropic Reasoning & Update Endpoint Handling (#6163)
danny-avila Mar 4, 2025
e0202e7
✨ feat: improve Nav/Conversations/Convo/NewChat component performance
berry-13 Feb 9, 2025
1b77b7a
🔧 refactor: remove unnecessary comment regarding lodash/debounce in A…
berry-13 Feb 16, 2025
5465f6b
🔧 refactor: update translation keys for clarity; simplify conversatio…
berry-13 Mar 6, 2025
787cbe2
🔧 refactor: optimize conversation loading logic and improve search ha…
berry-13 Mar 25, 2025
9286dd5
fix: package-lock
berry-13 Mar 25, 2025
a23353f
fix: package-lock 2
berry-13 Mar 27, 2025
c2a82d8
fix: package lock 3
berry-13 Mar 27, 2025
1ec7f38
refactor: remove unused utility files and exports to clean up the cod…
berry-13 Mar 30, 2025
933a456
refactor: remove i18n and useAuthRedirect modules to streamline codebase
berry-13 Mar 30, 2025
3a05eeb
refactor: optimize Conversations component and remove unused ToggleCo…
berry-13 Mar 31, 2025
e58ccbd
refactor(Convo): add RenameForm and ConvoLink components; enhance Con…
berry-13 Apr 2, 2025
fdd895a
fix: add missing @azure/storage-blob dependency in package.json
berry-13 Apr 6, 2025
1386bc9
refactor(Search): add error handling with toast notification for sear…
berry-13 Apr 6, 2025
097c59e
refactor: make createdAt and updatedAt fields of tConvoUpdateSchema l…
danny-avila Apr 6, 2025
bf4025c
chore: update @azure/storage-blob dependency to version 12.27.0, ensu…
danny-avila Apr 6, 2025
a12b801
refactor(Search): improve conversation handling server side
berry-13 Apr 6, 2025
8e8bc06
fix: eslint warning and errors
berry-13 Apr 6, 2025
36c734c
refactor(Search): improved search loading state and overall UX
berry-13 Apr 11, 2025
f6f4a9a
Refactors conversation cache management
berry-13 Apr 12, 2025
2f10cb0
fix: conversation handling and SSE event processing
berry-13 Apr 12, 2025
a35680b
refactor: add type for SearchBar `onChange`
berry-13 Apr 12, 2025
29fffe5
fix: type tags
berry-13 Apr 12, 2025
e46c383
style: rounded to xl all Header buttons
berry-13 Apr 12, 2025
d3ef7df
fix: activeConvo in Convo not working
berry-13 Apr 12, 2025
12a7209
style(Bookmarks): improved UI
berry-13 Apr 12, 2025
5a837fb
a11y(AccountSettings): fixed hover style not visible when using light…
berry-13 Apr 12, 2025
1017c59
style(SettingsTabs): improved tab switchers and dropdowns
berry-13 Apr 12, 2025
0c8bbe2
feat: add translations keys for Speech
berry-13 Apr 12, 2025
6caf859
chore: fix package-lock
berry-13 Apr 12, 2025
7f07213
fix(mutations): legacy import after rebase
berry-13 Apr 12, 2025
837cac9
feat: refactor conversation navigation for accessibility
berry-13 Apr 13, 2025
1706488
fix(search): convo and message create/update date not returned
berry-13 Apr 13, 2025
3d30fa4
fix(search): show correct iconURL and endpoint for searched messages
berry-13 Apr 13, 2025
26ed6e7
fix: small UI improvements
berry-13 Apr 13, 2025
735a8ba
chore: console.log cleanup
berry-13 Apr 13, 2025
bab4e82
chore: fix tests
berry-13 Apr 13, 2025
abf7456
fix(ChatForm): improve conversation ID handling and clean up useMemo …
danny-avila Apr 15, 2025
4f0fc6a
chore: improve typing
danny-avila Apr 15, 2025
e454196
chore: improve typing
danny-avila Apr 15, 2025
45c6645
fix(useSSE): clear conversation ID on submission to prevent draft res…
danny-avila Apr 15, 2025
71fdec8
refactor(OpenAIClient): clean up abort handler
danny-avila Apr 15, 2025
65f115a
refactor(abortMiddleware): change handleAbort to use function expression
danny-avila Apr 15, 2025
27b73b5
feat: add PENDING_CONVO constant and update conversation ID checks
danny-avila Apr 15, 2025
0674d68
fix: final event handling on abort
danny-avila Apr 15, 2025
016d55f
fix: improve title sync and query cache sync on final event
danny-avila Apr 15, 2025
54ca173
fix: prevent overwriting cached conversation data if it already exists
danny-avila Apr 15, 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
5 changes: 5 additions & 0 deletions api/app/clients/OpenAIClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,11 @@ ${convo}
});
}

if (openai.abortHandler && abortController.signal) {
abortController.signal.removeEventListener('abort', openai.abortHandler);
openai.abortHandler = undefined;
}

if (!chatCompletion && UnexpectedRoleError) {
throw new Error(
'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
Expand Down
149 changes: 96 additions & 53 deletions api/models/Conversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,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 @@ -148,75 +150,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 @@ -257,10 +284,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;
}
},
};
4 changes: 2 additions & 2 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@azure/identity": "^4.7.0",
"@azure/search-documents": "^12.0.0",
"@azure/storage-blob": "^12.26.0",
"@azure/storage-blob": "^12.27.0",
"@google/generative-ai": "^0.23.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
Expand All @@ -48,7 +48,7 @@
"@langchain/google-genai": "^0.2.2",
"@langchain/google-vertexai": "^0.2.3",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.17",
"@librechat/agents": "^2.4.20",
"@librechat/data-schemas": "*",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
Expand Down
4 changes: 2 additions & 2 deletions api/server/middleware/abortMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ async function abortMessage(req, res) {
res.send(JSON.stringify(finalEvent));
}

const handleAbort = () => {
return async (req, res) => {
const handleAbort = function () {
return async function (req, res) {
try {
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
await clearPendingReq({ userId: req.user.id });
Expand Down
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