From 5c1a85992541114ebe81a5b96ef2e1fc70860a06 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 20 Nov 2025 05:34:44 -0300 Subject: [PATCH] feat: enhance chat functionality with web context debugging and improved message handling - Added state management for web context and sources to improve debugging capabilities in chat. - Implemented a debug view for displaying extracted context and sources used in chat responses. - Refactored message handling to incorporate web context into user messages, enhancing response accuracy. - Updated UI components to support new debugging features and improve user interaction. --- app/chat/page.tsx | 72 ++++++++++++++++++-- src-tauri/src/lib.rs | 52 +++++++-------- src-tauri/src/web_scraper.rs | 126 +++++++++++++++++++++++++++-------- 3 files changed, 194 insertions(+), 56 deletions(-) diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 1f7889a..8e64194 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2 } from "lucide-react"; +import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown } from "lucide-react"; import { ChatInput } from "@/components/chat/chat-input"; import { ChatMessage } from "@/components/chat/chat-message"; import { SystemPanel } from "@/components/chat/system-panel"; @@ -22,6 +22,7 @@ import { SearchProgress } from "@/components/chat/search-progress"; import { ThinkingIndicator, ThinkingStep } from "@/components/chat/thinking-indicator"; import { useQueryGenerator } from "@/hooks/use-query-generator"; import { useSettingsStore } from "@/store/settings-store"; +import type { ScrapedContent } from "@/services/webSearch"; import { invoke } from "@tauri-apps/api/core"; import { useState, useEffect, useRef, useCallback } from "react"; import { useTheme } from "next-themes"; @@ -64,6 +65,9 @@ export default function ChatPage() { const saveTimeoutRef = useRef(null); const lastSavedMessagesRef = useRef(0); const messagesEndRef = useRef(null); + const [showContextDebug, setShowContextDebug] = useState(false); + const [lastWebContext, setLastWebContext] = useState(''); + const [lastContextSources, setLastContextSources] = useState([]); // Auto-select first model useEffect(() => { @@ -120,6 +124,9 @@ export default function ChatPage() { const handleSend = async (content: string) => { if (!selectedModel) return; + setShowContextDebug(false); + setLastWebContext(''); + setLastContextSources([]); // Generate ID if new session (but don't create card yet - wait for title) if (!currentSessionId) { @@ -131,7 +138,7 @@ export default function ChatPage() { // ========== LÓGICA DE AGENTE DE PESQUISA ========== let webContext = ''; let searchQuery = ''; - let scrapedSources: any[] = []; + let scrapedSources: ScrapedContent[] = []; // Iniciar indicador de pensamento setThinkingStep('analyzing'); @@ -188,7 +195,13 @@ export default function ChatPage() { // Log para debug: verificar tamanho do contexto gerado const contextLength = webContext.length; console.debug(`Contexto web gerado (sanitizado): ${contextLength} caracteres de ${scrapedSources.length} fontes`); + setLastWebContext(webContext); + setLastContextSources(scrapedSources); + setShowContextDebug(false); } + } else { + setLastWebContext(''); + setLastContextSources([]); } } catch (error) { console.error('Erro na pesquisa web:', error); @@ -283,7 +296,12 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente } // [DEBUG INJECTION END] - await sendMessage(content, selectedModel, enhancedSystemPrompt); + let finalUserContent = content; + if (webContext) { + finalUserContent = `[CONTEXTO WEB OBRIGATÓRIO]\n${webContext}\n[/CONTEXTO WEB]\n\nCom base EXCLUSIVAMENTE no texto acima, responda: ${content}`; + } + + await sendMessage(finalUserContent, selectedModel, enhancedSystemPrompt); // Marcar como completo quando começar a streamar setTimeout(() => setThinkingStep('complete'), 100); @@ -516,10 +534,56 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente currentStep={thinkingStep} searchQuery={webSearch.currentQuery} sourcesRead={webSearch.scrapedSources.length} - totalSources={5} + totalSources={settings.webSearch.maxResults} /> )} + {/* Raw Context Debug */} + {lastContextSources.length > 0 && ( +
+ + {showContextDebug && ( +
+
+

+ Fontes utilizadas ({lastContextSources.length}): +

+
+ {lastContextSources.map((source, idx) => ( + + {source.title || source.url} + + ))} +
+
+
+

+ Bloco enviado na mensagem do usuário: +

+
+                        {lastWebContext.trim().length > 0 ? lastWebContext : '[Contexto vazio após sanitização]'}
+                      
+
+
+ )} +
+ )} + {/* Input */} Result { } // Processo identificado como headless - pode matar com segurança - #[cfg(target_os = "windows")] - { - use std::process::Command; - match Command::new("taskkill") - .args(&["/F", "/PID", &pid.to_string()]) - .output() + #[cfg(target_os = "windows")] { - Ok(output) => { - if output.status.success() { - killed_count += 1; + use std::process::Command; + match Command::new("taskkill") + .args(&["/F", "/PID", &pid.to_string()]) + .output() + { + Ok(output) => { + if output.status.success() { + killed_count += 1; log::info!("Processo Chrome headless encerrado: PID {} ({})", pid, name); + } + } + Err(e) => { + log::warn!("Erro ao encerrar processo {}: {}", pid, e); } - } - Err(e) => { - log::warn!("Erro ao encerrar processo {}: {}", pid, e); } } - } - - #[cfg(not(target_os = "windows"))] - { - use std::process::Command; - match Command::new("kill") - .args(&["-9", &pid.to_string()]) - .output() + + #[cfg(not(target_os = "windows"))] { - Ok(output) => { - if output.status.success() { - killed_count += 1; + use std::process::Command; + match Command::new("kill") + .args(&["-9", &pid.to_string()]) + .output() + { + Ok(output) => { + if output.status.success() { + killed_count += 1; log::info!("Processo Chrome headless encerrado: PID {} ({})", pid, name); + } } - } - Err(e) => { - log::warn!("Erro ao encerrar processo {}: {}", pid, e); + Err(e) => { + log::warn!("Erro ao encerrar processo {}: {}", pid, e); } } } diff --git a/src-tauri/src/web_scraper.rs b/src-tauri/src/web_scraper.rs index 58e42b1..2890f44 100644 --- a/src-tauri/src/web_scraper.rs +++ b/src-tauri/src/web_scraper.rs @@ -532,33 +532,43 @@ fn fetch_and_convert_sync(browser: &Browser, url: &str) -> Result p, - Err(e) => { - log::warn!("Falha ao extrair conteúdo legível de {}: {}", url, e); - return Err(anyhow::anyhow!("Falha ao processar conteúdo: {}", e)); + match readability::extractor::extract(&mut reader, &url_obj) { + Ok(product) => { + let mut markdown = html2text::from_read(product.content.as_bytes(), 80); + // Se o markdown for muito curto, significa que o readability pode ter falhado + if markdown.trim().chars().count() < 400 { + if let Some(fallback) = extract_paragraph_fallback(url, &content) { + log::info!("Fallback de parágrafos aplicado para {}", url); + return Ok(fallback); + } + } + + let title = if product.title.is_empty() { + fallback_title(&content).unwrap_or_else(|| "Fonte externa sem título".to_string()) + } else { + product.title.clone() + }; + + Ok(ScrapedContent { + title: title.clone(), + url: url.to_string(), + content: product.content, + markdown: format!( + "---\nTitle: {}\nSource: {}\n---\n\n{}", + title, + url, + markdown + ), + }) } - }; - - // Conversão para Markdown - let markdown = html2text::from_read(product.content.as_bytes(), 80); - - // Clonar title antes de mover para poder usar no format! - let title = product.title.clone(); - - let result = ScrapedContent { - title: product.title, - url: url.to_string(), - content: product.content, - markdown: format!( - "---\nTitle: {}\nSource: {}\n---\n\n{}", - title, - url, - markdown - ), - }; - - Ok(result) + Err(e) => { + log::warn!("Falha ao extrair conteúdo legível de {}: {}. Tentando fallback...", url, e); + if let Some(fallback) = extract_paragraph_fallback(url, &content) { + return Ok(fallback); + } + Err(anyhow::anyhow!("Falha ao processar conteúdo: {}", e)) + } + } } /// Cria uma instância do Browser (singleton para reutilização) @@ -572,3 +582,67 @@ pub fn create_browser() -> Result { .map_err(|e| anyhow::anyhow!("Falha ao criar browser: {}", e)) } +fn extract_paragraph_fallback(url: &str, html: &str) -> Option { + use scraper::{Html, Selector}; + + let document = Html::parse_document(html); + let paragraph_selector = Selector::parse("p").ok()?; + let mut paragraphs = Vec::new(); + + for element in document.select(¶graph_selector) { + let text = element.text().collect::>().join(" "); + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.chars().count() >= 100 { + paragraphs.push(normalized); + } + if paragraphs.len() >= 20 { + break; + } + } + + if paragraphs.len() < 3 { + return None; + } + + let fallback_body = paragraphs.join("\n\n"); + let title = fallback_title(html).unwrap_or_else(|| "Conteúdo externo".to_string()); + + Some(ScrapedContent { + title: title.clone(), + url: url.to_string(), + content: fallback_body.clone(), + markdown: format!( + "---\nTitle: {}\nSource: {}\n---\n\n{}", + title, + url, + fallback_body + ), + }) +} + +fn fallback_title(html: &str) -> Option { + use scraper::{Html, Selector}; + + let document = Html::parse_document(html); + + if let Ok(selector) = Selector::parse("title") { + if let Some(node) = document.select(&selector).next() { + let text = node.text().collect::>().join(" ").trim().to_string(); + if !text.is_empty() { + return Some(text); + } + } + } + + if let Ok(selector) = Selector::parse("h1") { + if let Some(node) = document.select(&selector).next() { + let text = node.text().collect::>().join(" ").trim().to_string(); + if !text.is_empty() { + return Some(text); + } + } + } + + None +} +