From ce704f5c1102e5d6dfd8d45d425e2ef46a97b66c Mon Sep 17 00:00:00 2001 From: csdivad Date: Wed, 2 Sep 2026 03:17:11 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=8D=20fix:=20Include=20Message-Matched?= =?UTF-8?q?=20Conversations=20in=20Sidebar=20Search=20(#15483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔍 fix: Include Message-Matched Conversations in Sidebar Search - Union conversation and message index matches in getConvosByCursor - Type searchMessages with SearchParams and SchemaWithMeiliMethods - Inject searchMessages into createConversationMethods - Bound sidebar Meili queries with 1000 limit and attributesToRetrieve - Fall back to title-only matches when message search fails or is uninjected - Escape filter user values in search queries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(search): preserve original conversation keys in sidebar hits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * 🔍 fix: reuse the loaded conversation model Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * 🔍 fix: type the loaded conversation search model Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/methods/conversation.spec.ts | 161 +++++++++++++++++- .../data-schemas/src/methods/conversation.ts | 68 ++++++-- packages/data-schemas/src/methods/index.ts | 1 + packages/data-schemas/src/methods/message.ts | 14 +- 4 files changed, 219 insertions(+), 25 deletions(-) diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index ead08308e5..9ef5807a2f 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -26,6 +26,7 @@ jest.mock('~/config/winston', () => ({ debug: jest.fn(), })); +const MEILI_SEARCH_LIMIT = 1000; let mongoServer: InstanceType; let Conversation: mongoose.Model; let ChatProject: mongoose.Model; @@ -40,6 +41,7 @@ let modelsToCleanup: string[] = []; // Mock message methods (same as original test mocking ./Message) const getMessages = jest.fn().mockResolvedValue([]); const deleteMessages = jest.fn().mockResolvedValue({ deletedCount: 0 }); +const searchMessages = jest.fn().mockResolvedValue({ hits: [] }); let methods: ConversationMethods; @@ -59,7 +61,7 @@ beforeAll(async () => { position: number; }>; - methods = createConversationMethods(mongoose, { getMessages, deleteMessages }); + methods = createConversationMethods(mongoose, { getMessages, deleteMessages, searchMessages }); await mongoose.connect(mongoUri); }); @@ -138,6 +140,7 @@ describe('Conversation Operations', () => { jest.clearAllMocks(); getMessages.mockResolvedValue([]); deleteMessages.mockResolvedValue({ deletedCount: 0 }); + searchMessages.mockResolvedValue({ hits: [] }); mockCtx = { userId: 'user123', @@ -6558,4 +6561,160 @@ describe('Conversation Operations', () => { expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(createdAt.toISOString()); }); }); + + describe('getConvosByCursor search', () => { + it('should include conversations matched only by message content', async () => { + const titleMatch = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Contains keyword', + endpoint: EModelEndpoint.openAI, + }); + const contentMatch = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Unrelated title', + endpoint: EModelEndpoint.openAI, + }); + await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'No match anywhere', + endpoint: EModelEndpoint.openAI, + }); + + const meiliSearch = jest + .fn() + .mockResolvedValue({ hits: [{ conversationId: titleMatch.conversationId }] }); + Object.assign(Conversation, { meiliSearch }); + searchMessages.mockResolvedValue({ + hits: [{ conversationId: contentMatch.conversationId }], + }); + + const result = await getConvosByCursor('user123', { search: 'keyword' }); + + const searchParams = { + filter: 'user = "user123"', + limit: MEILI_SEARCH_LIMIT, + attributesToRetrieve: ['conversationId', 'originalConversationId'], + }; + expect(meiliSearch).toHaveBeenCalledWith('keyword', searchParams); + expect(searchMessages).toHaveBeenCalledWith('keyword', searchParams); + const convoIds = result?.conversations.map((c) => c.conversationId); + expect(convoIds).toHaveLength(2); + expect(convoIds).toContain(titleMatch.conversationId); + expect(convoIds).toContain(contentMatch.conversationId); + }); + + it('should dedupe conversations matched by both title and message search', async () => { + const overlapMatch = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Contains keyword', + endpoint: EModelEndpoint.openAI, + }); + const titleOnlyMatch = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Keyword in title', + endpoint: EModelEndpoint.openAI, + }); + const messageOnlyMatch = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Unrelated title', + endpoint: EModelEndpoint.openAI, + }); + + const meiliSearch = jest.fn().mockResolvedValue({ + hits: [ + { conversationId: overlapMatch.conversationId }, + { conversationId: titleOnlyMatch.conversationId }, + ], + }); + Object.assign(Conversation, { meiliSearch }); + searchMessages.mockResolvedValue({ + hits: [ + { conversationId: overlapMatch.conversationId }, + { conversationId: messageOnlyMatch.conversationId }, + ], + }); + + const result = await getConvosByCursor('user123', { search: 'keyword' }); + + const searchParams = { + filter: 'user = "user123"', + limit: MEILI_SEARCH_LIMIT, + attributesToRetrieve: ['conversationId', 'originalConversationId'], + }; + expect(meiliSearch).toHaveBeenCalledWith('keyword', searchParams); + expect(searchMessages).toHaveBeenCalledWith('keyword', searchParams); + const convoIds = result?.conversations.map((c) => c.conversationId); + expect(convoIds).toHaveLength(3); + expect(convoIds).toContain(overlapMatch.conversationId); + expect(convoIds).toContain(titleOnlyMatch.conversationId); + expect(convoIds).toContain(messageOnlyMatch.conversationId); + }); + + it('should return an empty result when neither titles nor messages match', async () => { + await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'No match anywhere', + endpoint: EModelEndpoint.openAI, + }); + + Object.assign(Conversation, { meiliSearch: jest.fn().mockResolvedValue({ hits: [] }) }); + searchMessages.mockResolvedValue({ hits: [] }); + + const result = await getConvosByCursor('user123', { search: 'keyword' }); + + expect(result?.conversations).toHaveLength(0); + expect(result?.nextCursor).toBeNull(); + }); + + it('should fall back to title matches when the message index search fails', async () => { + const titleMatch = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Contains keyword', + endpoint: EModelEndpoint.openAI, + }); + + Object.assign(Conversation, { + meiliSearch: jest + .fn() + .mockResolvedValue({ hits: [{ conversationId: titleMatch.conversationId }] }), + }); + searchMessages.mockRejectedValue(new Error('MeiliSearch plugin not registered')); + + const result = await getConvosByCursor('user123', { search: 'keyword' }); + + expect(result?.conversations.map((c) => c.conversationId)).toEqual([ + titleMatch.conversationId, + ]); + }); + + it('should search titles when message methods are not injected', async () => { + const titleMatch = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Contains keyword', + endpoint: EModelEndpoint.openAI, + }); + + Object.assign(Conversation, { + meiliSearch: jest + .fn() + .mockResolvedValue({ hits: [{ conversationId: titleMatch.conversationId }] }), + }); + + const scopedMethods = createConversationMethods(mongoose); + const result = await scopedMethods.getConvosByCursor('user123', { search: 'keyword' }); + + expect(result?.conversations.map((c) => c.conversationId)).toEqual([ + titleMatch.conversationId, + ]); + }); + }); }); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index ee598bc2a8..38b29f5828 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -1,7 +1,14 @@ import { Buffer } from 'node:buffer'; import { RetentionMode } from 'librechat-data-provider'; -import type { AnyBulkWriteOperation, FilterQuery, Model, SortOrder, Types } from 'mongoose'; -import type { DeleteResult } from 'mongoose'; +import type { + AnyBulkWriteOperation, + DeleteResult, + FilterQuery, + Model, + SortOrder, + Types, +} from 'mongoose'; +import type { SearchParams } from 'meilisearch'; import type { IAgentEventActorCheckpoint, IAgentEventActorReconciliation, @@ -17,6 +24,7 @@ import type { ISharedLink, ISubagentThreadReservation, } from '~/types'; +import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type { MessageMethods } from './message'; import { MAX_AGENT_EVENT_ACTOR_DISCOVERED_TOOLS, @@ -43,6 +51,10 @@ import logger from '~/config/winston'; const AGENT_EVENT_ACTOR_RECEIPT_RETENTION_MS = 90 * 24 * 60 * 60_000; const MAX_AGENT_EVENT_ACTOR_SUSPENSION_BYTES = 64 * 1_024; +/** MeiliSearch's default `pagination.maxTotalHits` ceiling. */ +const MEILI_SEARCH_LIMIT = 1000; +const escapeMeiliFilterValue = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); function validateAgentEventActorSuspension( conversationId: string, @@ -468,6 +480,7 @@ export interface ConversationMethods { export interface ConversationMethodDeps extends Pick { + searchMessages?: MessageMethods['searchMessages']; deleteAgentQueuedTurns?: ( user: string, conversations: Array<{ conversationId: string; tenantId?: string; allTenants?: true }>, @@ -2593,7 +2606,8 @@ export function createConversationMethods( projectId?: string; } = {}, ) { - const Conversation = mongoose.models.Conversation as Model; + const Conversation = mongoose.models.Conversation as Model & + Pick; const filters: FilterQuery[] = [{ user } as FilterQuery]; if (isArchived) { filters.push({ isArchived: true } as FilterQuery); @@ -2624,23 +2638,43 @@ export function createConversationMethods( if (search) { try { - const meiliResults = await ( - Conversation as unknown as { - meiliSearch: ( - query: string, - options: Record, - ) => Promise<{ - hits: Array<{ conversationId: string }>; - }>; + const searchParams: SearchParams = { + filter: `user = "${escapeMeiliFilterValue(user)}"`, + limit: MEILI_SEARCH_LIMIT, + attributesToRetrieve: ['conversationId', 'originalConversationId'], + }; + const [convoResults, messageHits] = await Promise.all([ + Conversation.meiliSearch(search, searchParams), + deps?.searchMessages + ? deps.searchMessages(search, searchParams).then( + (results) => (Array.isArray(results.hits) ? results.hits : []), + (error) => { + logger.error( + '[getConvosByCursor] Message search failed, using title matches only', + error, + ); + return []; + }, + ) + : [], + ]); + const matchingIds = new Set(); + for (const hit of convoResults.hits ?? []) { + if (typeof hit.conversationId === 'string') { + matchingIds.add(hit.conversationId); } - ).meiliSearch(search, { filter: `user = "${user}"` }); - const matchingIds = Array.isArray(meiliResults.hits) - ? meiliResults.hits.map((result) => result.conversationId) - : []; - if (!matchingIds.length) { + } + for (const hit of messageHits) { + if (typeof hit.conversationId === 'string') { + matchingIds.add(hit.conversationId); + } + } + if (!matchingIds.size) { return { conversations: [], nextCursor: null }; } - filters.push({ conversationId: { $in: matchingIds } } as FilterQuery); + filters.push({ + conversationId: { $in: [...matchingIds] }, + } as FilterQuery); } catch (error) { logger.error('[getConvosByCursor] Error during meiliSearch', error); throw new Error('Error during meiliSearch'); diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index b6cc67b312..c1bf4e35e3 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -307,6 +307,7 @@ export function createMethods( const conversationMethods = createConversationMethods(mongoose, { getMessages: messageMethods.getMessages, deleteMessages: messageMethods.deleteMessages, + searchMessages: messageMethods.searchMessages, deleteAgentQueuedTurns: async (user, conversations) => { /** Queued-turn ownership is ObjectId-backed. Conversation methods also * support synthetic/non-ObjectId owners in embedded integrations and diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 2806858847..26ea1194b8 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -1,6 +1,8 @@ import { HITL_MESSAGE_FILTER_FIELDS, RetentionMode } from 'librechat-data-provider'; import type { UserSubmittedMessageFieldPath } from 'librechat-data-provider'; import type { DeleteResult, FilterQuery, Model, Types } from 'mongoose'; +import type { SearchParams } from 'meilisearch'; +import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type { AppConfig, IConversation, IMessage } from '~/types'; import { activeExpirationFilter, createFallbackRetentionDate } from '~/utils/retention'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; @@ -678,9 +680,9 @@ export interface MessageMethods { ): Promise<{ messages: IMessage[]; nextCursor: string | null }>; searchMessages( query: string, - searchOptions: Partial, + searchOptions: SearchParams, hydrate?: boolean, - ): Promise; + ): Promise>>; deleteMessages(filter: FilterQuery): Promise; } @@ -3145,12 +3147,10 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa */ async function searchMessages( query: string, - searchOptions: Record, + searchOptions: SearchParams, hydrate?: boolean, - ) { - const Message = mongoose.models.Message as Model & { - meiliSearch?: (q: string, opts: Record, h?: boolean) => Promise; - }; + ): Promise>> { + const Message = mongoose.models.Message as SchemaWithMeiliMethods; if (typeof Message.meiliSearch !== 'function') { throw new Error('MeiliSearch plugin not registered on Message model'); }