diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index 1621458498d70..803f9a5d1077f 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -279,9 +279,16 @@ }, "title": "Online Search" }, - "searchAgentPlaceholder": "Search assistants...", - "searchAgents": "Search assistants...", - "selectedAgents": "Selected agents", + "searchAgentPlaceholder": "Search assistants/chat logs...", + "searchMessages": { + "count": "{{count}} results", + "empty": "No chat logs found", + "loadMore": "Load more chat logs", + "title": "Chat logs" + }, + "searchSessions": { + "title": "Assistants" + }, "sendPlaceholder": "Type your message here...", "sessionGroup": { "config": "Group Management", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 0b65d08f0d73e..6bd8f691ca100 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -279,9 +279,16 @@ }, "title": "联网搜索" }, - "searchAgentPlaceholder": "搜索助手...", - "searchAgents": "搜索助手...", - "selectedAgents": "已选助手", + "searchAgentPlaceholder": "搜索助手/聊天记录...", + "searchMessages": { + "count": "共找到 {{count}} 条记录", + "empty": "未找到相关聊天记录", + "loadMore": "加载更多聊天记录", + "title": "聊天记录" + }, + "searchSessions": { + "title": "助手" + }, "sendPlaceholder": "输入聊天内容...", "sessionGroup": { "config": "分组管理", diff --git a/packages/database/src/models/__tests__/message.test.ts b/packages/database/src/models/__tests__/message.test.ts index 8ad6471597b98..8b7b9288282f0 100644 --- a/packages/database/src/models/__tests__/message.test.ts +++ b/packages/database/src/models/__tests__/message.test.ts @@ -609,38 +609,44 @@ describe('MessageModel', () => { }); describe('queryByKeyWord', () => { - it('should query messages by keyword', async () => { - // 创建测试数据 + beforeEach(async () => { await serverDB.insert(messages).values([ { id: '1', userId, role: 'user', content: 'apple', createdAt: new Date('2022-02-01') }, { id: '2', userId, role: 'user', content: 'banana' }, { id: '3', userId, role: 'user', content: 'pear' }, { id: '4', userId, role: 'user', content: 'apple pie', createdAt: new Date('2024-02-01') }, ]); + }); - // 测试查询包含特定关键字的消息 + it('should query messages by keyword with default pagination', async () => { const result = await messageModel.queryByKeyword('apple'); - // 断言结果 - expect(result).toHaveLength(2); - expect(result[0].id).toBe('4'); - expect(result[1].id).toBe('1'); + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toBe('4'); + expect(result.data[1].id).toBe('1'); + expect(result.pagination.current).toBe(0); + expect(result.pagination.pageSize).toBe(20); + expect(result.pagination.total).toBe(2); }); - it('should return empty array when keyword is empty', async () => { - // 创建测试数据 - await serverDB.insert(messages).values([ - { id: '1', userId, role: 'user', content: 'apple' }, - { id: '2', userId, role: 'user', content: 'banana' }, - { id: '3', userId, role: 'user', content: 'pear' }, - { id: '4', userId, role: 'user', content: 'apple pie' }, - ]); + it('should support pagination options', async () => { + const firstPage = await messageModel.queryByKeyword('apple', { current: 0, pageSize: 1 }); + const secondPage = await messageModel.queryByKeyword('apple', { current: 1, pageSize: 1 }); - // 测试当关键字为空时返回空数组 - const result = await messageModel.queryByKeyword(''); + expect(firstPage.data).toHaveLength(1); + expect(firstPage.data[0].id).toBe('4'); + expect(firstPage.pagination).toMatchObject({ current: 0, pageSize: 1, total: 2 }); - // 断言结果 - expect(result).toHaveLength(0); + expect(secondPage.data).toHaveLength(1); + expect(secondPage.data[0].id).toBe('1'); + expect(secondPage.pagination).toMatchObject({ current: 1, pageSize: 1, total: 2 }); + }); + + it('should return empty result when keyword is empty after trim', async () => { + const result = await messageModel.queryByKeyword(' '); + + expect(result.data).toHaveLength(0); + expect(result.pagination.total).toBe(0); }); }); diff --git a/packages/database/src/models/message.ts b/packages/database/src/models/message.ts index 1e76d8201fc88..ad03ff10492b6 100644 --- a/packages/database/src/models/message.ts +++ b/packages/database/src/models/message.ts @@ -8,6 +8,7 @@ import { ChatVideoItem, CreateMessageParams, MessageItem, + MessageKeywordSearchResult, ModelRankItem, NewMessageQueryParams, UpdateMessageParams, @@ -15,7 +16,7 @@ import { } from '@lobechat/types'; import type { HeatmapsProps } from '@lobehub/charts'; import dayjs from 'dayjs'; -import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, like, sql } from 'drizzle-orm'; +import { and, asc, count, desc, eq, gt, ilike, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; import { merge } from '@/utils/merge'; import { today } from '@/utils/time'; @@ -314,14 +315,54 @@ export class MessageModel { return result as MessageItem[]; }; - queryByKeyword = async (keyword: string) => { - if (!keyword) return []; - const result = await this.db.query.messages.findMany({ - orderBy: [desc(messages.createdAt)], - where: and(eq(messages.userId, this.userId), like(messages.content, `%${keyword}%`)), - }); + queryByKeyword = async ( + keyword: string, + { current = 0, pageSize = 20 }: { current?: number; pageSize?: number } = {}, + ): Promise => { + const sanitizedKeyword = keyword.trim(); + const safePageSize = pageSize > 0 ? pageSize : 20; + const safeCurrent = current >= 0 ? current : 0; + + if (!sanitizedKeyword) { + return { + data: [], + pagination: { + current: safeCurrent, + pageSize: safePageSize, + total: 0, + }, + }; + } - return result as MessageItem[]; + const offset = safeCurrent * safePageSize; + const whereClause = and( + eq(messages.userId, this.userId), + ilike(messages.content, `%${sanitizedKeyword}%`), + ); + + const [data, totalResult] = await Promise.all([ + this.db.query.messages.findMany({ + limit: safePageSize, + offset, + orderBy: [desc(messages.createdAt)], + where: whereClause, + }), + this.db + .select({ value: count(messages.id) }) + .from(messages) + .where(whereClause), + ]); + + const total = Number(totalResult[0]?.value ?? 0); + + return { + data: data as MessageItem[], + pagination: { + current: safeCurrent, + pageSize: safePageSize, + total, + }, + }; }; count = async (params?: { diff --git a/packages/types/src/message/base.ts b/packages/types/src/message/base.ts index c3bbf1cb5042d..a44a565d90212 100644 --- a/packages/types/src/message/base.ts +++ b/packages/types/src/message/base.ts @@ -151,6 +151,17 @@ export interface NewMessage { userId: string; // optional because it's generated } +export interface MessageKeywordSearchPagination { + current: number; + pageSize: number; + total: number; +} + +export interface MessageKeywordSearchResult { + data: MessageItem[]; + pagination: MessageKeywordSearchPagination; +} + export interface UpdateMessageParams { content?: string; error?: ChatMessageError | null; diff --git a/src/app/[variants]/(main)/chat/@session/features/SessionListContent/SearchMessages.tsx b/src/app/[variants]/(main)/chat/@session/features/SessionListContent/SearchMessages.tsx new file mode 100644 index 0000000000000..e94ad45abe74b --- /dev/null +++ b/src/app/[variants]/(main)/chat/@session/features/SessionListContent/SearchMessages.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { Button, Text } from '@lobehub/ui'; +import { createStyles } from 'antd-style'; +import dayjs from 'dayjs'; +import { memo, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Flexbox } from 'react-layout-kit'; +import useSWRInfinite from 'swr/infinite'; + +import { INBOX_SESSION_ID } from '@/const/session'; +import { messageService } from '@/services/message'; +import { useSessionStore } from '@/store/session'; +import type { MessageKeywordSearchResult } from '@/types/message'; + +import SkeletonList from '../SkeletonList'; +import useMessageNavigator from './useMessageNavigator'; + +const PAGE_SIZE = 20; +const SEARCH_MESSAGES_KEY = 'session.search.messages'; + +const useStyles = createStyles(({ css, token }) => ({ + container: css` + padding: 12px; + border-radius: ${token.borderRadiusLG}px; + background: ${token.colorFillTertiary}; + `, + header: css` + display: flex; + align-items: center; + justify-content: space-between; + margin-block-end: 12px; + `, + highlight: css` + padding-block: 0; + padding-inline: 2px; + border-radius: ${token.borderRadiusSM}px; + + color: ${token.colorWarningText}; + + background: ${token.colorWarningBg}; + `, + item: css` + cursor: pointer; + padding: 12px; + border-radius: ${token.borderRadiusLG}px; + transition: background-color 0.2s ease; + + &:hover { + background: ${token.colorFillSecondary}; + } + `, + itemLoading: css` + opacity: 0.6; + `, + meta: css` + display: flex; + flex-wrap: wrap; + gap: 8px; + + margin-block-end: 4px; + + font-size: 12px; + color: ${token.colorTextTertiary}; + `, + snippet: css` + line-height: 1.5; + color: ${token.colorTextSecondary}; + word-break: break-word; + `, +})); + +const escapeRegExp = (value: string) => value.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'); + +const createSnippet = (content: string, keyword: string, padding = 48) => { + const normalized = content.replaceAll(/\s+/g, ' ').trim(); + if (!normalized) return ''; + + const lower = normalized.toLowerCase(); + const lowerKeyword = keyword.toLowerCase(); + const index = lower.indexOf(lowerKeyword); + + if (index === -1) { + return normalized.length > padding * 2 ? `${normalized.slice(0, padding * 2)}…` : normalized; + } + + const start = Math.max(index - padding, 0); + const end = Math.min(index + lowerKeyword.length + padding, normalized.length); + + const prefix = start > 0 ? '…' : ''; + const suffix = end < normalized.length ? '…' : ''; + + return `${prefix}${normalized.slice(start, end)}${suffix}`; +}; + +const highlightKeyword = (snippet: string, keyword: string, className: string) => { + if (!keyword) return snippet; + + const escaped = escapeRegExp(keyword); + const regex = new RegExp(`(${escaped})`, 'ig'); + const parts = snippet.split(regex); + + return parts.map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + part + ), + ); +}; + +interface SearchMessagesProps { + keyword: string; +} + +const SearchMessages = memo(({ keyword }) => { + const normalizedKeyword = keyword.trim(); + const { t } = useTranslation('chat'); + const { styles, cx } = useStyles(); + const navigateToMessage = useMessageNavigator(); + + const sessions = useSessionStore((s) => s.sessions); + const sessionMetaMap = useMemo(() => { + return new Map(sessions.map((session) => [session.id, session.meta])); + }, [sessions]); + + const [pendingId, setPendingId] = useState(null); + + const { data, size, setSize, isLoading, isValidating } = + useSWRInfinite( + (pageIndex, previousPageData) => { + if (!normalizedKeyword) return null; + + if (previousPageData) { + const { current, pageSize, total } = previousPageData.pagination; + const fetched = (current + 1) * pageSize; + if (fetched >= total) return null; + } + + return [SEARCH_MESSAGES_KEY, normalizedKeyword, pageIndex] as const; + }, + async ([, searchValue, pageIndex]) => + messageService.searchMessages(searchValue as string, { + current: pageIndex as number, + pageSize: PAGE_SIZE, + }), + { + revalidateFirstPage: true, + }, + ); + + const pages = data ?? []; + const results = useMemo(() => pages.flatMap((page) => page.data), [pages]); + const total = pages[0]?.pagination.total ?? 0; + const hasMore = results.length < total; + const isEmpty = !isLoading && results.length === 0; + const isLoadingMore = isValidating && results.length > 0; + + if (!normalizedKeyword) return null; + + if (isEmpty) return null; + + return ( + +
+ {t('searchMessages.title')} + {total > 0 && {t('searchMessages.count', { count: total })}} +
+ {isLoading && results.length === 0 ? ( + + ) : ( + + {results.map(({ id, content, sessionId, topicId, threadId, createdAt }) => { + const sessionKey = sessionId ?? INBOX_SESSION_ID; + const meta = sessionMetaMap.get(sessionKey); + const title = + sessionKey === INBOX_SESSION_ID + ? t('inbox.title') + : meta?.title || t('defaultSession'); + const snippet = createSnippet(content || '', normalizedKeyword); + const timestamp = createdAt ? dayjs(createdAt).format('YYYY/MM/DD HH:mm') : ''; + + return ( + { + setPendingId(id); + try { + await navigateToMessage({ + messageId: id, + sessionId, + threadId, + topicId, + }); + } finally { + setPendingId(null); + } + }} + > +
+ {title && {title}} + {timestamp && {timestamp}} +
+
+ {highlightKeyword(snippet, normalizedKeyword, styles.highlight)} +
+
+ ); + })} +
+ )} + {hasMore && ( + + )} +
+ ); +}); + +SearchMessages.displayName = 'SessionSearchMessages'; + +export default SearchMessages; diff --git a/src/app/[variants]/(main)/chat/@session/features/SessionListContent/SearchMode.tsx b/src/app/[variants]/(main)/chat/@session/features/SessionListContent/SearchMode.tsx index 763e37b52a740..8163ad42ed83e 100644 --- a/src/app/[variants]/(main)/chat/@session/features/SessionListContent/SearchMode.tsx +++ b/src/app/[variants]/(main)/chat/@session/features/SessionListContent/SearchMode.tsx @@ -1,4 +1,6 @@ -import { memo, useMemo } from 'react'; +import { Text } from '@lobehub/ui'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { useServerConfigStore } from '@/store/serverConfig'; import { serverConfigSelectors } from '@/store/serverConfig/selectors'; @@ -7,8 +9,10 @@ import { LobeAgentSession, LobeSessionType, LobeSessions } from '@/types/session import SkeletonList from '../SkeletonList'; import SessionList from './List'; +import SearchMessages from './SearchMessages'; const SearchMode = memo(() => { + const { t } = useTranslation('chat'); const [sessionSearchKeywords, useSearchSessions] = useSessionStore((s) => [ s.sessionSearchKeywords, s.useSearchSessions, @@ -17,24 +21,20 @@ const SearchMode = memo(() => { const isMobile = useServerConfigStore(serverConfigSelectors.isMobile); const { data, isLoading } = useSearchSessions(sessionSearchKeywords); - - const filteredData = useMemo(() => { - if (!data) return data; - - if (isMobile) { - return data.filter((session: LobeSessions[0]) => session.type !== LobeSessionType.Group); - } - - return data.filter( - (session: LobeSessions[0]) => - session.type !== LobeSessionType.Agent || !(session as LobeAgentSession).config?.virtual, - ); - }, [data, isMobile]); - - return isLoading ? ( - - ) : ( - + const hasSessionResults = (data?.length ?? 0) > 0; + + return ( + <> + + {isLoading ? ( + + ) : hasSessionResults ? ( +
+ {t('searchSessions.title')} + +
+ ) : null} + ); }); diff --git a/src/app/[variants]/(main)/chat/@session/features/SessionListContent/useMessageNavigator.ts b/src/app/[variants]/(main)/chat/@session/features/SessionListContent/useMessageNavigator.ts new file mode 100644 index 0000000000000..0e620845f7765 --- /dev/null +++ b/src/app/[variants]/(main)/chat/@session/features/SessionListContent/useMessageNavigator.ts @@ -0,0 +1,85 @@ +import { useCallback } from 'react'; + +import { INBOX_SESSION_ID } from '@/const/session'; +import { getVirtuosoGlobalRef } from '@/features/Conversation/components/VirtualizedList/VirtuosoContext'; +import { useSwitchSession } from '@/hooks/useSwitchSession'; +import { getChatStoreState, useChatStore } from '@/store/chat'; +import { chatSelectors } from '@/store/chat/selectors'; +import { getSessionStoreState } from '@/store/session'; + +interface NavigateToMessageOptions { + messageId: string; + sessionId: string | null; + threadId: string | null; + topicId: string | null; +} + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); + +const waitFor = async (predicate: () => boolean, timeout = 2000, interval = 100) => { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + if (predicate()) return true; + await sleep(interval); + } + + return predicate(); +}; + +const useMessageNavigator = () => { + const switchSession = useSwitchSession(); + const switchTopic = useChatStore((s) => s.switchTopic); + const switchThread = useChatStore((s) => s.switchThread); + + return useCallback( + async ({ messageId, sessionId, topicId, threadId }: NavigateToMessageOptions) => { + const targetSessionId = sessionId ?? INBOX_SESSION_ID; + const sessionState = getSessionStoreState(); + + if (sessionState.activeId !== targetSessionId) { + switchSession(targetSessionId); + await waitFor(() => getChatStoreState().activeId === targetSessionId); + } + + const chatState = getChatStoreState(); + const normalizedTopicId = topicId ?? undefined; + + if (chatState.activeTopicId !== normalizedTopicId) { + await switchTopic(normalizedTopicId); + } else if (!threadId && chatState.activeThreadId) { + useChatStore.setState({ activeThreadId: undefined }); + } + + if (threadId) { + await switchThread(threadId); + } + + const messageReady = await waitFor( + () => !!chatSelectors.getMessageById(messageId)(getChatStoreState()), + ); + + if (!messageReady) return false; + + const ids = chatSelectors.mainDisplayChatIDs(getChatStoreState()); + const index = ids.indexOf(messageId); + if (index === -1) return false; + + const virtuosoRef = getVirtuosoGlobalRef(); + virtuosoRef?.current?.scrollToIndex({ + align: 'start', + behavior: 'smooth', + index, + offset: 48, + }); + + return true; + }, + [switchSession, switchThread, switchTopic], + ); +}; + +export default useMessageNavigator; diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 2d857c4048e08..67f722d6e6804 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -309,9 +309,16 @@ export default { }, title: '联网搜索', }, - searchAgentPlaceholder: '搜索助手...', - searchAgents: '搜索助手...', - selectedAgents: '已选助手', + searchAgentPlaceholder: '搜索助手/聊天记录...', + searchMessages: { + count: '共找到 {{count}} 条记录', + empty: '未找到相关聊天记录', + loadMore: '加载更多聊天记录', + title: '聊天记录', + }, + searchSessions: { + title: '助手', + }, sendPlaceholder: '输入聊天内容...', sessionGroup: { config: '分组管理', diff --git a/src/server/routers/lambda/message.ts b/src/server/routers/lambda/message.ts index e9ccd0e5d4dab..8ed3ba7e89e65 100644 --- a/src/server/routers/lambda/message.ts +++ b/src/server/routers/lambda/message.ts @@ -165,9 +165,17 @@ export const messageRouter = router({ }), searchMessages: messageProcedure - .input(z.object({ keywords: z.string() })) + .input( + z.object({ + current: z.number().optional(), + keywords: z.string(), + pageSize: z.number().optional(), + }), + ) .query(async ({ input, ctx }) => { - return ctx.messageModel.queryByKeyword(input.keywords); + const { current, keywords, pageSize } = input; + + return ctx.messageModel.queryByKeyword(keywords, { current, pageSize }); }), update: messageProcedure diff --git a/src/services/message/_deprecated.ts b/src/services/message/_deprecated.ts index 77c815452f3a4..19070cc0ea9db 100644 --- a/src/services/message/_deprecated.ts +++ b/src/services/message/_deprecated.ts @@ -10,6 +10,8 @@ import { ChatTTS, ChatTranslate, CreateMessageParams, + MessageItem, + MessageKeywordSearchResult, ModelRankItem, } from '@/types/message'; @@ -89,6 +91,61 @@ export class ClientService implements IMessageService { return MessageModel.queryBySessionId(sessionId); } + async searchMessages(keyword: string, params?: { current?: number; pageSize?: number }) { + const current = Math.max(params?.current ?? 0, 0); + const pageSize = Math.max(params?.pageSize ?? 20, 1); + const normalizedKeyword = keyword.trim().toLowerCase(); + + if (!normalizedKeyword) { + return { + data: [], + pagination: { current, pageSize, total: 0 }, + } satisfies MessageKeywordSearchResult; + } + + const messages = await MessageModel.queryAll(); + + const matched = messages + .filter((message) => (message.content || '').toLowerCase().includes(normalizedKeyword)) + .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + + const start = current * pageSize; + const slice = matched.slice(start, start + pageSize); + + return { + data: slice.map((message) => ({ + agentId: (message as any).agentId ?? null, + clientId: null, + content: message.content ?? '', + createdAt: new Date(message.createdAt || Date.now()), + error: null, + favorite: null, + id: message.id, + metadata: null, + model: null, + observationId: null, + parentId: message.parentId ?? null, + provider: null, + quotaId: null, + reasoning: null, + role: message.role, + search: null, + sessionId: message.sessionId ?? null, + threadId: message.threadId ?? null, + tools: null, + topicId: message.topicId ?? null, + traceId: null, + updatedAt: new Date(message.updatedAt || message.createdAt || Date.now()), + userId: '', + })), + pagination: { + current, + pageSize, + total: matched.length, + }, + } satisfies MessageKeywordSearchResult; + } + async updateMessageError(id: string, error: ChatMessageError) { return MessageModel.update(id, { error }); } diff --git a/src/services/message/client.ts b/src/services/message/client.ts index 8fb107b0e792f..fdb8ca45be930 100644 --- a/src/services/message/client.ts +++ b/src/services/message/client.ts @@ -92,6 +92,10 @@ export class ClientService extends BaseClientService implements IMessageService return data as unknown as ChatMessage[]; }; + searchMessages: IMessageService['searchMessages'] = async (keyword, params) => { + return this.messageModel.queryByKeyword(keyword, params); + }; + updateMessageError: IMessageService['updateMessageError'] = async (id, error) => { return this.messageModel.update(id, { error }); }; diff --git a/src/services/message/server.ts b/src/services/message/server.ts index 7b4b0408f214d..d406296e9fe8b 100644 --- a/src/services/message/server.ts +++ b/src/services/message/server.ts @@ -45,6 +45,14 @@ export class ServerService implements IMessageService { }); }; + searchMessages: IMessageService['searchMessages'] = async (keyword, params) => { + return lambdaClient.message.searchMessages.query({ + current: params?.current, + keywords: keyword, + pageSize: params?.pageSize, + }); + }; + countMessages: IMessageService['countMessages'] = async (params) => { return lambdaClient.message.count.query(params); }; diff --git a/src/services/message/type.ts b/src/services/message/type.ts index 480f000bd5a71..21bfd6238fd6e 100644 --- a/src/services/message/type.ts +++ b/src/services/message/type.ts @@ -8,6 +8,7 @@ import { ChatTranslate, CreateMessageParams, MessageItem, + MessageKeywordSearchResult, ModelRankItem, UpdateMessageParams, } from '@/types/message'; @@ -23,6 +24,13 @@ export interface IMessageService { getGroupMessages(groupId: string, topicId?: string): Promise; getAllMessages(): Promise; getAllMessagesInSession(sessionId: string): Promise; + searchMessages( + keyword: string, + params?: { + current?: number; + pageSize?: number; + }, + ): Promise; countMessages(params?: { endDate?: string; range?: [string, string];