🐛 fix(cmdk): scope topic/message search to current agent (#13960)

Previously `agentId` was only used to boost relevance in SearchRepo,
so results from other agents still leaked into CMD+K when scoped to
an agent. Strictly filter topics/messages by `agentId` when provided,
and surface the active agent (avatar + title) as the scope chip so
users can see what the search is limited to.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-18 22:41:32 +08:00 committed by GitHub
parent e990b08cc6
commit bc9164ae4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 75 additions and 84 deletions

View file

@ -539,7 +539,7 @@ describe.skipIf(!isServerDB)('SearchRepo', () => {
]);
});
it('should boost current agent topics in relevance', async () => {
it('should only return topics of the current agent when agentId is provided', async () => {
const results = await searchRepo.search({
agentId: testAgentId,
query: 'testing',
@ -547,46 +547,24 @@ describe.skipIf(!isServerDB)('SearchRepo', () => {
const topicResults = results.filter((r) => r.type === 'topic');
// Current agent's topics should have better relevance (0.5-0.7)
const currentAgentTopics = topicResults.filter(
(t) => t.type === 'topic' && t.agentId === testAgentId,
);
const otherTopics = topicResults.filter(
(t) => t.type === 'topic' && t.agentId !== testAgentId,
);
expect(currentAgentTopics.length).toBeGreaterThan(0);
expect(otherTopics.length).toBeGreaterThan(0);
// Current agent topics should have lower relevance scores (higher priority)
currentAgentTopics.forEach((topic) => {
expect(topic.relevance).toBeLessThan(1);
});
otherTopics.forEach((topic) => {
expect(topic.relevance).toBeGreaterThanOrEqual(1);
expect(topicResults.length).toBeGreaterThan(0);
topicResults.forEach((topic) => {
if (topic.type === 'topic') {
expect(topic.agentId).toBe(testAgentId);
}
});
});
it('should show all user topics but rank current agent topics first', async () => {
it('should include topics from all agents when agentId is not provided', async () => {
const results = await searchRepo.search({
agentId: testAgentId,
query: 'testing',
});
const topicResults = results.filter((r) => r.type === 'topic');
// Should include topics from all agents (current, other, and no agent)
const agentIds = new Set(topicResults.map((t) => (t.type === 'topic' ? t.agentId : null)));
expect(agentIds.has(testAgentId)).toBe(true);
expect(agentIds.has(otherAgentId)).toBe(true);
expect(agentIds.has(null)).toBe(true);
// First results should be from current agent
expect(topicResults[0].type).toBe('topic');
if (topicResults[0].type === 'topic') {
expect(topicResults[0].agentId).toBe(testAgentId);
}
});
it('should return 6 topics in agent context', async () => {
@ -652,14 +630,13 @@ describe.skipIf(!isServerDB)('SearchRepo', () => {
expect(topicResults.length).toBeLessThanOrEqual(3);
});
it('should not boost topics when agentId is not provided', async () => {
it('should return topics with normal relevance range (1-3) when agentId is not provided', async () => {
const results = await searchRepo.search({
query: 'testing',
});
const topicResults = results.filter((r) => r.type === 'topic');
// All topics should have normal relevance (1-3)
topicResults.forEach((topic) => {
expect(topic.relevance).toBeGreaterThanOrEqual(1);
expect(topic.relevance).toBeLessThanOrEqual(3);

View file

@ -419,31 +419,25 @@ export class SearchRepo {
.where(
and(
eq(topics.userId, this.userId),
agentId ? eq(topics.agentId, agentId) : undefined,
sql`(${topics.title} @@@ ${bm25Query} OR ${topics.content} @@@ ${bm25Query} OR ${topics.description} @@@ ${bm25Query})`,
),
)
.orderBy(sql`paradedb.score(${topics.id}) DESC`)
.limit(limit);
return this.mapScoresToRelevance(rows).map((row) => {
let { relevance } = row;
if (agentId && row.agentId === agentId) {
relevance = relevance * 0.5;
}
return {
agentId: row.agentId,
createdAt: row.createdAt,
description: this.truncate(row.content),
favorite: row.favorite,
id: row.id,
relevance,
sessionId: row.sessionId,
title: row.title || '',
type: 'topic' as const,
updatedAt: row.updatedAt,
};
});
return this.mapScoresToRelevance(rows).map((row) => ({
agentId: row.agentId,
createdAt: row.createdAt,
description: this.truncate(row.content),
favorite: row.favorite,
id: row.id,
relevance: row.relevance,
sessionId: row.sessionId,
title: row.title || '',
type: 'topic' as const,
updatedAt: row.updatedAt,
}));
}
/**
@ -475,33 +469,27 @@ export class SearchRepo {
and(
eq(messages.userId, this.userId),
ne(messages.role, 'tool'),
agentId ? eq(messages.agentId, agentId) : undefined,
sql`${messages.content} @@@ ${bm25Query}`,
),
)
.orderBy(sql`paradedb.score(${messages.id}) DESC`)
.limit(limit);
return this.mapScoresToRelevance(rows).map((row) => {
let { relevance } = row;
if (agentId && row.agentId === agentId) {
relevance = relevance * 0.5;
}
return {
agentId: row.agentId,
content: row.content || '',
createdAt: row.createdAt,
description: row.agentTitle || 'General Chat',
id: row.id,
model: row.model,
relevance,
role: row.role,
title: this.truncate(row.content) || '',
topicId: row.topicId,
type: 'message' as const,
updatedAt: row.updatedAt,
};
});
return this.mapScoresToRelevance(rows).map((row) => ({
agentId: row.agentId,
content: row.content || '',
createdAt: row.createdAt,
description: row.agentTitle || 'General Chat',
id: row.id,
model: row.model,
relevance: row.relevance,
role: row.role,
title: this.truncate(row.content) || '',
topicId: row.topicId,
type: 'message' as const,
updatedAt: row.updatedAt,
}));
}
/**

View file

@ -8,6 +8,7 @@ import { detectContext } from './utils/context';
import { type ValidSearchType } from './utils/queryParser';
interface CommandMenuContextValue {
activeAgentId: string | undefined;
menuContext: MenuContext;
mounted: boolean;
onClose: () => void;
@ -43,6 +44,11 @@ export const CommandMenuProvider = ({ children, onClose, pathname }: CommandMenu
// Memoize derived values
const menuContext = useMemo(() => detectContext(pathname ?? '/'), [pathname]);
const activeAgentId = useMemo(() => {
if (menuContext !== 'agent') return undefined;
const match = pathname?.match(/^\/agent\/([^/?]+)/);
return match?.[1] || undefined;
}, [menuContext, pathname]);
const page = pages.at(-1);
const viewMode: MenuViewMode = search.trim().length > 0 ? 'search' : 'default';
@ -63,6 +69,7 @@ export const CommandMenuProvider = ({ children, onClose, pathname }: CommandMenu
// Memoize the context value to prevent unnecessary re-renders
const contextValue = useMemo<CommandMenuContextValue>(
() => ({
activeAgentId,
menuContext,
mounted: true, // Always true after initial render since provider only mounts on client
onClose,
@ -80,6 +87,7 @@ export const CommandMenuProvider = ({ children, onClose, pathname }: CommandMenu
viewMode,
}),
[
activeAgentId,
menuContext,
onClose,
page,

View file

@ -1,9 +1,13 @@
import { DEFAULT_AVATAR } from '@lobechat/const';
import { Avatar, Tag } from '@lobehub/ui';
import { Command } from 'cmdk';
import { ArrowLeft, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useCommandMenuContext } from '../CommandMenuContext';
import { styles } from '../styles';
import { useCommandMenu } from '../useCommandMenu';
@ -23,10 +27,16 @@ const CommandInput = memo(() => {
setTypeFilter,
selectedAgent,
setSelectedAgent,
activeAgentId,
} = useCommandMenuContext();
const activeAgentMeta = useAgentStore((s) =>
activeAgentId ? agentSelectors.getAgentMetaById(activeAgentId)(s) : undefined,
);
const hasPages = pages.length > 0;
const hasSelectedAgent = !!selectedAgent;
const hasActiveAgent = !!activeAgentId && menuContext === 'agent';
// Get localized context name
const contextName = t(`cmdk.context.${menuContext}`, { defaultValue: menuContext });
@ -49,7 +59,24 @@ const CommandInput = memo(() => {
<>
{(menuContext !== 'general' || typeFilter) && !hasPages && !hasSelectedAgent && (
<div className={styles.contextWrapper}>
{menuContext !== 'general' && <Tag className={styles.contextTag}>{contextName}</Tag>}
{hasActiveAgent ? (
<Tag
className={styles.contextTag}
icon={
<Avatar
emojiScaleWithBackground
avatar={activeAgentMeta?.avatar || DEFAULT_AVATAR}
background={activeAgentMeta?.backgroundColor}
shape="square"
size={14}
/>
}
>
{activeAgentMeta?.title || t('defaultAgent')}
</Tag>
) : (
menuContext !== 'general' && <Tag className={styles.contextTag}>{contextName}</Tag>
)}
{typeFilter && (
<Tag
className={styles.backTag}

View file

@ -1,6 +1,6 @@
import { useDebounce } from 'ahooks';
import { useTheme as useNextThemesTheme } from 'next-themes';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
@ -36,10 +36,10 @@ export const useCommandMenu = () => {
typeFilter,
setTypeFilter,
page,
menuContext: context,
pathname,
selectedAgent,
setSelectedAgent,
activeAgentId: agentId,
} = useCommandMenuContext();
const navigate = useNavigate();
@ -51,15 +51,6 @@ export const useCommandMenu = () => {
const { createGroupWithMembers, createGroupFromTemplate, createPage } = useCreateMenuItems();
const { open: openCreateLibraryModal } = useCreateNewModal();
// Extract agentId from pathname when in agent context
const agentId = useMemo(() => {
if (context === 'agent') {
const match = pathname?.match(/^\/agent\/([^/?]+)/);
return match?.[1] || undefined;
}
return undefined;
}, [context, pathname]);
// Debounce search input to reduce API calls
const debouncedSearch = useDebounce(search, { wait: 600 });