🔍 fix: Include Message-Matched Conversations in Sidebar Search (#15483)

* 🔍 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>
This commit is contained in:
csdivad 2026-09-02 03:17:11 +02:00 • committed by GitHub
parent 164f146bdf
commit ce704f5c11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 219 additions and 25 deletions

View file

@ -26,6 +26,7 @@ jest.mock('~/config/winston', () => ({
debug: jest.fn(),
}));
const MEILI_SEARCH_LIMIT = 1000;
let mongoServer: InstanceType<typeof MongoMemoryServer>;
let Conversation: mongoose.Model<IConversation>;
let ChatProject: mongoose.Model<IChatProject>;
@ -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,
]);
});
});
});

View file

@ -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<MessageMethods, 'getMessages' | 'deleteMessages'> {
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<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation> &
Pick<SchemaWithMeiliMethods, 'meiliSearch'>;
const filters: FilterQuery<IConversation>[] = [{ user } as FilterQuery<IConversation>];
if (isArchived) {
filters.push({ isArchived: true } as FilterQuery<IConversation>);
@ -2624,23 +2638,43 @@ export function createConversationMethods(
if (search) {
try {
const meiliResults = await (
Conversation as unknown as {
meiliSearch: (
query: string,
options: Record<string, string>,
) => 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<string>();
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<IConversation>);
filters.push({
conversationId: { $in: [...matchingIds] },
} as FilterQuery<IConversation>);
} catch (error) {
logger.error('[getConvosByCursor] Error during meiliSearch', error);
throw new Error('Error during meiliSearch');

View file

@ -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

View file

@ -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<IMessage>,
searchOptions: SearchParams,
hydrate?: boolean,
): Promise<unknown>;
): Promise<Awaited<ReturnType<SchemaWithMeiliMethods['meiliSearch']>>>;
deleteMessages(filter: FilterQuery<IMessage>): Promise<DeleteResult>;
}
@ -3145,12 +3147,10 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
*/
async function searchMessages(
query: string,
searchOptions: Record<string, unknown>,
searchOptions: SearchParams,
hydrate?: boolean,
) {
const Message = mongoose.models.Message as Model<IMessage> & {
meiliSearch?: (q: string, opts: Record<string, unknown>, h?: boolean) => Promise<unknown>;
};
): Promise<Awaited<ReturnType<SchemaWithMeiliMethods['meiliSearch']>>> {
const Message = mongoose.models.Message as SchemaWithMeiliMethods;
if (typeof Message.meiliSearch !== 'function') {
throw new Error('MeiliSearch plugin not registered on Message model');
}