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.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-27 20:39:39 -03:00
1 parent 0358a6ea2d
commit 6cc3f71f38
7 files changed
+395 -116

No files matched your search

+68 -10
View File
@@ -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 += `<mark class="${className}">${match.text}</mark>`;
lastIndex = match.end;
});
// Adicionar texto restante
highlighted += content.substring(lastIndex);
return highlighted;
}, [processedContent, highlightTerm, highlightIndex, message.role]);
return (
<MessageBackground
@@ -75,11 +125,14 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
</div>
</div>
<div className="flex-1 overflow-hidden min-w-0 pt-1">
<div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
{isUser ? (
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
{processedContent}
<div
className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
>
{!highlightedContent && processedContent}
</div>
) : (
// 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"
)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{processedContent}
</ReactMarkdown>
{highlightedContent ? (
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{processedContent}
</ReactMarkdown>
)}
{isStreaming && !isEmpty && (
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
)}
+58 -15
View File
@@ -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<NodeJS.Timeout | null>(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({
<Input
type="text"
placeholder="Buscar conversas..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={localSearchInput}
onChange={(e) => setLocalSearchInput(e.target.value)}
className="pl-8 pr-8 h-9 text-sm"
/>
{isSearching && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
)}
{searchQuery && !isSearching && (
{localSearchInput && !isSearching && (
<button
onClick={handleClearSearch}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
@@ -190,16 +194,55 @@ export function SidebarList({
</div>
{/* Texto do card */}
<div className="sidebar-chat-item-text">
<div className="sidebar-chat-item-text flex-1 min-w-0">
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
{session.title}
</h3>
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
{formatDate(session.updated_at)}
</p>
<div className="flex items-center gap-2">
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
{formatDate(session.updated_at)}
</p>
{searchQuery && session.match_count !== undefined && session.match_count > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
{session.match_count} {session.match_count === 1 ? 'ocorrência' : 'ocorrências'}
</span>
)}
</div>
</div>
{/* Navegação de matches - aparece apenas quando selecionado e há busca ativa */}
{isSelected && searchQuery && session.match_count !== undefined && session.match_count > 0 && onNavigateMatch && (
<div className="flex flex-col gap-0.5 mr-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'prev');
}}
aria-label="Ocorrência anterior"
>
<ChevronUp className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'next');
}}
aria-label="Próxima ocorrência"
>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</div>
)}
{/* Botão de deletar - aparece no hover */}
<Button
variant="ghost"