diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 1970738..d9b6d33 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -48,11 +48,14 @@ export default function ChatPage() { currentSessionId, setCurrentSessionId, loadSessionHistory, + loadMoreMessages, + paginationState, saveSession, deleteSession, isGeneratingTitle, searchSessions, - isSearching + isSearching, + searchQuery } = useChatStorage(); const { isDownloading, progress } = useAutoLabelingModel(); @@ -121,6 +124,7 @@ export default function ChatPage() { const saveTimeoutRef = useRef(null); const lastSavedMessagesRef = useRef(0); const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); const [showContextDebug, setShowContextDebug] = useState(false); const [showProcessDebug, setShowProcessDebug] = useState(false); const [lastWebContext, setLastWebContext] = useState(''); @@ -128,6 +132,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(() => { @@ -246,6 +258,34 @@ export default function ChatPage() { } }, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]); + // ========== EXECUÇÃO ESPECULATIVA DE BUSCA ========== + // Heurística rápida para detectar se a query provavelmente precisa de busca web + const mightNeedWebSearch = useCallback((text: string): boolean => { + if (!webSearch.isEnabled) return false; + + const lowerText = text.toLowerCase(); + + // Padrões que indicam alta probabilidade de precisar de busca + const questionPatterns = /\b(o que|como|quando|onde|quem|porque|por que|qual|quais|quanto|quantos|me explique|me diga|existe|há)\b/i; + const recentPatterns = /\b(hoje|ontem|esta semana|este mês|este ano|atualmente|recentemente|últimas notícias|novo|novos|última|últimas|2024|2025)\b/i; + const searchPatterns = /\?$|buscar|pesquisar|encontrar|procurar|descobrir/i; + + // Se contém padrões de pergunta ou busca, provavelmente precisa de pesquisa + if (questionPatterns.test(lowerText) || recentPatterns.test(lowerText) || searchPatterns.test(lowerText)) { + chatLog.debug('[SpeculativeSearch] Query might need web search (pattern match)'); + return true; + } + + // Se tem mais de 5 palavras e termina com ?, provavelmente é uma pergunta + const wordCount = text.trim().split(/\s+/).length; + if (wordCount > 5 && text.trim().endsWith('?')) { + chatLog.debug('[SpeculativeSearch] Query might need web search (question detected)'); + return true; + } + + return false; + }, [webSearch.isEnabled]); + const handleSend = async (content: string) => { if (!selectedModel) return; @@ -262,6 +302,22 @@ export default function ChatPage() { // Limpar erros anteriores setError(null); + // ========== EXECUÇÃO ESPECULATIVA ========== + // Iniciar busca web especulativamente se a query parecer precisar + // Isso reduz latência sobrepondo busca com pré-processamento + let speculativeSearchPromise: Promise | null = null; + const shouldSpeculate = mightNeedWebSearch(content); + + if (shouldSpeculate) { + chatLog.info('[SpeculativeSearch] Starting speculative web search in parallel with preprocessing'); + // Iniciar busca sem await - será usada depois se confirmada + speculativeSearchPromise = webSearch.smartSearchRag(content, settings.webSearch?.maxResults || 5) + .catch(err => { + chatLog.debug(`[SpeculativeSearch] Speculative search failed (will use normal flow): ${err}`); + return [] as ScrapedContent[]; + }); + } + // ========== PRÉ-PROCESSAMENTO ========== let preprocessed: PreprocessedQuery; let finalContent: string; @@ -330,7 +386,8 @@ export default function ChatPage() { // Função auxiliar para executar pesquisa web const executeWebResearch = async ( content: string, - preprocessed: PreprocessedQuery + preprocessed: PreprocessedQuery, + speculativeResults?: ScrapedContent[] | null ): Promise<{ knowledgeBaseContext: string; scrapedSources: ScrapedContent[]; @@ -344,6 +401,12 @@ export default function ChatPage() { if (webSearch.isEnabled && preprocessed.shouldSearch) { chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---'); + // Se temos resultados especulativos, usá-los como seed inicial + if (speculativeResults && speculativeResults.length > 0) { + chatLog.info(`[SpeculativeSearch] Using ${speculativeResults.length} speculative results as seed`); + deepResearch.addToKnowledgeBase('speculative_seed', speculativeResults); + } + // Adicionar mensagem de processo: Pesquisa Web addThinkingMessage('web-research', 'Pesquisando na Web...', 'running'); const webResearchStart = Date.now(); @@ -605,10 +668,25 @@ export default function ChatPage() { // ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ========== // Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas - // Executar pesquisa web + // Aguardar resultado da busca especulativa se ela foi iniciada e shouldSearch é true + let speculativeResults: ScrapedContent[] | null = null; + if (speculativeSearchPromise && preprocessed.shouldSearch) { + chatLog.info('[SpeculativeSearch] Waiting for speculative search results...'); + try { + speculativeResults = await speculativeSearchPromise; + chatLog.info(`[SpeculativeSearch] Got ${speculativeResults.length} speculative results`); + } catch (err) { + chatLog.debug(`[SpeculativeSearch] Failed to get speculative results: ${err}`); + } + } else if (speculativeSearchPromise && !preprocessed.shouldSearch) { + chatLog.info('[SpeculativeSearch] Discarding speculative search (preprocessing determined search not needed)'); + } + + // Executar pesquisa web (usando resultados especulativos como seed se disponíveis) const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch( content, - preprocessed + preprocessed, + speculativeResults ); // Passo 5: Formular resposta @@ -803,7 +881,93 @@ 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 + } }; + + // Handler para scroll infinito reverso (carregar mensagens mais antigas) + const handleScroll = useCallback(async () => { + const container = scrollContainerRef.current; + if (!container || !currentSessionId || paginationState.isLoadingMore) return; + + // Verificar se o usuário está próximo do topo (threshold de 100px) + const isNearTop = container.scrollTop < 100; + + if (isNearTop && paginationState.hasMore) { + // Salvar posição atual do scroll e altura do conteúdo + const scrollHeightBefore = container.scrollHeight; + + // Carregar mais mensagens + const olderMessages = await loadMoreMessages(currentSessionId); + + if (olderMessages.length > 0) { + // Prepend as mensagens antigas + setMessages(prev => [...olderMessages, ...prev]); + + // Restaurar posição do scroll para manter o contexto visual + // (compensar o novo conteúdo adicionado no topo) + requestAnimationFrame(() => { + if (container) { + const scrollHeightAfter = container.scrollHeight; + const heightDiff = scrollHeightAfter - scrollHeightBefore; + container.scrollTop = container.scrollTop + heightDiff; + } + }); + } + } + }, [currentSessionId, paginationState.hasMore, paginationState.isLoadingMore, loadMoreMessages, setMessages]); + + // 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 +1111,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente onNewChat={handleNewChat} onSearch={searchSessions} isSearching={isSearching} + searchQuery={searchQuery} + onNavigateMatch={handleNavigateMatch} /> @@ -1050,8 +1216,29 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente {/* Messages Area with Floating Input Layout */}
{/* Scrollable Content Area */} -
+
+ {/* Indicador de carregamento de mensagens antigas */} + {paginationState.isLoadingMore && ( +
+ + Carregando mensagens anteriores... +
+ )} + + {/* Indicador de que há mais mensagens para carregar */} + {paginationState.hasMore && !paginationState.isLoadingMore && messages.length > 0 && ( +
+ + Role para cima para ver mensagens anteriores ({paginationState.totalCount - messages.length} restantes) + +
+ )} + {messages.length === 0 ? (
@@ -1086,6 +1273,13 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente 'type' in msg.metadata && msg.metadata.type === 'thinking'; + // Pular mensagens de sistema que não são thinking (system prompt) + // Essas mensagens nunca devem aparecer no chat + const isSystemPrompt = msg.role === 'system' && !isThinkingMessage; + if (isSystemPrompt) { + continue; + } + if (isThinkingMessage) { const thinkingMeta = msg.metadata as ThinkingMessageMetadata; currentThinkingGroup.push(thinkingMeta); @@ -1132,6 +1326,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 +1356,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..d122c93 100644 --- a/components/chat/chat-message.tsx +++ b/components/chat/chat-message.tsx @@ -1,6 +1,6 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { cn } from "@/lib/utils"; import { Message } from "@/hooks/use-chat"; import { Bot, User } from "lucide-react"; @@ -13,9 +13,19 @@ 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) { +/** + * Componente de mensagem do chat otimizado com React.memo + * + * Durante streaming, usa renderização simplificada (texto puro) para evitar + * recálculo custoso da árvore DOM e syntax highlight a cada token. + * Markdown completo é aplicado apenas quando a mensagem está finalizada. + */ +function ChatMessageComponent({ 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 +61,57 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) return content || message.content; }, [message.content]); + + // Processar highlight separadamente (após limpeza de conteúdo) + // Desabilitar durante streaming para performance + const highlightedContent = useMemo(() => { + // Não processar highlight durante streaming + if (isStreaming) return null; + + 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, isStreaming]); return (
-
+
{isUser ? ( // Estilo específico para mensagem do usuário (mais destaque, fonte maior) -
- {processedContent} +
+ {!highlightedContent && processedContent}
) : ( // Estilo Assistant @@ -88,22 +152,37 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
Gerando resposta...
+ ) : isStreaming ? ( + // Renderização LEVE durante streaming: texto simples com formatação básica + // Evita recálculo de ReactMarkdown e syntax highlight a cada token +
+
+ {processedContent} +
+ +
) : ( + // Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
- - {processedContent} - - {isStreaming && !isEmpty && ( - + {highlightedContent ? ( +
+ ) : ( + + {processedContent} + )}
) @@ -123,5 +202,34 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) ); } +/** + * ChatMessage com React.memo para evitar re-renders desnecessários + * + * Comparação customizada: + * - Compara conteúdo da mensagem + * - Compara estado de streaming + * - Compara termo de highlight e índice + */ +export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => { + // Se streaming mudou, sempre re-renderizar + if (prevProps.isStreaming !== nextProps.isStreaming) { + return false; + } + + // Durante streaming, só re-renderizar se conteúdo mudou + if (nextProps.isStreaming) { + return prevProps.message.content === nextProps.message.content; + } + + // Quando não está em streaming, comparar todas as props relevantes + return ( + prevProps.message.content === nextProps.message.content && + prevProps.message.role === nextProps.message.role && + prevProps.highlightTerm === nextProps.highlightTerm && + prevProps.highlightIndex === nextProps.highlightIndex && + prevProps.messageIndex === nextProps.messageIndex + ); +}); - +// Adicionar displayName para debugging +ChatMessage.displayName = 'ChatMessage'; 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 */}