mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
🐛 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:
parent
e990b08cc6
commit
bc9164ae4a
5 changed files with 75 additions and 84 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue