diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 0e2653ec..62da540a 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -24,7 +24,7 @@ import { useTheme } from './hooks/useTheme.ts'; import { useIsMobile } from './hooks/useIsMobile.ts'; import { prefetch, PREFETCH_KEYS } from './prefetchCache.ts'; import { useTranslation } from 'react-i18next'; -import { SearchModal } from './components/SearchModal.tsx'; +import { SearchModal, type SearchCategory } from './components/SearchModal.tsx'; const HIDDEN_STYLE: React.CSSProperties = { visibility: 'hidden', @@ -110,6 +110,7 @@ export function App() { }, [checkLicenseLimits]); const [showSearchModal, setShowSearchModal] = useState(false); + const [searchInitialTab, setSearchInitialTab] = useState(); // Global search shortcut: Cmd+P (Mac) / Ctrl+P (Win/Linux) useEffect(() => { @@ -118,16 +119,22 @@ export function App() { const onKey = (e: KeyboardEvent) => { if (isMac && e.metaKey && !e.ctrlKey && e.key === 'p') { e.preventDefault(); + setSearchInitialTab(undefined); setShowSearchModal(prev => !prev); } else if (!isMac && e.ctrlKey && !e.metaKey && e.key === 'p') { e.preventDefault(); + setSearchInitialTab(undefined); setShowSearchModal(prev => !prev); } }; - const onOpen = () => setShowSearchModal(true); + const onOpen = (e: Event) => { + const detail = (e as CustomEvent).detail; + setSearchInitialTab(detail?.initialTab); + setShowSearchModal(true); + }; document.addEventListener('keydown', onKey); - window.addEventListener('markus:open-search', onOpen); - return () => { document.removeEventListener('keydown', onKey); window.removeEventListener('markus:open-search', onOpen); }; + window.addEventListener('markus:open-search', onOpen as EventListener); + return () => { document.removeEventListener('keydown', onKey); window.removeEventListener('markus:open-search', onOpen as EventListener); }; }, [isMobile]); const navigate = useCallback((p: PageId) => { @@ -449,7 +456,7 @@ export function App() { {/* Global search modal (desktop) */} {!isMobile && showSearchModal && ( - setShowSearchModal(false)} currentPage={page} /> + { setShowSearchModal(false); setSearchInitialTab(undefined); }} currentPage={page} initialTab={searchInitialTab} /> )} ); diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index b91b9fbd..17b1ef99 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -89,11 +89,19 @@ export interface SearchResult { source: 'channel' | 'direct'; id: string; text: string; + /** Text snippet from FTS5 with tags for highlight rendering */ + snippetText?: string; senderName?: string; + senderId?: string; channel?: string; sessionId?: string; + sessionName?: string; agentId?: string; createdAt: string; + /** Number of matches in this message (from FTS5) */ + matchCount?: number; + /** FTS5 relevance score */ + relevanceScore?: number; } @@ -1679,12 +1687,26 @@ export const api = { // ─── Message Search ─────────────────────────────────────────────── messages: { - search: (query: string, opts?: { scope?: 'all' | 'channel' | 'direct'; channel?: string; limit?: number }) => { + search: (query: string, opts?: { + scope?: 'all' | 'channel' | 'direct'; + channel?: string; + agentId?: string; + sessionId?: string; + limit?: number; + offset?: number; + from?: string; + to?: string; + }) => { const params = new URLSearchParams({ q: query }); if (opts?.scope) params.set('scope', opts.scope); if (opts?.channel) params.set('channel', opts.channel); + if (opts?.agentId) params.set('agentId', opts.agentId); + if (opts?.sessionId) params.set('sessionId', opts.sessionId); if (opts?.limit) params.set('limit', String(opts.limit)); - return request<{ results: SearchResult[] }>(`/messages/search?${params}`); + if (opts?.offset) params.set('offset', String(opts.offset)); + if (opts?.from) params.set('from', opts.from); + if (opts?.to) params.set('to', opts.to); + return request<{ results: SearchResult[]; total?: number; speedMs?: number }>(`/messages/search?${params}`); }, }, diff --git a/packages/web-ui/src/components/ChatPanel.tsx b/packages/web-ui/src/components/ChatPanel.tsx index d2bd1e25..15839674 100644 --- a/packages/web-ui/src/components/ChatPanel.tsx +++ b/packages/web-ui/src/components/ChatPanel.tsx @@ -505,6 +505,13 @@ export function ChatPanel({ className="rounded-md" /> {agentName} + {onClose && ( + ))} + + + {/* Loading */} + {messageLoading && ( +
+
{t('common:loading')}
+
+ )} + + {/* Results */} + {!messageLoading && messageResults.length > 0 && ( +
+
+ {messageTotal > 0 ? `${messageTotal} ${t('common:results', { defaultValue: '个结果' })}` : ''} +
+ {messageResults.map((r) => { + const idx = itemCounter++; + const displayText = r.snippetText ?? r.text; + return ( + + ); + })} +
+ )} + + {/* Empty */} + {!messageLoading && query.length >= 2 && messageResults.length === 0 && !messageError && ( +
+ +

{t('common:noResults', { defaultValue: '没有找到相关结果' })}

+
+ )} + + {/* Error */} + {messageError && ( +
+

{messageError}

+ +
+ )} + + {/* Initial state */} + {!messageLoading && query.length < 2 && ( +
+ +

{t('common:searchHint', { defaultValue: '输入关键词搜索对话内容' })}

+
+ )} + + )} + + {/* ── Entity tabs (non-messages) ──────────────────────── */} + {category !== 'messages' && loading && (
{t('common:loading')}
)} - {!loading && searched && !hasResults && ( + {category !== 'messages' && !loading && searched && !hasResults && (

{t('common:noResults', { defaultValue: '没有找到相关结果' })}

)} - {!loading && hasResults && ( + {category !== 'messages' && !loading && hasResults && (
{sectionOrder.map(section => { if (category !== 'all' && category !== section) return null; @@ -438,7 +606,7 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur
)} - {!loading && !searched && ( + {category !== 'messages' && !loading && !searched && (

{t('common:searchHint', { defaultValue: '输入关键词搜索' })}

@@ -459,6 +627,39 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur ); } +/** + * Render text with tags from FTS5 snippet, or fallback to case-insensitive highlight. + */ +function renderMessageHighlight(text: string, query: string): React.ReactNode { + if (text.includes('')) { + const parts: React.ReactNode[] = []; + const regex = /(.*?)<\/mark>|([^<]+(?!<\/mark>))|<[^>]+>/g; + let match: RegExpExecArray | null; + let lastIndex = 0; + let key = 0; + while ((match = regex.exec(text)) !== null) { + if (match[1] !== undefined) { + parts.push({match[1]}); + } else if (match[0]) { + parts.push({match[0]}); + } + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) parts.push({text.slice(lastIndex)}); + return parts.length > 0 ? parts : text; + } + if (!query || query.length < 2) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\function StatusDot({ status }: { status: string }) {'); + const regex = new RegExp(`(${escaped})`, 'gi'); + const parts = text.split(regex); + if (parts.length === 1) return text; + return parts.map((part, i) => + regex.test(part) + ? {part} + : part + ); +} + function StatusDot({ status }: { status: string }) { const color = status === 'active' || status === 'idle' ? 'bg-green-500' : status === 'working' || status === 'busy' ? 'bg-blue-500' diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index faf77d26..d265f6d9 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -449,13 +449,6 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const pendingSelectTeamRef = useRef(null); const [showMemberPanel, setShowMemberPanel] = useState(false); - // Message search - const [searchOpen, setSearchOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - const searchDebounceRef = useRef>(undefined); - // Teams const [teams, setTeams] = useState(previewData?.teams ?? []); @@ -2346,45 +2339,6 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } }; - const executeSearch = useCallback(async (q: string) => { - if (q.length < 2) { setSearchResults([]); return; } - setSearchLoading(true); - try { - const scope = chatMode === 'channel' ? 'channel' : chatMode === 'direct' ? 'direct' : 'all'; - const channel = chatMode === 'channel' ? activeChannel : undefined; - const { results } = await api.messages.search(q, { scope, channel, limit: 30 }); - setSearchResults(results); - } catch { setSearchResults([]); } - setSearchLoading(false); - }, [chatMode, activeChannel]); - - const handleSearchInput = useCallback((q: string) => { - setSearchQuery(q); - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = setTimeout(() => executeSearch(q), 300); - }, [executeSearch]); - - const handleSearchResultClick = useCallback((result: import('../api.ts').SearchResult) => { - setSearchOpen(false); - setSearchQuery(''); - setSearchResults([]); - if (result.source === 'channel' && result.channel) { - setChatMode('channel'); - setActiveChannel(result.channel); - } else if (result.source === 'direct' && result.agentId) { - setChatMode('direct'); - setSelectedAgent(result.agentId); - } - setTimeout(() => { - const el = document.getElementById(`msg-${result.id}`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('bg-brand-500/10'); - setTimeout(() => el.classList.remove('bg-brand-500/10'), 2000); - } - }, 500); - }, []); - const newConversation = () => { setActiveSessionId(NEW_CHAT_PLACEHOLDER_ID); const key = currentConvKeyRef.current; @@ -2825,8 +2779,8 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: >{chatMode === 'channel' ? t('page.teamTab') : t('page.profileTab')}
@@ -3012,8 +2966,8 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: {/* Right side buttons */}
-
- {searchLoading && ( -
- - {t('page.searching')} -
- )} - {!searchLoading && searchQuery.length >= 2 && searchResults.length === 0 && ( -
{t('page.noSearchResults')}
- )} - {searchResults.length > 0 && ( -
- {searchResults.map(r => ( - - ))} -
- )} -
- )} - {/* Session tab bar (direct mode, chat tab) — hide when only 1 session */} {chatMode === 'direct' && selectedAgent && mainTab === 'chat' && openSessionTabs.length > 1 && (