From ba03131a78f3f5e1d5ec1738a338219b5fc725eb Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 20:39:39 -0300 Subject: [PATCH 1/3] feat(chat): enhance search functionality and message highlighting - Added search match navigation to allow users to cycle through search results in chat sessions. - Implemented highlighting of search terms in chat messages for better visibility. - Updated sidebar to display match counts for sessions based on search results. - Enhanced session search logic in the database to include match counts for improved user experience. - Refactored chat message component to support dynamic highlighting of search terms. --- app/chat/page.tsx | 107 ++++++++++++++++++- components/chat/chat-message.tsx | 78 ++++++++++++-- components/chat/sidebar-list.tsx | 73 ++++++++++--- hooks/use-chat-storage.ts | 1 + hooks/use-chat.ts | 66 +++++++----- src-tauri/src/db.rs | 176 ++++++++++++++++++++----------- src-tauri/src/lib.rs | 10 +- 7 files changed, 395 insertions(+), 116 deletions(-) diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 1970738..24b2065 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -52,7 +52,8 @@ export default function ChatPage() { deleteSession, isGeneratingTitle, searchSessions, - isSearching + isSearching, + searchQuery } = useChatStorage(); const { isDownloading, progress } = useAutoLabelingModel(); @@ -128,6 +129,14 @@ export default function ChatPage() { const [showDownloadDialog, setShowDownloadDialog] = useState(false); const [logsCopied, setLogsCopied] = useState(false); const [lastUserQuery, setLastUserQuery] = useState(''); + const [searchMatchIndex, setSearchMatchIndex] = useState(0); + + // Resetar índice quando busca é limpa ou sessão muda + useEffect(() => { + if (!searchQuery || searchQuery.trim().length < 2) { + setSearchMatchIndex(0); + } + }, [searchQuery, currentSessionId]); // Auto-select first model useEffect(() => { @@ -803,7 +812,61 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente setMessages(history); setCurrentSessionId(id); lastSavedMessagesRef.current = history.length; + + // Se há busca ativa, encontrar matches e resetar índice + if (searchQuery && searchQuery.trim().length >= 2) { + setSearchMatchIndex(0); + // Os matches serão encontrados quando as mensagens renderizarem + } }; + + // Função para navegar entre matches + const handleNavigateMatch = useCallback((sessionId: string, direction: 'prev' | 'next') => { + if (sessionId !== currentSessionId || !searchQuery || searchQuery.trim().length < 2) { + return; + } + + // Encontrar todos os matches nas mensagens + const query = searchQuery.trim(); + const matches: Array<{ messageIndex: number; matchIndex: number }> = []; + + messages.forEach((msg, msgIdx) => { + if (msg.role === 'user' || msg.role === 'assistant') { + const content = msg.content; + const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + let match; + let matchIdx = 0; + while ((match = regex.exec(content)) !== null) { + matches.push({ messageIndex: msgIdx, matchIndex: matchIdx }); + matchIdx++; + } + } + }); + + if (matches.length === 0) return; + + // Navegar + setSearchMatchIndex(prev => { + const newIndex = direction === 'next' + ? (prev + 1) % matches.length + : (prev - 1 + matches.length) % matches.length; + + // Scroll para o match após renderização + setTimeout(() => { + const messageElement = document.querySelector(`[data-message-index="${matches[newIndex].messageIndex}"]`); + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Destacar o match atual visualmente + const marks = messageElement.querySelectorAll('mark'); + if (marks.length > matches[newIndex].matchIndex) { + marks[matches[newIndex].matchIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + } + } + }, 150); + + return newIndex; + }); + }, [currentSessionId, searchQuery, messages]); const handleNewChat = () => { stop(); @@ -947,6 +1010,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente onNewChat={handleNewChat} onSearch={searchSessions} isSearching={isSearching} + searchQuery={searchQuery} + onNavigateMatch={handleNavigateMatch} /> @@ -1132,6 +1197,25 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente }); } + // Calcular posições de todos os matches uma vez + const allMatches: Array<{ messageIndex: number; matchIndex: number }> = []; + if (searchQuery && searchQuery.trim().length >= 2) { + const query = searchQuery.trim(); + const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + + messages.forEach((msg, msgIdx) => { + if (msg.role === 'user' || msg.role === 'assistant') { + const content = msg.content; + let match; + let matchIdx = 0; + while ((match = regex.exec(content)) !== null) { + allMatches.push({ messageIndex: msgIdx, matchIndex: matchIdx }); + matchIdx++; + } + } + }); + } + return groupedMessages.map((group, idx) => { if (group.type === 'thinking-group' && group.thinkingSteps) { return ( @@ -1143,11 +1227,30 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente ); } else if (group.type === 'regular' && group.message) { + // Encontrar índice do match atual para esta mensagem + let messageMatchIndex: number | undefined = undefined; + + if (searchQuery && searchQuery.trim().length >= 2 && allMatches.length > 0 && group.index !== undefined) { + // Encontrar matches desta mensagem + const messageMatches = allMatches.filter(m => m.messageIndex === group.index); + + if (messageMatches.length > 0) { + // Verificar se o match atual está nesta mensagem + const currentMatch = allMatches[searchMatchIndex]; + if (currentMatch && currentMatch.messageIndex === group.index) { + messageMatchIndex = currentMatch.matchIndex; + } + } + } + return (
= 2 ? searchQuery.trim() : undefined} + highlightIndex={messageMatchIndex} + messageIndex={group.index} />
); diff --git a/components/chat/chat-message.tsx b/components/chat/chat-message.tsx index f84cc15..55293ef 100644 --- a/components/chat/chat-message.tsx +++ b/components/chat/chat-message.tsx @@ -13,9 +13,12 @@ import type { ThinkingMessageMetadata } from "@/hooks/use-chat"; interface ChatMessageProps { message: Message; isStreaming?: boolean; + highlightTerm?: string; + highlightIndex?: number; + messageIndex?: number; } -export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) { +export function ChatMessage({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) { // Verificar se é mensagem de processo de pensamento const isThinkingMessage = message.metadata && typeof message.metadata === 'object' && @@ -51,6 +54,53 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) return content || message.content; }, [message.content]); + + // Processar highlight separadamente (após limpeza de conteúdo) + const highlightedContent = useMemo(() => { + if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) { + return null; // Não há highlight + } + + const term = highlightTerm.trim(); + const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedTerm})`, 'gi'); + const content = processedContent; + + // Encontrar todos os matches + const matches: Array<{ start: number; end: number; text: string }> = []; + let match; + while ((match = regex.exec(content)) !== null) { + matches.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0] + }); + } + + if (matches.length === 0) return null; + + // Construir conteúdo com highlights + let highlighted = ''; + let lastIndex = 0; + + matches.forEach((match, idx) => { + // Adicionar texto antes do match + highlighted += content.substring(lastIndex, match.start); + + // Adicionar match com highlight (laranja para atual, amarelo para outros) + const isCurrent = highlightIndex !== undefined && idx === highlightIndex; + const className = isCurrent + ? 'bg-orange-400/60 dark:bg-orange-500/60 px-0.5 rounded' + : 'bg-yellow-200/50 dark:bg-yellow-800/50 px-0.5 rounded'; + + highlighted += `${match.text}`; + lastIndex = match.end; + }); + + // Adicionar texto restante + highlighted += content.substring(lastIndex); + return highlighted; + }, [processedContent, highlightTerm, highlightIndex, message.role]); return ( -
+
{isUser ? ( // Estilo específico para mensagem do usuário (mais destaque, fonte maior) -
- {processedContent} +
+ {!highlightedContent && processedContent}
) : ( // Estilo Assistant @@ -94,14 +147,19 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) "prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg", "prose-p:leading-7 prose-li:leading-7", "prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl", + "prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded", isStreaming && "relative" )}> - - {processedContent} - + {highlightedContent ? ( +
+ ) : ( + + {processedContent} + + )} {isStreaming && !isEmpty && ( )} diff --git a/components/chat/sidebar-list.tsx b/components/chat/sidebar-list.tsx index 6f7b685..5bd7222 100644 --- a/components/chat/sidebar-list.tsx +++ b/components/chat/sidebar-list.tsx @@ -2,7 +2,7 @@ import { SessionSummary } from "@/hooks/use-chat-storage"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react"; +import { Trash2, Plus, MessageSquare, Search, X, Loader2, ChevronUp, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useState, useEffect, useRef } from "react"; @@ -14,6 +14,8 @@ interface SidebarListProps { onNewChat: () => void; onSearch?: (query: string) => void; isSearching?: boolean; + searchQuery?: string; + onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void; } function formatDate(dateString: string) { @@ -58,9 +60,11 @@ export function SidebarList({ onDeleteSession, onNewChat, onSearch, - isSearching = false + isSearching = false, + searchQuery = '', + onNavigateMatch }: SidebarListProps) { - const [searchQuery, setSearchQuery] = useState(''); + const [localSearchInput, setLocalSearchInput] = useState(''); const searchTimeoutRef = useRef(null); const DEBOUNCE_MS = 300; @@ -71,7 +75,7 @@ export function SidebarList({ } // Se query vazia, limpar busca imediatamente - if (!searchQuery.trim()) { + if (!localSearchInput.trim()) { if (onSearch) { onSearch(''); } @@ -80,8 +84,8 @@ export function SidebarList({ // Debounce: aguardar antes de buscar searchTimeoutRef.current = setTimeout(() => { - if (onSearch && searchQuery.trim().length >= 2) { - onSearch(searchQuery.trim()); + if (onSearch && localSearchInput.trim().length >= 2) { + onSearch(localSearchInput.trim()); } }, DEBOUNCE_MS); @@ -91,10 +95,10 @@ export function SidebarList({ clearTimeout(searchTimeoutRef.current); } }; - }, [searchQuery, onSearch]); + }, [localSearchInput, onSearch]); const handleClearSearch = () => { - setSearchQuery(''); + setLocalSearchInput(''); if (onSearch) { onSearch(''); } @@ -111,14 +115,14 @@ export function SidebarList({ setSearchQuery(e.target.value)} + value={localSearchInput} + onChange={(e) => setLocalSearchInput(e.target.value)} className="pl-8 pr-8 h-9 text-sm" /> {isSearching && ( )} - {searchQuery && !isSearching && ( + {localSearchInput && !isSearching && ( + +
+ )} + {/* Botão de deletar - aparece no hover */}