mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🔍 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:
parent
164f146bdf
commit
ce704f5c11
4 changed files with 219 additions and 25 deletions
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue