diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 96c9d56..854eb26 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -5,7 +5,8 @@ import { Button } from "@/components/ui/button"; 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, ChevronDown, Plus, ScrollText, Copy, Check } from "lucide-react"; +import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { ChatInput } from "@/components/chat/chat-input"; import { ChatMessage } from "@/components/chat/chat-message"; @@ -120,7 +121,7 @@ export default function ChatPage() { const initializedRef = useRef(false); // Initialize with default format prompt const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); - const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(false); + const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada const [thinkingStep, setThinkingStep] = useState(null); const [processSteps, setProcessSteps] = useState([]); const [error, setError] = useState(null); @@ -398,7 +399,11 @@ export default function ChatPage() { }); try { - // Executar busca progressiva + // Obter contexto e queries enriquecidas do deep research + const context = deepResearch.state.context; + const enrichedQueries = deepResearch.state.enrichedQueries; + + // Executar busca progressiva com semantic search se disponível const fallbackResult = await executeProgressiveSearch( query, async (q, limit) => { @@ -410,7 +415,10 @@ export default function ChatPage() { maxResultsPerRound: maxResults, maxTotalResults: 30, // Máximo 30 resultados por query minRelevanceScore: 0.3, - enableQueryExpansion: true, + enableQueryExpansion: !enrichedQueries, // Desabilitar expansão tradicional se usar enriquecimento + useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas + context: context || undefined, + enrichedQueries: enrichedQueries || undefined, } ); @@ -442,9 +450,25 @@ export default function ChatPage() { }); } + // Se houve falhas mas ainda tem resultados, avisar discretamente + const failedRounds = fallbackResult.attempts.filter(a => a.results.length === 0); + if (failedRounds.length > 0 && fallbackResult.scrapedSources.length > 0) { + updateThinkingMessage('web-research', { + details: `Consulta ${idx + 1}: Algumas tentativas falharam, mas ${fallbackResult.scrapedSources.length} resultados foram encontrados.`, + }); + } + return { query, results: fallbackResult.scrapedSources, fallbackResult }; } catch (err) { - chatLog.error(` ✗ Progressive search ${idx + 1} failed: ${err}`); + // Logar erro mas continuar (não quebrar o fluxo) + const errorMsg = err instanceof Error ? err.message : String(err); + chatLog.warn(` ⚠️ Progressive search ${idx + 1} failed (continuando): ${errorMsg}`); + + // Atualizar mensagem de processo com aviso + updateThinkingMessage('web-research', { + details: `Consulta ${idx + 1}: Erro na busca (${errorMsg}). Continuando com outras consultas...`, + }); + return { query, results: [] as ScrapedContent[], fallbackResult: null }; } }); @@ -563,25 +587,19 @@ export default function ChatPage() { } } catch (error) { + // Tratar erro graciosamente sem quebrar o fluxo const errorMsg = error instanceof Error ? error.message : String(error); - chatLog.error(`❌ Error in Deep Research pipeline: ${errorMsg}`); + chatLog.warn(`⚠️ Error in Deep Research pipeline (continuing with partial results): ${errorMsg}`); - // Atualizar mensagem de processo com erro + // Atualizar mensagem de processo com aviso (não erro fatal) updateThinkingMessage('web-research', { - status: 'error', - error: errorMsg, + status: 'completed', // Marcar como completed mesmo com erro parcial + details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`, duration: Date.now() - webResearchStart, }); - // Atualizar step com erro (para compatibilidade) - setProcessSteps(prev => prev.map(s => - s.id === 'web-research' - ? { ...s, status: 'error' as const, error: errorMsg } - : s - )); - - // Definir erro global - setError(error); + // Não definir erro global - permitir que o fluxo continue + // O sistema continuará com conhecimento interno se necessário } } else { if (!webSearch.isEnabled) { @@ -849,7 +867,12 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente const panel = chatsSidebarRef.current; if (panel) { if (isChatsSidebarCollapsed) { - panel.expand(); + // No mobile, apenas atualizar estado (overlay será mostrado) + if (typeof window !== 'undefined' && window.innerWidth < 768) { + setIsChatsSidebarCollapsed(false); + } else { + panel.expand(); + } } else { panel.collapse(); } @@ -905,7 +928,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente defaultSize={4} minSize={4} maxSize={4} - className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px]" + className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px] min-w-[60px] max-w-[60px] flex-shrink-0" > - + - {/* Sessions List */} + {/* Sessions List - Desktop: ResizablePanel, Mobile: Overlay */} setIsChatsSidebarCollapsed(true)} onExpand={() => setIsChatsSidebarCollapsed(false)} - className="min-w-[200px]" + className="min-w-0 md:min-w-[220px] md:max-w-[360px] hidden md:block" + style={{ + minWidth: 'clamp(220px, 20vw, 360px)', + maxWidth: '360px', + }} > { + handleSelectSession(id); + // Fechar sidebar no mobile após seleção + if (typeof window !== 'undefined' && window.innerWidth < 768) { + setIsChatsSidebarCollapsed(true); + chatsSidebarRef.current?.collapse(); + } + }} onDeleteSession={deleteSession} onNewChat={handleNewChat} /> - + {/* Main Chat Area */} - +
{/* Header */} -
+
- {isChatsSidebarCollapsed ? ( - - ) : ( - - )} -
- Chat + )} + +
+ Chat {isGeneratingTitle && } {isDownloading && ( @@ -984,40 +1022,62 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
-
-
- { + if (v === "__add_model__") { + setShowDownloadDialog(true); + // Não alterar selectedModel quando abrir dialog + } else { + setSelectedModel(v); + settings.setSelectedModel(v); + } + }} + > + + + + + + + {selectedModel && ( + +

{selectedModel}

+
+ )} +
+ + {models.map(m => ( + + + + + + {m.name} + + {m.size} + + + + {m.name.length > 40 && ( + +

{m.name}

+
+ )} +
+ ))} + + + + Adicionar modelo... - ))} - - - - Adicionar modelo... - - -
- + + +
+ )} + {index < settings.webSearch.engineOrder.length - 1 && ( + + )} +
+
+ ))} +
+
+ +
+ +
+
+ { + const v = parseInt(e.target.value || '1', 10); + const clamped = Math.min(10, Math.max(1, isNaN(v) ? 1 : v)); + settings.setWebSearchMinResultsPerEngine(clamped); + }} + className="w-24" + min={1} + max={10} + disabled={!settings.webSearch.enabled} + /> +
+ settings.setWebSearchMinResultsPerEngine(value)} + min={1} + max={10} + step={1} + disabled={!settings.webSearch.enabled} + /> +
+
+

+ Número mínimo de resultados para considerar um motor como bem-sucedido. Se um motor retornar menos resultados, o sistema tentará o próximo. +

+
+
+ +
+
+ +

+ Adiciona sinônimos e variantes às queries para melhorar resultados +

+
+ +
+ + {settings.webSearch.enableSemanticExpansion && ( +
+ + +

+ Idioma usado para identificar stopwords e sinônimos durante a expansão semântica. +

+
+ )} + + {/* Tab: Sources & Knowledge */} diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx index 483e24c..024ad07 100644 --- a/components/chat/chat-input.tsx +++ b/components/chat/chat-input.tsx @@ -112,14 +112,14 @@ export function ChatInput({ onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="Pergunte qualquer coisa..." - className={`min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-4 pl-6 pr-32 text-base sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${ + className={`min-h-[52px] sm:min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-3 sm:py-4 pl-4 sm:pl-6 pr-28 sm:pr-32 text-[16px] sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${ input.trim() && !isValid ? 'text-destructive' : '' }`} rows={1} /> {/* Right Actions */} -
+
{onWebSearchToggle && ( diff --git a/components/chat/chat-message.tsx b/components/chat/chat-message.tsx index 17daafe..f84cc15 100644 --- a/components/chat/chat-message.tsx +++ b/components/chat/chat-message.tsx @@ -123,3 +123,5 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) ); } + + diff --git a/components/chat/model-download-dialog.tsx b/components/chat/model-download-dialog.tsx index c4d434c..357415a 100644 --- a/components/chat/model-download-dialog.tsx +++ b/components/chat/model-download-dialog.tsx @@ -3,10 +3,12 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Label } from "@/components/ui/label"; -import { Download, Loader2, X } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Download, Loader2, X, Upload, Globe, AlertCircle, File } from "lucide-react"; import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +// Usar file dialog do Tauri v2 interface ModelDownloadDialogProps { open: boolean; @@ -24,6 +26,7 @@ interface DownloadInfo { } export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) { + const [activeTab, setActiveTab] = useState<"download" | "local">("download"); const [modelName, setModelName] = useState(""); const [isDownloading, setIsDownloading] = useState(false); const [downloadProgress, setDownloadProgress] = useState(0); @@ -33,6 +36,12 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown const [isInstalled, setIsInstalled] = useState(false); const isDownloadingRef = useRef(false); const modelNameRef = useRef(""); + + // Estados para instalação local + const [isInstallingLocal, setIsInstallingLocal] = useState(false); + const [localInstallProgress, setLocalInstallProgress] = useState(0); + const [localInstallError, setLocalInstallError] = useState(null); + const [localModelName, setLocalModelName] = useState(""); // Atualizar refs quando estado muda useEffect(() => { @@ -43,6 +52,7 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown // Resetar estado quando dialog abre/fecha useEffect(() => { if (open) { + setActiveTab("download"); setModelName(""); setIsDownloading(false); setDownloadProgress(0); @@ -52,6 +62,11 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown setIsInstalled(false); isDownloadingRef.current = false; modelNameRef.current = ""; + // Reset estados locais + setIsInstallingLocal(false); + setLocalInstallProgress(0); + setLocalInstallError(null); + setLocalModelName(""); } }, [open]); @@ -243,6 +258,69 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown } }; + const handleSelectFileButton = async () => { + try { + // Usar comando Rust para abrir dialog nativo do sistema + // Isso evita limitações do input file HTML5 com arquivos grandes + const selectedPath = await invoke('open_gguf_file_dialog'); + + if (!selectedPath) { + // Usuário cancelou + return; + } + + setIsInstallingLocal(true); + setLocalInstallError(null); + setLocalInstallProgress(0); + + try { + setLocalInstallProgress(10); + + // Extrair nome do arquivo do caminho + const pathParts = selectedPath.split(/[/\\]/); + const fileName = pathParts[pathParts.length - 1] || 'model'; + const fileNameLower = fileName.toLowerCase(); + const hasGgufExtension = fileNameLower.endsWith('.gguf'); + + // Extrair nome do modelo (remover extensão se for .gguf, senão manter) + const fileNameWithoutExt = hasGgufExtension + ? fileName.replace(/\.gguf$/i, '') + : fileName; + const modelNameToUse = localModelName.trim() || fileNameWithoutExt; + + setLocalInstallProgress(30); + + // Instalar diretamente do caminho (sem precisar copiar para temp primeiro) + const installedModelName = await invoke('install_gguf_model', { + filePath: selectedPath, + modelName: modelNameToUse || undefined, + }); + + setLocalInstallProgress(100); + + await new Promise(resolve => setTimeout(resolve, 500)); + + if (onSuccess) { + onSuccess(installedModelName); + } + + setTimeout(() => { + onOpenChange(false); + }, 1000); + + } catch (err: any) { + const errorMsg = err?.message || 'Erro ao instalar modelo GGUF'; + setLocalInstallError(errorMsg); + console.error('Erro ao instalar modelo GGUF:', err); + } finally { + setIsInstallingLocal(false); + } + } catch (err: any) { + console.error('Erro ao abrir dialog de arquivo:', err); + setLocalInstallError('Erro ao abrir seletor de arquivo'); + } + }; + const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim()); return ( @@ -251,114 +329,198 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown handleClose(); } }}> - + Adicionar Modelo - Digite o nome do modelo do Ollama que deseja baixar (ex: llama3.2:1b, mistral:latest) + Baixe modelos do Ollama ou instale arquivos GGUF locais -
-
- - setModelName(e.target.value)} - placeholder="ex: llama3.2:1b" - disabled={isDownloading} - onKeyDown={(e) => { - if (e.key === 'Enter' && isValid && !isDownloading) { - handleDownload(); - } - }} - /> - {error && ( -

{error}

- )} - {isInstalled && !isDownloading && ( -

- ✓ Este modelo já está instalado -

- )} -
- - {isDownloading && ( + setActiveTab(v as "download" | "local")} className="w-full"> + + + + Baixar do Ollama + + + + Instalar Local + + + +
-
- {downloadStatus || "Iniciando..."} - {downloadProgress}% -
- - {downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && ( -
- - {downloadInfo.downloaded && downloadInfo.total - ? `${downloadInfo.downloaded} / ${downloadInfo.total}` - : downloadInfo.downloaded || ""} - - {downloadInfo.speed && ( - {downloadInfo.speed} - )} -
+ + setModelName(e.target.value)} + placeholder="ex: llama3.2:1b" + disabled={isDownloading} + onKeyDown={(e) => { + if (e.key === 'Enter' && isValid && !isDownloading) { + handleDownload(); + } + }} + /> + {error && ( +

{error}

+ )} + {isInstalled && !isDownloading && ( +

+ ✓ Este modelo já está instalado +

)}
- )} - {isInstalled && !isDownloading && ( -
-

- O modelo está instalado e pronto para uso. -

+ {isDownloading && ( +
+
+ {downloadStatus || "Iniciando..."} + {downloadProgress}% +
+ + {downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && ( +
+ + {downloadInfo.downloaded && downloadInfo.total + ? `${downloadInfo.downloaded} / ${downloadInfo.total}` + : downloadInfo.downloaded || ""} + + {downloadInfo.speed && ( + {downloadInfo.speed} + )} +
+ )} +
+ )} + + {isInstalled && !isDownloading && ( +
+

+ O modelo está instalado e pronto para uso. +

+
+ )} + + + +
+
+ + setLocalModelName(e.target.value)} + placeholder="Deixe vazio para usar o nome do arquivo" + disabled={isInstallingLocal} + /> +

+ Se não especificar, o nome será extraído do arquivo +

+
+ +
+ {isInstallingLocal ? ( +
+
+ +
+

Instalando modelo...

+

+ Processando arquivo... +

+
+
+ + {localInstallProgress > 0 && ( +
+ +

+ {localInstallProgress}% +

+
+ )} +
+ ) : ( + <> +
+ +

+ Selecione um arquivo GGUF para instalar localmente +

+ +
+ + {localInstallError && ( +
+
+ +
+

Erro ao instalar modelo

+

{localInstallError}

+
+
+
+ )} + + )} +
+ +
+

ℹ️ Modelos instalados localmente

+

Funcionam sem conexão à internet. O arquivo será copiado para o diretório do Ollama.

+
- )} -
+
+
- + {activeTab === "download" && ( + + )} diff --git a/components/chat/prompt-generator.tsx b/components/chat/prompt-generator.tsx index b6da570..0bd6077 100644 --- a/components/chat/prompt-generator.tsx +++ b/components/chat/prompt-generator.tsx @@ -2,6 +2,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Sparkles, Loader2 } from "lucide-react"; import { useState, useEffect } from "react"; import { usePromptGenerator } from "@/hooks/use-prompt-generator"; @@ -92,30 +93,54 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp - + + +

Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).

diff --git a/components/chat/sidebar-list.tsx b/components/chat/sidebar-list.tsx index 2800294..f3fc98e 100644 --- a/components/chat/sidebar-list.tsx +++ b/components/chat/sidebar-list.tsx @@ -1,6 +1,7 @@ import { SessionSummary } from "@/hooks/use-chat-storage"; import { Button } from "@/components/ui/button"; -import { Trash2, Plus } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Trash2, Plus, MessageSquare } from "lucide-react"; import { cn } from "@/lib/utils"; interface SidebarListProps { @@ -13,11 +14,36 @@ interface SidebarListProps { function formatDate(dateString: string) { const date = new Date(dateString); + const now = new Date(); + const diffInMs = now.getTime() - date.getTime(); + const diffInHours = diffInMs / (1000 * 60 * 60); + const diffInDays = diffInMs / (1000 * 60 * 60 * 24); + + // Se foi hoje, mostrar apenas hora + if (diffInHours < 24) { + return new Intl.DateTimeFormat('pt-BR', { + hour: '2-digit', + minute: '2-digit' + }).format(date); + } + + // Se foi ontem + if (diffInDays < 2) { + return 'Ontem'; + } + + // Se foi nos últimos 7 dias + if (diffInDays < 7) { + return new Intl.DateTimeFormat('pt-BR', { + weekday: 'short' + }).format(date); + } + + // Caso contrário, mostrar data completa return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', - hour: '2-digit', - minute: '2-digit' + year: diffInDays > 365 ? 'numeric' : undefined }).format(date); } @@ -29,61 +55,122 @@ export function SidebarList({ onNewChat }: SidebarListProps) { return ( -
-
- -
- -
-
- {sessions.length === 0 && ( -
- Nenhuma conversa salva -
- )} - - {sessions.map((session) => ( -
onSelectSession(session.id)} - > -
- - {session.emoji || '💬'} - -
- - {session.title} - - - {formatDate(session.updated_at)} - -
+ +
+ {/* Header com botão Nova Conversa */} +
+ +
+ + {/* Lista de chats com scroll customizado */} +
+
+ {sessions.length === 0 && ( +
+ +

+ Nenhuma conversa salva +

+

+ Comece uma nova conversa para começar +

+ )} + + {sessions.map((session) => { + const isSelected = currentSessionId === session.id; + const showTooltip = session.title.length > 30; - -
- ))} + return ( + + +
onSelectSession(session.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectSession(session.id); + } + }} + > + {/* Conteúdo do card */} +
+ {/* Ícone/Emoji */} +
+ {session.emoji || '💬'} +
+ + {/* Texto do card */} +
+

+ {session.title} +

+ +

+ {formatDate(session.updated_at)} +

+
+ + {/* Botão de deletar - aparece no hover */} + +
+ + {/* Indicador de seleção */} + {isSelected && ( +
+ )} +
+ + {showTooltip && ( + +

{session.title}

+

+ {formatDate(session.updated_at)} +

+
+ )} + + ); + })} +
-
+ ); } diff --git a/components/chat/system-panel.tsx b/components/chat/system-panel.tsx index 5ac2ce6..41b31ea 100644 --- a/components/chat/system-panel.tsx +++ b/components/chat/system-panel.tsx @@ -1,13 +1,18 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; -import { Trash2, HardDrive, Cpu, Database } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Trash2, HardDrive, Cpu, Database, Upload, Globe } from "lucide-react"; import { useSystemMonitor } from "@/hooks/use-system-monitor"; import { useLocalModels } from "@/hooks/use-local-models"; +import { useState } from "react"; +import { ModelDownloadDialog } from "./model-download-dialog"; export function SystemPanel() { const stats = useSystemMonitor(); const { models, deleteModel, refresh } = useLocalModels(); + const [showDownloadDialog, setShowDownloadDialog] = useState(false); const ramUsagePercent = stats ? (stats.memory_used / stats.memory_total) * 100 @@ -63,42 +68,103 @@ export function SystemPanel() {

Modelos Instalados

- +
+ + +
- {models.map((model) => ( - - -
-
- {model.name} + {models.map((model) => { + // Tentar detectar se é modelo local/GGUF (heurística: modelos GGUF geralmente têm nomes específicos) + // Por enquanto, vamos mostrar todos como disponíveis + const isLocal = model.name.toLowerCase().includes('gguf') || + model.name.toLowerCase().endsWith('.gguf'); + + return ( + + +
+
+
+ {model.name} +
+ {isLocal && ( + + + Local + + )} +
+
+ + {model.size} +
-
- - {model.size} -
-
- - - - ))} + + + + + + +

Remover modelo

+
+
+
+ + + ); + })} {models.length === 0 && ( -
- Nenhum modelo encontrado. +
+

Nenhum modelo encontrado.

+
)}
+ + { + refresh(); + setShowDownloadDialog(false); + }} + />
); } diff --git a/components/notifications/welcome-notification.tsx b/components/notifications/welcome-notification.tsx index 8603ecf..2d896c7 100644 --- a/components/notifications/welcome-notification.tsx +++ b/components/notifications/welcome-notification.tsx @@ -65,3 +65,5 @@ export function WelcomeNotification() { + + diff --git a/components/settings/GpuDashboard.tsx b/components/settings/GpuDashboard.tsx new file mode 100644 index 0000000..b3a5b40 --- /dev/null +++ b/components/settings/GpuDashboard.tsx @@ -0,0 +1,271 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; +import { Monitor, Thermometer, Zap, Fan, Cpu, HardDrive, Activity } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +interface GpuStats { + id: string; + name: string; + vendor: string | null; + vram_used_mb: number | null; + vram_total_mb: number | null; + vram_percent: number | null; + compute_usage_percent: number | null; + graphics_usage_percent: number | null; + overall_usage_percent: number | null; + temperature_celsius: number | null; + temperature_max_celsius: number | null; + power_watts: number | null; + power_max_watts: number | null; + fan_speed_rpm: number | null; + fan_speed_percent: number | null; + processes_count: number | null; + driver_version: string | null; + api: string | null; +} + +interface GpuDashboardProps { + gpuId?: string; + pollInterval?: number; // em milissegundos, padrão 2000ms +} + +export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps) { + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadStats = async () => { + try { + const data = await invoke('get_gpu_stats', { gpuId: gpuId || null }); + setStats(data); + setError(null); + setIsLoading(false); + } catch (err) { + console.error('Failed to load GPU stats:', err); + setError(err instanceof Error ? err.message : 'Erro ao carregar estatísticas da GPU'); + setIsLoading(false); + } + }; + + loadStats(); + const interval = setInterval(loadStats, pollInterval); + + return () => clearInterval(interval); + }, [gpuId, pollInterval]); + + if (isLoading) { + return ( + + + Carregando estatísticas da GPU... + + + ); + } + + if (error || !stats) { + return ( + + + {error || 'GPU não detectada ou sem suporte para monitoramento detalhado'} + + + ); + } + + const formatBytes = (mb: number | null): string => { + if (!mb) return '-'; + if (mb < 1024) return `${mb} MB`; + return `${(mb / 1024).toFixed(2)} GB`; + }; + + const getStatusColor = (value: number | null, thresholds: { good: number; warning: number }): string => { + if (!value) return 'bg-muted'; + if (value < thresholds.good) return 'bg-green-500'; + if (value < thresholds.warning) return 'bg-yellow-500'; + return 'bg-red-500'; + }; + + const getTemperatureColor = (temp: number | null): string => { + if (!temp) return 'text-muted-foreground'; + if (temp < 60) return 'text-green-500'; + if (temp < 80) return 'text-yellow-500'; + return 'text-red-500'; + }; + + return ( +
+ {/* Card Principal da GPU */} + + +
+ + + GPU + +
+ {stats.vendor && ( + + {stats.vendor} + + )} + {stats.api && ( + + {stats.api} + + )} +
+
+

+ {stats.name} +

+ {stats.driver_version && ( +

Driver: {stats.driver_version}

+ )} +
+ + {/* VRAM */} + {stats.vram_total_mb && ( +
+
+ + + Memória VRAM + + + {formatBytes(stats.vram_used_mb)} / {formatBytes(stats.vram_total_mb)} + {stats.vram_percent && ` (${stats.vram_percent.toFixed(1)}%)`} + +
+ {stats.vram_percent !== null && ( + + )} +
+ )} + + {/* Uso de Processamento */} + {stats.overall_usage_percent !== null && ( +
+
+ + + Uso de Processamento + + {stats.overall_usage_percent.toFixed(1)}% +
+ + {stats.compute_usage_percent !== null && stats.graphics_usage_percent !== null && ( +
+ Compute: {stats.compute_usage_percent.toFixed(1)}% + Graphics: {stats.graphics_usage_percent.toFixed(1)}% +
+ )} +
+ )} + + {/* Grid de Métricas */} +
+ {/* Temperatura */} + {stats.temperature_celsius !== null && ( +
+
+ + Temperatura +
+
+ + {stats.temperature_celsius.toFixed(0)} + + °C + {stats.temperature_max_celsius && ( + + / {stats.temperature_max_celsius.toFixed(0)}°C + + )} +
+
+ )} + + {/* Energia */} + {stats.power_watts !== null && ( +
+
+ + Energia +
+
+ + {stats.power_watts.toFixed(1)} + + W + {stats.power_max_watts && ( + + / {stats.power_max_watts.toFixed(1)}W + + )} +
+
+ )} + + {/* Ventilador */} + {(stats.fan_speed_percent !== null || stats.fan_speed_rpm !== null) && ( +
+
+ + Ventilador +
+
+ {stats.fan_speed_percent !== null && ( + <> + + {stats.fan_speed_percent.toFixed(0)} + + % + + )} + {stats.fan_speed_rpm !== null && ( + + ({stats.fan_speed_rpm} RPM) + + )} +
+
+ )} + + {/* Processos */} + {stats.processes_count !== null && ( +
+
+ + Processos +
+
+ + {stats.processes_count} + + + {stats.processes_count === 1 ? 'processo' : 'processos'} + +
+
+ )} +
+
+
+
+ ); +} + + diff --git a/components/settings/HardwareDashboard.tsx b/components/settings/HardwareDashboard.tsx index 648d185..2fa5a82 100644 --- a/components/settings/HardwareDashboard.tsx +++ b/components/settings/HardwareDashboard.tsx @@ -6,6 +6,7 @@ import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; import { Cpu, MemoryStick, HardDrive, Activity, Clock, Monitor } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; +import { GpuDashboard } from './GpuDashboard'; interface SystemStats { cpu_usage: number; @@ -85,7 +86,8 @@ export function HardwareDashboard() { }; return ( -
+
+
{/* CPU Card */} @@ -135,25 +137,6 @@ export function HardwareDashboard() { - {/* GPU Card */} - - - - - GPU - - - -
- {stats.gpu_name ? ( - {stats.gpu_name} - ) : ( - Não detectada - )} -
-
-
- {/* System Info Card */} @@ -207,6 +190,10 @@ export function HardwareDashboard() {
+
+ + {/* GPU Dashboard - Substitui o card simples */} +
); } diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 58d32a4..990b2b5 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 [&>span]:truncate [&>span]:min-w-0", className )} {...props} diff --git a/data/prompts/deep-research.ts b/data/prompts/deep-research.ts index fe10d89..b5c5a32 100644 --- a/data/prompts/deep-research.ts +++ b/data/prompts/deep-research.ts @@ -1,18 +1,40 @@ export const DEEP_RESEARCH_PROMPTS = { - DECOMPOSITION: `Role: Research Lead. -Task: Decompose the user's query into 3-5 atomic, search-friendly questions to cover different angles. -Output: JSON array of strings ONLY. No markdown, no explanations. + DECOMPOSITION: `Role: Research Lead specializing in web search optimization. +Task: Decompose the user's query into 3-5 atomic, search-friendly questions optimized for web search engines. User Query: "{{userQuery}}" -Constraints: -1. PRESERVE THE SUBJECT: If the user asks about "Garuva", ALL queries must mention "Garuva". -2. DO NOT TRANSLATE proper names or specific terms unless necessary. -3. AVOID generic queries like "what is this" without context. -4. Return ONLY a JSON array of strings. +## CRITICAL INSTRUCTIONS -Example Output for "Onde fica Garuva?": -["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"]`, +1. **PRESERVE THE SUBJECT**: If the user asks about "Garuva", ALL queries must mention "Garuva". +2. **USE SPECIFIC KEYWORDS**: Include relevant technical terms, proper names, locations, dates, or context that will help search engines find precise results. +3. **AVOID GENERIC QUERIES**: Never create queries like "what is this" or "tell me about" without specific context. Instead, use descriptive terms. +4. **OPTIMIZE FOR SEARCH ENGINES**: + - Use terms that are likely to appear in search results + - Include synonyms or related terms when appropriate + - For academic/research queries, include terms like "estudo", "pesquisa", "resultado", "premiado" + - For location queries, include geographic context (state, country, region) +5. **COVER DIFFERENT ANGLES**: Each query should explore a different aspect or perspective of the original question. +6. **DO NOT TRANSLATE**: Preserve proper names, technical terms, and specific terminology in their original form. +7. **OUTPUT FORMAT**: Return ONLY a JSON array of strings. No markdown, no explanations, no additional text. + +## EXAMPLES + +Input: "Onde fica Garuva?" +Output: ["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"] + +Input: "quais os ultimos resultados academicos mais premiados?" +Output: ["resultados academicos premiados 2024", "pesquisas cientificas premiadas recentes", "estudos academicos reconhecidos premios", "descobertas academicas premiadas"] + +Input: "como funciona inteligencia artificial?" +Output: ["inteligencia artificial como funciona", "IA machine learning algoritmos", "sistemas inteligencia artificial aplicacoes"] + +## YOUR TASK + +Decompose this query into search-optimized sub-queries: +"{{userQuery}}" + +Return ONLY a JSON array of strings:`, CONTEXT_CHECK: `Role: Fact Checker. Task: Analyze the Knowledge Base below and determine if it contains sufficient information to answer the user's query. @@ -59,9 +81,47 @@ When citing sources, use this format: - For inline citations: "Garuva está localizada em Santa Catarina [ID: 1]." - For multiple sources: "A cidade possui praias e montanhas [ID: 1, 3]." - At the end, list all sources used with their URLs. -` +`, + + QUERY_ENRICHMENT: `Você é um especialista em otimização de queries de busca e expansão semântica. + +Com base na query original e no contexto analisado, gere múltiplas variantes de queries enriquecidas para maximizar a cobertura de resultados relevantes. + +## QUERY ORIGINAL +"{{originalQuery}}" + +## CONTEXTO ANALISADO +- Intenção: {{intent}} +- Tópicos: {{topics}} +- Entidades: {{entities}} +- Contexto Temporal: {{temporalContext}} +- Contexto Geográfico: {{geographicContext}} +- Palavras-chave: {{keywords}} +- Sinônimos: {{synonyms}} +- Domínio: {{domain}} + +## INSTRUÇÕES + +Gere queries enriquecidas nas seguintes categorias: + +1. **LITERAL**: Variações diretas da query original (preservar termos principais) +2. **SEMANTIC**: Variantes semânticas usando sinônimos e reformulações +3. **RELATED**: Perguntas relacionadas e FAQs +4. **EXPANDED**: Expansões temáticas específicas +5. **CONTEXTUAL**: Queries com contexto adicional (datas, locais, domínios) + +## REGRAS CRÍTICAS + +- **PRESERVAR ENTIDADES**: Se a query menciona uma entidade, TODAS as queries devem mencioná-la +- **NÃO TRADUZIR**: Preservar nomes próprios, termos técnicos na forma original +- **OTIMIZAR PARA BUSCA**: Usar termos que aparecem em resultados reais +- **COBERTURA AMPLA**: Cada categoria deve explorar diferentes ângulos +- **QUALIDADE > QUANTIDADE**: 3-5 queries por categoria é suficiente + +Retorne APENAS um JSON válido com arrays para cada categoria.` }; + diff --git a/hooks/use-deep-research.ts b/hooks/use-deep-research.ts index 46efbe7..bb45b91 100644 --- a/hooks/use-deep-research.ts +++ b/hooks/use-deep-research.ts @@ -4,6 +4,8 @@ import type { ScrapedContent } from '@/services/webSearch'; import { deepResearchLog as log } from '@/lib/terminal-logger'; import { condenseKnowledgeBase, validateCondensedContext } from '@/lib/knowledge-base-processor'; import type { CondensationResult } from '@/lib/knowledge-base-processor'; +import { analyzeQueryContext, type QueryContext } from '@/lib/contextual-analyzer'; +import { enrichQueries, type EnrichedQueries } from '@/lib/query-enricher'; export type DeepResearchStep = 'idle' | 'planning' | 'searching' | 'aggregating' | 'validating' | 'formulating' | 'complete' | 'error'; @@ -32,6 +34,8 @@ export interface DeepResearchState { validationReport: string; error?: string; logs: DeepResearchLog[]; + context?: QueryContext; + enrichedQueries?: EnrichedQueries; } // ========== LOGGING UTILITIES ========== @@ -177,6 +181,49 @@ export function useDeepResearch() { } }; + // --- CONTEXTUAL ANALYSIS --- + const analyzeContext = useCallback(async (query: string, model: string): Promise => { + logStep('--- Contextual Analysis ---'); + try { + const context = await analyzeQueryContext(query, model); + logData('Context Analysis', { + intent: context.intent, + entities: context.entities.length, + topics: context.topics.length, + keywords: context.keywords.length, + }); + setState(s => ({ ...s, context })); + return context; + } catch (error) { + log.warn('Contextual analysis failed, continuing without context:', error); + return null; + } + }, []); + + // --- QUERY ENRICHMENT --- + const enrich = useCallback(async (query: string, context: QueryContext | null, model: string): Promise => { + if (!context) { + return null; + } + + logStep('--- Query Enrichment ---'); + try { + const enriched = await enrichQueries(query, context, model); + logData('Enriched Queries', { + literal: enriched.literal.length, + semantic: enriched.semantic.length, + related: enriched.related.length, + expanded: enriched.expanded.length, + contextual: enriched.contextual.length, + }); + setState(s => ({ ...s, enrichedQueries: enriched })); + return enriched; + } catch (error) { + log.warn('Query enrichment failed, continuing without enrichment:', error); + return null; + } + }, []); + // --- DECOMPOSITION --- const decompose = useCallback(async (query: string, model: string) => { logSection('DECOMPOSITION'); @@ -186,6 +233,12 @@ export function useDeepResearch() { setState(s => ({ ...s, step: 'planning', error: undefined, logs: [], knowledgeBase: [] })); try { + // Análise contextual (opcional, não bloqueia se falhar) + const context = await analyzeContext(query, model); + + // Enriquecimento de queries (opcional, não bloqueia se falhar) + const enriched = context ? await enrich(query, context, model) : null; + const prompt = DEEP_RESEARCH_PROMPTS.DECOMPOSITION.replace('{{userQuery}}', query); logStep('Sending decomposition prompt to LLM...'); const response = await callLLM(model, prompt, true); diff --git a/hooks/use-operating-system.ts b/hooks/use-operating-system.ts index db22302..7b4ae40 100644 --- a/hooks/use-operating-system.ts +++ b/hooks/use-operating-system.ts @@ -54,3 +54,5 @@ export function useOperatingSystem() { + + diff --git a/hooks/use-web-search.ts b/hooks/use-web-search.ts index 791efc5..0fd6f0a 100644 --- a/hooks/use-web-search.ts +++ b/hooks/use-web-search.ts @@ -147,7 +147,13 @@ export function useWebSearch() { // Etapa 1: metadados setState(prev => ({ ...prev, status: 'searching' })); - const { metadata, contents } = await webSearchService.smartSearchRag(query, limit, searchConfig); + const timeoutMs = settings.webSearch.timeout || 15000; + const { metadata, contents } = await webSearchService.smartSearchRag( + query, + limit, + searchConfig, + timeoutMs + ); // Etapa 2: scraping setState(prev => ({ ...prev, status: 'scraping' })); @@ -162,16 +168,22 @@ export function useWebSearch() { error: null, })); + // Retornar resultados mesmo se parciais (não quebrar o fluxo) return contents; } catch (error) { + // Se houver erro, logar mas retornar array vazio (não quebrar o fluxo) const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag'; + console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage); + setState(prev => ({ ...prev, status: 'error', error: errorMessage, scrapedSources: [], })); - throw error; + + // Retornar vazio em vez de throw para não quebrar o fluxo + return []; } }, []); diff --git a/lib/context-unifier.ts b/lib/context-unifier.ts new file mode 100644 index 0000000..7b09ed4 --- /dev/null +++ b/lib/context-unifier.ts @@ -0,0 +1,320 @@ +import type { ResearchEntry } from '@/hooks/use-deep-research'; +import type { QueryContext } from './contextual-analyzer'; +import { chatLog } from './terminal-logger'; + +/** + * Fato extraído de uma fonte + */ +export interface ExtractedFact { + fact: string; + sourceId: string; + sourceUrl: string; + sourceTitle: string; + confidence: number; // 0-1 + entities?: string[]; // Entidades mencionadas no fato +} + +/** + * Resultado da unificação de contexto + */ +export interface UnifiedContext { + unifiedText: string; + keyFacts: ExtractedFact[]; + contradictions: Array<{ + fact1: ExtractedFact; + fact2: ExtractedFact; + description: string; + }>; + sources: Array<{ + id: string; + title: string; + url: string; + factsCount: number; + }>; + totalFacts: number; + uniqueFacts: number; +} + +/** + * Prompt para extração de fatos usando LLM + */ +const FACT_EXTRACTION_PROMPT = `Você é um especialista em extração de fatos e análise de informações. + +Analise o conteúdo abaixo e extraia os fatos principais relevantes para responder à query do usuário. + +## QUERY DO USUÁRIO +"{{query}}" + +## CONTEXTO ANALISADO +- Intenção: {{intent}} +- Tópicos: {{topics}} +- Entidades: {{entities}} + +## CONTEÚDO DA FONTE +Título: {{title}} +URL: {{url}} + +{{content}} + +## INSTRUÇÕES + +Extraia os fatos principais do conteúdo acima que são relevantes para responder à query do usuário. + +Para cada fato: +- Seja específico e factual +- Inclua entidades mencionadas (pessoas, lugares, datas, conceitos) +- Mantenha o contexto necessário para entender o fato +- Evite informações genéricas ou óbvias + +## FORMATO DE SAÍDA + +Retorne APENAS um JSON válido com um array de fatos: + +[ + { + "fact": "Fato específico extraído do conteúdo", + "entities": ["entidade1", "entidade2"], + "confidence": 0.9 + }, + ... +] + +IMPORTANTE: +- Retorne APENAS o JSON, sem markdown, sem explicações +- Confidence deve ser entre 0 e 1 (0.9 = muito confiável, 0.5 = incerto) +- Extraia 3-8 fatos principais, não mais +- Fatos devem ser independentes e específicos`; + +/** + * Extrai fatos principais de uma entrada de pesquisa + */ +export async function extractKeyFacts( + entry: ResearchEntry, + query: string, + context: QueryContext | null, + model: string +): Promise { + try { + const entitiesStr = context?.entities.map(e => `${e.type}: ${e.value}`).join(', ') || 'nenhuma'; + const topicsStr = context?.topics.join(', ') || 'nenhum'; + const intentStr = context?.intent || 'unknown'; + + const prompt = FACT_EXTRACTION_PROMPT + .replace('{{query}}', query) + .replace('{{intent}}', intentStr) + .replace('{{topics}}', topicsStr) + .replace('{{entities}}', entitiesStr) + .replace('{{title}}', entry.title) + .replace('{{url}}', entry.sourceUrl) + .replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho + + const response = await fetch('http://localhost:11434/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + prompt, + stream: false, + options: { + temperature: 0.3, + num_predict: 600, + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const rawResponse = (data.response || '').trim(); + + // Limpar e extrair JSON + let jsonText = rawResponse + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + + const jsonMatch = jsonText.match(/\[[\s\S]*\]/); + if (jsonMatch) { + jsonText = jsonMatch[0]; + } + + const facts = JSON.parse(jsonText) as Array<{ + fact: string; + entities?: string[]; + confidence: number; + }>; + + return facts.map(f => ({ + fact: f.fact, + sourceId: entry.id, + sourceUrl: entry.sourceUrl, + sourceTitle: entry.title, + confidence: f.confidence || 0.7, + entities: f.entities || [], + })); + } catch (error) { + chatLog.warn(`[ContextUnifier] Erro ao extrair fatos de ${entry.title}:`, error); + // Fallback: criar fato básico do título + return [{ + fact: entry.title, + sourceId: entry.id, + sourceUrl: entry.sourceUrl, + sourceTitle: entry.title, + confidence: 0.5, + }]; + } +} + +/** + * Detecta contradições entre fatos + */ +function detectContradictions(facts: ExtractedFact[]): Array<{ + fact1: ExtractedFact; + fact2: ExtractedFact; + description: string; +}> { + const contradictions: Array<{ + fact1: ExtractedFact; + fact2: ExtractedFact; + description: string; + }> = []; + + // Comparar fatos por similaridade de entidades + for (let i = 0; i < facts.length; i++) { + for (let j = i + 1; j < facts.length; j++) { + const fact1 = facts[i]; + const fact2 = facts[j]; + + // Verificar se mencionam as mesmas entidades + const sharedEntities = fact1.entities?.filter(e => + fact2.entities?.includes(e) + ) || []; + + if (sharedEntities.length > 0) { + // Verificar se os fatos são contraditórios (heurística simples) + const fact1Lower = fact1.fact.toLowerCase(); + const fact2Lower = fact2.fact.toLowerCase(); + + // Palavras que indicam contradição + const contradictionIndicators = [ + fact1Lower.includes('não') && fact2Lower.includes('sim'), + fact1Lower.includes('sim') && fact2Lower.includes('não'), + fact1Lower.includes('falso') && !fact2Lower.includes('falso'), + !fact1Lower.includes('falso') && fact2Lower.includes('falso'), + ]; + + if (contradictionIndicators.some(Boolean)) { + contradictions.push({ + fact1, + fact2, + description: `Possível contradição sobre ${sharedEntities.join(', ')}`, + }); + } + } + } + } + + return contradictions; +} + +/** + * Agrupa fatos similares + */ +function groupSimilarFacts(facts: ExtractedFact[]): Map { + const groups = new Map(); + + for (const fact of facts) { + // Criar chave baseada em entidades principais + const key = fact.entities?.slice(0, 2).join('|') || fact.fact.substring(0, 50); + + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(fact); + } + + return groups; +} + +/** + * Unifica contexto de múltiplas fontes + */ +export async function unifyContext( + entries: ResearchEntry[], + query: string, + context: QueryContext | null, + model: string +): Promise { + chatLog.info(`[ContextUnifier] Unificando contexto de ${entries.length} fontes`); + + // Extrair fatos de todas as fontes + const allFacts: ExtractedFact[] = []; + const factPromises = entries.map(entry => extractKeyFacts(entry, query, context, model)); + const factArrays = await Promise.all(factPromises); + + for (const facts of factArrays) { + allFacts.push(...facts); + } + + chatLog.info(`[ContextUnifier] Extraídos ${allFacts.length} fatos de ${entries.length} fontes`); + + // Agrupar fatos similares + const factGroups = groupSimilarFacts(allFacts); + + // Detectar contradições + const contradictions = detectContradictions(allFacts); + + if (contradictions.length > 0) { + chatLog.warn(`[ContextUnifier] Detectadas ${contradictions.length} possíveis contradições`); + } + + // Criar texto unificado + const unifiedSections: string[] = []; + const sourceMap = new Map(); + + // Agrupar por fonte + for (const entry of entries) { + const entryFacts = allFacts.filter(f => f.sourceId === entry.id); + + if (entryFacts.length > 0) { + sourceMap.set(entry.id, { + title: entry.title, + url: entry.sourceUrl, + factsCount: entryFacts.length, + }); + + unifiedSections.push(`## ${entry.title}`); + unifiedSections.push(`Fonte: ${entry.sourceUrl}\n`); + + // Adicionar fatos principais + entryFacts + .sort((a, b) => b.confidence - a.confidence) + .slice(0, 5) // Top 5 fatos por fonte + .forEach(fact => { + unifiedSections.push(`- ${fact.fact}`); + }); + + unifiedSections.push(''); + } + } + + const unifiedText = unifiedSections.join('\n'); + + // Remover duplicatas de fatos (mesmo texto) + const uniqueFacts = Array.from( + new Map(allFacts.map(f => [f.fact.toLowerCase(), f])).values() + ); + + return { + unifiedText, + keyFacts: allFacts.sort((a, b) => b.confidence - a.confidence), + contradictions, + sources: Array.from(sourceMap.values()), + totalFacts: allFacts.length, + uniqueFacts: uniqueFacts.length, + }; +} + diff --git a/lib/contextual-analyzer.ts b/lib/contextual-analyzer.ts new file mode 100644 index 0000000..fcb3b13 --- /dev/null +++ b/lib/contextual-analyzer.ts @@ -0,0 +1,276 @@ +import { chatLog } from './terminal-logger'; + +/** + * Tipos de intenção da query + */ +export type QueryIntent = + | 'factual' // Busca por fatos específicos + | 'comparison' // Comparação entre entidades + | 'how-to' // Instruções ou tutoriais + | 'definition' // Definição de conceito + | 'list' // Listagem de itens + | 'opinion' // Opiniões ou análises + | 'temporal' // Informações temporais (quando, histórico) + | 'causal' // Causa e efeito (por que, como funciona) + | 'unknown'; // Não identificado + +/** + * Entidade extraída da query + */ +export interface ExtractedEntity { + type: 'person' | 'location' | 'date' | 'concept' | 'organization' | 'event' | 'product' | 'other'; + value: string; + confidence: number; // 0-1 +} + +/** + * Relacionamento entre entidades + */ +export interface EntityRelationship { + from: string; + to: string; + relation: string; // ex: "localizado em", "criado por", "relacionado com" + confidence: number; +} + +/** + * Contexto temporal extraído + */ +export interface TemporalContext { + period?: string; // ex: "2024", "últimos 5 anos" + date?: string; // Data específica + relative?: string; // ex: "recente", "atual", "histórico" +} + +/** + * Contexto geográfico extraído + */ +export interface GeographicContext { + location?: string; // Cidade, estado, país + region?: string; // Região, continente + coordinates?: { lat: number; lng: number }; +} + +/** + * Análise contextual completa da query + */ +export interface QueryContext { + originalQuery: string; + intent: QueryIntent; + entities: ExtractedEntity[]; + topics: string[]; // Tópicos principais identificados + relationships: EntityRelationship[]; // Relacionamentos entre entidades + implicitQuestions: string[]; // Perguntas implícitas na query + temporalContext?: TemporalContext; + geographicContext?: GeographicContext; + keywords: string[]; // Palavras-chave importantes + synonyms: string[]; // Sinônimos relevantes + domain?: string; // Domínio (ex: "acadêmico", "tecnologia", "saúde") + complexity: 'simple' | 'moderate' | 'complex'; +} + +/** + * Prompt para análise contextual usando LLM + */ +const CONTEXTUAL_ANALYSIS_PROMPT = `Você é um especialista em análise semântica e extração de contexto de queries de busca. + +Analise a query abaixo e extraia TODAS as informações contextuais relevantes. + +Query: "{{query}}" + +## INSTRUÇÕES + +1. **INTENÇÃO**: Identifique o tipo de intenção (factual, comparison, how-to, definition, list, opinion, temporal, causal) +2. **ENTIDADES**: Extraia todas as entidades mencionadas (pessoas, lugares, datas, conceitos, organizações, eventos, produtos) +3. **TÓPICOS**: Identifique os tópicos principais abordados +4. **RELACIONAMENTOS**: Identifique relacionamentos implícitos entre entidades +5. **PERGUNTAS IMPLÍCITAS**: Quais perguntas relacionadas estão implícitas? +6. **CONTEXTO TEMPORAL**: Há referências a tempo? (datas, períodos, "recente", "atual", "histórico") +7. **CONTEXTO GEOGRÁFICO**: Há referências a localização? (cidades, estados, países, regiões) +8. **PALAVRAS-CHAVE**: Identifique palavras-chave importantes (remover stop words) +9. **SINÔNIMOS**: Liste sinônimos relevantes para os termos principais +10. **DOMÍNIO**: Identifique o domínio da query (acadêmico, tecnologia, saúde, negócios, etc.) +11. **COMPLEXIDADE**: Avalie a complexidade (simple, moderate, complex) + +## FORMATO DE SAÍDA + +Retorne APENAS um JSON válido no seguinte formato: + +{ + "intent": "list", + "entities": [ + {"type": "concept", "value": "resultados acadêmicos", "confidence": 0.9}, + {"type": "concept", "value": "prêmios", "confidence": 0.9} + ], + "topics": ["pesquisa científica", "reconhecimento acadêmico", "premiações"], + "relationships": [ + {"from": "resultados acadêmicos", "to": "prêmios", "relation": "recebem", "confidence": 0.8} + ], + "implicitQuestions": [ + "Quais foram os prêmios acadêmicos mais recentes?", + "Quem recebeu prêmios acadêmicos?" + ], + "temporalContext": {"relative": "recente", "period": "2024"}, + "geographicContext": null, + "keywords": ["resultados", "acadêmicos", "premiados", "últimos"], + "synonyms": ["pesquisas", "estudos", "trabalhos científicos", "reconhecimento", "distinções"], + "domain": "acadêmico", + "complexity": "moderate" +} + +IMPORTANTE: +- Retorne APENAS o JSON, sem markdown, sem explicações +- Use null para campos não aplicáveis +- Confidence deve ser entre 0 e 1 +- Preserve nomes próprios e termos técnicos na forma original`; + +/** + * Analisa o contexto de uma query usando LLM + * + * @param query - Query original do usuário + * @param model - Modelo LLM a ser usado + * @returns Análise contextual completa + */ +export async function analyzeQueryContext( + query: string, + model: string +): Promise { + if (!query || !query.trim()) { + return { + originalQuery: query, + intent: 'unknown', + entities: [], + topics: [], + relationships: [], + implicitQuestions: [], + keywords: [], + synonyms: [], + complexity: 'simple', + }; + } + + chatLog.info(`[ContextualAnalysis] Analisando contexto da query: "${query}"`); + + try { + const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query); + + const response = await fetch('http://localhost:11434/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + prompt, + stream: false, + options: { + temperature: 0.3, // Baixa temperatura para análise mais determinística + num_predict: 800, // Espaço suficiente para JSON completo + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const rawResponse = (data.response || '').trim(); + + // Limpar resposta (remover markdown code blocks se houver) + let jsonText = rawResponse + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + + // Tentar extrair JSON se houver texto antes/depois + const jsonMatch = jsonText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + jsonText = jsonMatch[0]; + } + + const analysis = JSON.parse(jsonText) as Partial; + + // Validar e preencher campos obrigatórios + const context: QueryContext = { + originalQuery: query, + intent: (analysis.intent as QueryIntent) || 'unknown', + entities: analysis.entities || [], + topics: analysis.topics || [], + relationships: analysis.relationships || [], + implicitQuestions: analysis.implicitQuestions || [], + temporalContext: analysis.temporalContext, + geographicContext: analysis.geographicContext, + keywords: analysis.keywords || [], + synonyms: analysis.synonyms || [], + domain: analysis.domain, + complexity: analysis.complexity || 'moderate', + }; + + chatLog.success(`[ContextualAnalysis] Contexto extraído: ${context.intent}, ${context.entities.length} entidades, ${context.topics.length} tópicos`); + + return context; + } catch (error) { + chatLog.error(`[ContextualAnalysis] Erro ao analisar contexto:`, error); + + // Fallback: análise básica usando heurísticas + return createFallbackContext(query); + } +} + +/** + * Cria contexto básico usando heurísticas quando a análise LLM falha + */ +function createFallbackContext(query: string): QueryContext { + const lowerQuery = query.toLowerCase(); + + // Detectar intenção básica + let intent: QueryIntent = 'factual'; + if (lowerQuery.includes('como') || lowerQuery.includes('tutorial')) { + intent = 'how-to'; + } else if (lowerQuery.includes('o que é') || lowerQuery.includes('definição')) { + intent = 'definition'; + } else if (lowerQuery.includes('quais') || lowerQuery.includes('lista')) { + intent = 'list'; + } else if (lowerQuery.includes('comparar') || lowerQuery.includes('vs')) { + intent = 'comparison'; + } else if (lowerQuery.includes('por que') || lowerQuery.includes('causa')) { + intent = 'causal'; + } + + // Extrair palavras-chave básicas + const stopWords = new Set([ + 'o', 'a', 'os', 'as', 'um', 'uma', 'de', 'da', 'do', 'das', 'dos', + 'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'com', 'sem', + 'que', 'qual', 'quais', 'onde', 'quando', 'como', 'porque', + ]); + + const keywords = query + .toLowerCase() + .split(/\s+/) + .filter(word => word.length > 2 && !stopWords.has(word)); + + // Detectar contexto temporal + let temporalContext: TemporalContext | undefined; + if (lowerQuery.includes('último') || lowerQuery.includes('recente') || lowerQuery.includes('atual')) { + temporalContext = { relative: 'recente' }; + } else if (lowerQuery.includes('2024') || lowerQuery.includes('2023')) { + const yearMatch = query.match(/\b(20\d{2})\b/); + if (yearMatch) { + temporalContext = { period: yearMatch[1] }; + } + } + + return { + originalQuery: query, + intent, + entities: [], + topics: keywords.slice(0, 5), // Usar keywords como tópicos + relationships: [], + implicitQuestions: [], + temporalContext, + keywords, + synonyms: [], + complexity: keywords.length > 5 ? 'complex' : keywords.length > 2 ? 'moderate' : 'simple', + }; +} + diff --git a/lib/knowledge-base-processor.ts b/lib/knowledge-base-processor.ts index a91d39b..6959e27 100644 --- a/lib/knowledge-base-processor.ts +++ b/lib/knowledge-base-processor.ts @@ -1,4 +1,5 @@ import type { ResearchEntry } from '@/hooks/use-deep-research'; +import type { QueryContext } from './contextual-analyzer'; import { processContent, type Chunk, type ProcessedContent } from './content-condenser'; import { scoreAndSelectChunks } from './relevance-scorer'; import { summarizeMarkdown } from './content-condenser'; @@ -20,6 +21,7 @@ export interface CondensationOptions { autoSummarize?: boolean; summarizeThreshold?: number; fallbackToSummarization?: boolean; + context?: QueryContext; // Contexto analisado para ranqueamento semântico } const DEFAULT_OPTIONS: Required = { @@ -129,12 +131,13 @@ export function condenseKnowledgeBase( // Calcular tokens disponíveis (reservar 20% para margem) const availableTokens = Math.floor(opts.maxTokens * 0.8); - // Selecionar chunks mais relevantes + // Selecionar chunks mais relevantes (usando contexto se disponível) let selectedChunks = scoreAndSelectChunks( allChunks, query, availableTokens, - opts.minRelevanceScore + opts.minRelevanceScore, + opts.context ); // Se não selecionou chunks suficientes ou método de fallback está ativo diff --git a/lib/query-enricher.ts b/lib/query-enricher.ts new file mode 100644 index 0000000..1dcc3b9 --- /dev/null +++ b/lib/query-enricher.ts @@ -0,0 +1,294 @@ +import type { QueryContext } from './contextual-analyzer'; +import { chatLog } from './terminal-logger'; + +/** + * Queries enriquecidas por categoria + */ +export interface EnrichedQueries { + literal: string[]; // Queries literais (original + variações diretas) + semantic: string[]; // Variantes semânticas (sinônimos, reformulações) + related: string[]; // Perguntas relacionadas + expanded: string[]; // Expansões temáticas (autores, eventos, tipos) + contextual: string[]; // Queries com contexto adicional (datas, locais) +} + +/** + * Prompt para enriquecimento de queries usando LLM + */ +const QUERY_ENRICHMENT_PROMPT = `Você é um especialista em otimização de queries de busca e expansão semântica. + +Com base na query original e no contexto analisado, gere múltiplas variantes de queries enriquecidas para maximizar a cobertura de resultados relevantes. + +## QUERY ORIGINAL +"{{originalQuery}}" + +## CONTEXTO ANALISADO +- Intenção: {{intent}} +- Tópicos: {{topics}} +- Entidades: {{entities}} +- Contexto Temporal: {{temporalContext}} +- Contexto Geográfico: {{geographicContext}} +- Palavras-chave: {{keywords}} +- Sinônimos: {{synonyms}} +- Domínio: {{domain}} + +## INSTRUÇÕES + +Gere queries enriquecidas nas seguintes categorias: + +1. **LITERAL**: Variações diretas da query original (preservar termos principais) + - Ex: "resultados acadêmicos premiados" → ["resultados academicos premiados", "ultimos resultados academicos premiados"] + +2. **SEMANTIC**: Variantes semânticas usando sinônimos e reformulações + - Ex: "resultados acadêmicos" → "pesquisas científicas", "estudos acadêmicos", "trabalhos científicos" + - Manter o significado, mas usar termos alternativos + +3. **RELATED**: Perguntas relacionadas e FAQs + - Ex: "premios academicos 2024", "conferencias academicas premiadas", "descobertas cientificas premiadas" + - Explorar aspectos adjacentes do tópico + +4. **EXPANDED**: Expansões temáticas específicas + - Ex: "Nobel Prize 2024", "Turing Award winners", "Fields Medal recentes" + - Incluir tipos específicos, categorias, exemplos concretos + +5. **CONTEXTUAL**: Queries com contexto adicional (datas, locais, domínios) + - Ex: "resultados academicos brasileiros premiados 2024", "pesquisas premiadas universidades" + - Combinar tópico principal com contexto temporal/geográfico + +## REGRAS CRÍTICAS + +- **PRESERVAR ENTIDADES**: Se a query menciona "Garuva", TODAS as queries devem mencionar "Garuva" +- **NÃO TRADUZIR**: Preservar nomes próprios, termos técnicos na forma original +- **OTIMIZAR PARA BUSCA**: Usar termos que aparecem em resultados reais +- **COBERTURA AMPLA**: Cada categoria deve explorar diferentes ângulos +- **QUALIDADE > QUANTIDADE**: 3-5 queries por categoria é suficiente +- **RELEVÂNCIA**: Todas as queries devem ser relevantes para responder a query original + +## FORMATO DE SAÍDA + +Retorne APENAS um JSON válido no seguinte formato: + +{ + "literal": ["query literal 1", "query literal 2"], + "semantic": ["variante semantica 1", "variante semantica 2"], + "related": ["pergunta relacionada 1", "pergunta relacionada 2"], + "expanded": ["expansao tematica 1", "expansao tematica 2"], + "contextual": ["query contextual 1", "query contextual 2"] +} + +IMPORTANTE: +- Retorne APENAS o JSON, sem markdown, sem explicações +- Cada array deve ter 3-5 queries +- Queries devem ser em português se a query original for em português +- Preservar termos técnicos e nomes próprios`; + +/** + * Enriquece queries baseado no contexto analisado + * + * @param originalQuery - Query original do usuário + * @param context - Contexto analisado da query + * @param model - Modelo LLM a ser usado + * @returns Queries enriquecidas por categoria + */ +export async function enrichQueries( + originalQuery: string, + context: QueryContext, + model: string +): Promise { + chatLog.info(`[QueryEnricher] Enriquecendo queries para: "${originalQuery}"`); + + try { + // Preparar contexto para o prompt + const entitiesStr = context.entities.map(e => `${e.type}: ${e.value}`).join(', ') || 'nenhuma'; + const temporalStr = context.temporalContext + ? JSON.stringify(context.temporalContext) + : 'nenhum'; + const geographicStr = context.geographicContext + ? JSON.stringify(context.geographicContext) + : 'nenhum'; + + const prompt = QUERY_ENRICHMENT_PROMPT + .replace('{{originalQuery}}', originalQuery) + .replace('{{intent}}', context.intent) + .replace('{{topics}}', context.topics.join(', ') || 'nenhum') + .replace('{{entities}}', entitiesStr) + .replace('{{temporalContext}}', temporalStr) + .replace('{{geographicContext}}', geographicStr) + .replace('{{keywords}}', context.keywords.join(', ') || 'nenhuma') + .replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum') + .replace('{{domain}}', context.domain || 'geral'); + + const response = await fetch('http://localhost:11434/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + prompt, + stream: false, + options: { + temperature: 0.5, // Temperatura média para criatividade controlada + num_predict: 1000, // Espaço suficiente para múltiplas queries + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const rawResponse = (data.response || '').trim(); + + // Limpar resposta (remover markdown code blocks se houver) + let jsonText = rawResponse + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + + // Tentar extrair JSON se houver texto antes/depois + const jsonMatch = jsonText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + jsonText = jsonMatch[0]; + } + + const enriched = JSON.parse(jsonText) as Partial; + + // Validar e preencher campos + const result: EnrichedQueries = { + literal: enriched.literal || [originalQuery], + semantic: enriched.semantic || [], + related: enriched.related || [], + expanded: enriched.expanded || [], + contextual: enriched.contextual || [], + }; + + // Garantir que sempre há pelo menos a query original + if (result.literal.length === 0) { + result.literal = [originalQuery]; + } + + // Log estatísticas + const totalQueries = + result.literal.length + + result.semantic.length + + result.related.length + + result.expanded.length + + result.contextual.length; + + chatLog.success( + `[QueryEnricher] ${totalQueries} queries geradas: ` + + `${result.literal.length} literal, ${result.semantic.length} semântica, ` + + `${result.related.length} relacionadas, ${result.expanded.length} expandidas, ` + + `${result.contextual.length} contextuais` + ); + + return result; + } catch (error) { + chatLog.error(`[QueryEnricher] Erro ao enriquecer queries:`, error); + + // Fallback: usar apenas query original + return createFallbackEnrichment(originalQuery, context); + } +} + +/** + * Cria enriquecimento básico usando heurísticas quando a análise LLM falha + */ +function createFallbackEnrichment( + originalQuery: string, + context: QueryContext +): EnrichedQueries { + const literal = [originalQuery]; + + // Adicionar variações básicas usando sinônimos do contexto + const semantic: string[] = []; + if (context.synonyms.length > 0) { + // Substituir algumas palavras por sinônimos + let semanticQuery = originalQuery; + context.synonyms.slice(0, 2).forEach(synonym => { + // Tentar substituir palavra-chave por sinônimo + const keywords = context.keywords; + if (keywords.length > 0) { + const keyword = keywords[0]; + semanticQuery = semanticQuery.replace( + new RegExp(keyword, 'gi'), + synonym + ); + if (semanticQuery !== originalQuery) { + semantic.push(semanticQuery); + } + } + }); + } + + // Adicionar contexto temporal se disponível + const contextual: string[] = []; + if (context.temporalContext?.period) { + contextual.push(`${originalQuery} ${context.temporalContext.period}`); + } + if (context.temporalContext?.relative === 'recente') { + contextual.push(`${originalQuery} recente`); + } + + // Adicionar contexto geográfico se disponível + if (context.geographicContext?.location) { + contextual.push(`${originalQuery} ${context.geographicContext.location}`); + } + + return { + literal, + semantic: semantic.length > 0 ? semantic : [], + related: [], + expanded: [], + contextual, + }; +} + +/** + * Combina todas as queries enriquecidas em uma lista única para busca + * + * @param enriched - Queries enriquecidas + * @param strategy - Estratégia de combinação + * @returns Lista de queries para buscar + */ +export function combineEnrichedQueries( + enriched: EnrichedQueries, + strategy: 'all' | 'prioritize-literal' | 'prioritize-semantic' = 'prioritize-literal' +): string[] { + const queries: string[] = []; + + switch (strategy) { + case 'all': + queries.push(...enriched.literal); + queries.push(...enriched.semantic); + queries.push(...enriched.related); + queries.push(...enriched.expanded); + queries.push(...enriched.contextual); + break; + + case 'prioritize-literal': + queries.push(...enriched.literal); + queries.push(...enriched.semantic); + queries.push(...enriched.contextual); + queries.push(...enriched.related); + queries.push(...enriched.expanded); + break; + + case 'prioritize-semantic': + queries.push(...enriched.semantic); + queries.push(...enriched.literal); + queries.push(...enriched.contextual); + queries.push(...enriched.related); + queries.push(...enriched.expanded); + break; + } + + // Remover duplicatas mantendo ordem + const unique = Array.from(new Set(queries.map(q => q.toLowerCase()))) + .map(lower => queries.find(q => q.toLowerCase() === lower)!) + .filter(Boolean); + + return unique; +} + diff --git a/lib/relevance-scorer.ts b/lib/relevance-scorer.ts index 4108820..74cc39b 100644 --- a/lib/relevance-scorer.ts +++ b/lib/relevance-scorer.ts @@ -1,4 +1,5 @@ import type { Chunk } from './content-condenser'; +import type { QueryContext } from './contextual-analyzer'; import { normalizeText } from './text-normalization'; /** @@ -72,14 +73,162 @@ export function calculateSimilarity(query: string, chunk: string): number { return Math.min(Math.max(score, 0), 1); } +/** + * Calcula similaridade semântica avançada usando contexto completo + * Considera entidades, tópicos, relacionamentos e intenção + * + * @param context - Contexto analisado da query + * @param chunk - Chunk de conteúdo + * @returns Score de similaridade semântica (0-1) + */ +export function calculateSemanticSimilarity( + context: QueryContext, + chunk: string +): number { + if (!chunk || !context) { + return 0; + } + + const normalizedChunk = normalizeText(chunk).toLowerCase(); + let totalScore = 0; + let weightSum = 0; + + // 1. Match de keywords (peso: 0.3) + if (context.keywords.length > 0) { + let keywordMatches = 0; + for (const keyword of context.keywords) { + const keywordLower = keyword.toLowerCase(); + if (normalizedChunk.includes(keywordLower)) { + keywordMatches++; + } + } + const keywordScore = keywordMatches / context.keywords.length; + totalScore += keywordScore * 0.3; + weightSum += 0.3; + } + + // 2. Match de entidades (peso: 0.3) + if (context.entities.length > 0) { + let entityMatches = 0; + for (const entity of context.entities) { + const entityLower = entity.value.toLowerCase(); + // Buscar entidade completa (case-insensitive) + if (normalizedChunk.includes(entityLower)) { + entityMatches += entity.confidence; // Ponderar por confiança + } + } + const entityScore = entityMatches / context.entities.length; + totalScore += entityScore * 0.3; + weightSum += 0.3; + } + + // 3. Match de tópicos (peso: 0.2) + if (context.topics.length > 0) { + let topicMatches = 0; + for (const topic of context.topics) { + const topicLower = topic.toLowerCase(); + // Verificar se tópico aparece no chunk (pode ser parcial) + const topicWords = topicLower.split(/\s+/); + const matches = topicWords.filter(word => + word.length > 3 && normalizedChunk.includes(word) + ); + if (matches.length >= topicWords.length * 0.5) { // 50% das palavras do tópico + topicMatches++; + } + } + const topicScore = context.topics.length > 0 + ? topicMatches / context.topics.length + : 0; + totalScore += topicScore * 0.2; + weightSum += 0.2; + } + + // 4. Match de sinônimos (peso: 0.1) + if (context.synonyms.length > 0) { + let synonymMatches = 0; + for (const synonym of context.synonyms) { + const synonymLower = synonym.toLowerCase(); + if (normalizedChunk.includes(synonymLower)) { + synonymMatches++; + } + } + const synonymScore = context.synonyms.length > 0 + ? Math.min(synonymMatches / context.synonyms.length, 0.5) // Cap em 0.5 para não dominar + : 0; + totalScore += synonymScore * 0.1; + weightSum += 0.1; + } + + // 5. Relevância contextual (peso: 0.1) + // Verificar se contexto temporal/geográfico aparece no chunk + let contextualScore = 0; + if (context.temporalContext?.period) { + if (normalizedChunk.includes(context.temporalContext.period)) { + contextualScore += 0.5; + } + } + if (context.temporalContext?.relative) { + const relativeTerms = ['recente', 'atual', 'novo', 'último', 'atualizado']; + if (relativeTerms.some(term => normalizedChunk.includes(term))) { + contextualScore += 0.3; + } + } + if (context.geographicContext?.location) { + const locationLower = context.geographicContext.location.toLowerCase(); + if (normalizedChunk.includes(locationLower)) { + contextualScore += 0.5; + } + } + totalScore += Math.min(contextualScore, 1) * 0.1; + weightSum += 0.1; + + // Normalizar pelo peso total + const finalScore = weightSum > 0 ? totalScore / weightSum : 0; + + // Garantir que score esteja entre 0 e 1 + return Math.min(Math.max(finalScore, 0), 1); +} + +/** + * Calcula similaridade combinada (keywords + semântica) + * + * @param query - Query original + * @param context - Contexto analisado (opcional) + * @param chunk - Chunk de conteúdo + * @returns Score combinado (0-1) + */ +export function calculateCombinedSimilarity( + query: string, + chunk: string, + context?: QueryContext +): number { + // Score baseado em keywords + const keywordScore = calculateSimilarity(query, chunk); + + // Se temos contexto, calcular score semântico também + if (context) { + const semanticScore = calculateSemanticSimilarity(context, chunk); + + // Combinar: 60% keywords, 40% semântica + return keywordScore * 0.6 + semanticScore * 0.4; + } + + return keywordScore; +} + /** * Calcula scores de relevância para todos os chunks * * @param chunks - Array de chunks * @param query - Query do usuário + * @param context - Contexto analisado (opcional, para ranqueamento semântico) * @returns Chunks com scores atribuídos, ordenados por relevância */ -export function scoreChunks(chunks: Chunk[], query: string): Chunk[] { +export function scoreChunks( + chunks: Chunk[], + query: string, + context?: QueryContext +): Chunk[] { if (!query || chunks.length === 0) { return chunks; } @@ -87,7 +236,9 @@ export function scoreChunks(chunks: Chunk[], query: string): Chunk[] { // Calcular score para cada chunk const scoredChunks = chunks.map(chunk => ({ ...chunk, - score: calculateSimilarity(query, chunk.content), + score: context + ? calculateCombinedSimilarity(query, chunk.content, context) + : calculateSimilarity(query, chunk.content), })); // Ordenar por score (maior primeiro) @@ -170,16 +321,18 @@ export function filterByMinScore(chunks: Chunk[], minScore: number = 0.1): Chunk * @param query - Query do usuário * @param maxTokens - Limite máximo de tokens * @param minScore - Score mínimo (opcional) + * @param context - Contexto analisado (opcional, para ranqueamento semântico) * @returns Chunks selecionados e ordenados por relevância */ export function scoreAndSelectChunks( chunks: Chunk[], query: string, maxTokens: number, - minScore?: number + minScore?: number, + context?: QueryContext ): Chunk[] { // Calcular scores - let scoredChunks = scoreChunks(chunks, query); + let scoredChunks = scoreChunks(chunks, query, context); // Filtrar por score mínimo se especificado if (minScore !== undefined) { diff --git a/lib/retry-utils.ts b/lib/retry-utils.ts new file mode 100644 index 0000000..8370eff --- /dev/null +++ b/lib/retry-utils.ts @@ -0,0 +1,131 @@ +/** + * Utilitários para retry com backoff exponencial + */ + +export interface RetryConfig { + maxAttempts: number; // Máximo de tentativas (padrão: 3) + initialDelay: number; // Delay inicial em ms (padrão: 1000) + maxDelay: number; // Delay máximo em ms (padrão: 10000) + backoffMultiplier: number; // Multiplicador do backoff (padrão: 2) + retryableErrors?: string[]; // Padrões de erro que devem ser retentados +} + +export const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxAttempts: 3, + initialDelay: 1000, + maxDelay: 10000, + backoffMultiplier: 2, + retryableErrors: [ + 'timeout', + 'network', + 'connection', + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'sending request', + ], +}; + +export interface RetryResult { + success: boolean; + result?: T; + error?: Error; + attempts: number; + lastError?: Error; +} + +/** + * Classifica se um erro é recuperável (deve ser retentado) + */ +export function isRetryableError(error: unknown, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean { + if (!error) return false; + + const errorMessage = error instanceof Error ? error.message : String(error); + const errorString = errorMessage.toLowerCase(); + + // Verificar padrões de erro recuperáveis + if (config.retryableErrors) { + return config.retryableErrors.some(pattern => + errorString.includes(pattern.toLowerCase()) + ); + } + + // Erros de rede/timeout são geralmente recuperáveis + return errorString.includes('timeout') || + errorString.includes('network') || + errorString.includes('connection'); +} + +/** + * Calcula o delay para a próxima tentativa usando backoff exponencial + */ +function calculateDelay(attempt: number, config: RetryConfig): number { + const delay = config.initialDelay * Math.pow(config.backoffMultiplier, attempt - 1); + return Math.min(delay, config.maxDelay); +} + +/** + * Executa uma função com retry e backoff exponencial + */ +export async function retryWithBackoff( + fn: () => Promise, + config: Partial = {} +): Promise> { + const finalConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= finalConfig.maxAttempts; attempt++) { + try { + const result = await fn(); + return { + success: true, + result, + attempts: attempt, + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Se não for erro recuperável, não tenta novamente + if (!isRetryableError(error, finalConfig)) { + return { + success: false, + error: lastError, + attempts: attempt, + lastError, + }; + } + + // Se não for a última tentativa, aguarda antes de tentar novamente + if (attempt < finalConfig.maxAttempts) { + const delay = calculateDelay(attempt, finalConfig); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + return { + success: false, + error: lastError, + attempts: finalConfig.maxAttempts, + lastError, + }; +} + +/** + * Cria um timeout para uma promise + */ +export function withTimeout( + promise: Promise, + timeoutMs: number, + timeoutMessage = 'Operation timed out' +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs) + ), + ]); +} + + + diff --git a/lib/search-logger.ts b/lib/search-logger.ts new file mode 100644 index 0000000..1da03c5 --- /dev/null +++ b/lib/search-logger.ts @@ -0,0 +1,213 @@ +import type { QueryContext } from './contextual-analyzer'; +import type { EnrichedQueries } from './query-enricher'; +import type { FallbackAttempt } from './web-search-fallback'; +import type { ScrapedContent } from '@/services/webSearch'; +import type { UnifiedContext } from './context-unifier'; + +/** + * Log completo de uma pesquisa + */ +export interface SearchLog { + timestamp: number; + originalQuery: string; + contextAnalysis?: QueryContext; + enrichedQueries?: EnrichedQueries; + rounds: Array<{ + round: number; + strategy?: string; + queries: string[]; + results: Array<{ + query: string; + engine?: string; + resultsCount: number; + duration: number; + topResults: Array<{ + title: string; + url: string; + score?: number; + }>; + }>; + totalResults: number; + relevanceScore: number; + }>; + finalResults: { + totalSources: number; + unifiedContext?: UnifiedContext; + keyFacts?: number; + contradictions?: number; + }; + usedFallback: boolean; + totalDuration: number; +} + +/** + * Armazena logs de pesquisa em memória + * (Pode ser expandido para salvar em arquivo ou banco de dados) + */ +class SearchLogger { + private logs: SearchLog[] = []; + private maxLogs = 100; // Manter apenas os últimos 100 logs + + /** + * Adiciona um novo log + */ + addLog(log: SearchLog): void { + this.logs.unshift(log); // Adicionar no início + if (this.logs.length > this.maxLogs) { + this.logs = this.logs.slice(0, this.maxLogs); + } + } + + /** + * Obtém todos os logs + */ + getLogs(): SearchLog[] { + return [...this.logs]; + } + + /** + * Obtém log por query + */ + getLogByQuery(query: string): SearchLog | undefined { + return this.logs.find(log => + log.originalQuery.toLowerCase() === query.toLowerCase() + ); + } + + /** + * Limpa todos os logs + */ + clearLogs(): void { + this.logs = []; + } + + /** + * Exporta logs como JSON + */ + exportLogs(): string { + return JSON.stringify(this.logs, null, 2); + } +} + +// Instância singleton +export const searchLogger = new SearchLogger(); + +/** + * Cria um log de pesquisa a partir de dados coletados + */ +export function createSearchLog( + originalQuery: string, + contextAnalysis: QueryContext | null, + enrichedQueries: EnrichedQueries | null, + attempts: FallbackAttempt[], + finalSources: ScrapedContent[], + unifiedContext: UnifiedContext | null, + usedFallback: boolean, + startTime: number +): SearchLog { + const endTime = Date.now(); + const totalDuration = endTime - startTime; + + // Processar rounds dos attempts + const rounds = attempts.map(attempt => { + const queryResults = attempt.queries + ? attempt.queries.map(q => ({ + query: q, + resultsCount: attempt.results.length, + duration: attempt.duration, + topResults: attempt.results.slice(0, 3).map(r => ({ + title: r.title, + url: r.url, + score: attempt.relevanceScore, + })), + })) + : [{ + query: attempt.query, + resultsCount: attempt.results.length, + duration: attempt.duration, + topResults: attempt.results.slice(0, 3).map(r => ({ + title: r.title, + url: r.url, + score: attempt.relevanceScore, + })), + }]; + + return { + round: attempt.round, + strategy: attempt.strategy, + queries: attempt.queries || [attempt.query], + results: queryResults, + totalResults: attempt.results.length, + relevanceScore: attempt.relevanceScore, + }; + }); + + return { + timestamp: startTime, + originalQuery, + contextAnalysis: contextAnalysis || undefined, + enrichedQueries: enrichedQueries || undefined, + rounds, + finalResults: { + totalSources: finalSources.length, + unifiedContext: unifiedContext || undefined, + keyFacts: unifiedContext?.keyFacts.length, + contradictions: unifiedContext?.contradictions.length, + }, + usedFallback, + totalDuration, + }; +} + +/** + * Formata log para exibição + */ +export function formatSearchLog(log: SearchLog): string { + const lines: string[] = []; + + lines.push(`=== SEARCH LOG ===`); + lines.push(`Timestamp: ${new Date(log.timestamp).toISOString()}`); + lines.push(`Query: "${log.originalQuery}"`); + lines.push(`Duration: ${log.totalDuration}ms`); + lines.push(`Used Fallback: ${log.usedFallback}`); + lines.push(''); + + if (log.contextAnalysis) { + lines.push(`--- Context Analysis ---`); + lines.push(`Intent: ${log.contextAnalysis.intent}`); + lines.push(`Entities: ${log.contextAnalysis.entities.length}`); + lines.push(`Topics: ${log.contextAnalysis.topics.join(', ')}`); + lines.push(''); + } + + if (log.enrichedQueries) { + lines.push(`--- Enriched Queries ---`); + lines.push(`Literal: ${log.enrichedQueries.literal.length}`); + lines.push(`Semantic: ${log.enrichedQueries.semantic.length}`); + lines.push(`Related: ${log.enrichedQueries.related.length}`); + lines.push(`Expanded: ${log.enrichedQueries.expanded.length}`); + lines.push(`Contextual: ${log.enrichedQueries.contextual.length}`); + lines.push(''); + } + + lines.push(`--- Rounds (${log.rounds.length}) ---`); + log.rounds.forEach((round, idx) => { + lines.push(`Round ${round.round} [${round.strategy || 'N/A'}]:`); + lines.push(` Queries: ${round.queries.join(', ')}`); + lines.push(` Results: ${round.totalResults}`); + lines.push(` Relevance Score: ${round.relevanceScore.toFixed(2)}`); + lines.push(''); + }); + + lines.push(`--- Final Results ---`); + lines.push(`Total Sources: ${log.finalResults.totalSources}`); + if (log.finalResults.keyFacts) { + lines.push(`Key Facts: ${log.finalResults.keyFacts}`); + } + if (log.finalResults.contradictions) { + lines.push(`Contradictions: ${log.finalResults.contradictions}`); + } + + return lines.join('\n'); +} + diff --git a/lib/terminal-logger.ts b/lib/terminal-logger.ts index 678a76a..90836a2 100644 --- a/lib/terminal-logger.ts +++ b/lib/terminal-logger.ts @@ -36,3 +36,5 @@ export const deepResearchLog = { + + diff --git a/lib/web-search-fallback.ts b/lib/web-search-fallback.ts index a08d362..f560dde 100644 --- a/lib/web-search-fallback.ts +++ b/lib/web-search-fallback.ts @@ -3,25 +3,43 @@ * * Implementa múltiplas tentativas de busca com query expansion, * validação de relevância e fallback para conhecimento interno. + * + * Agora suporta multi-round semantic search com estratégias diferentes. */ import type { ScrapedContent } from '@/services/webSearch'; +import type { QueryContext } from './contextual-analyzer'; +import type { EnrichedQueries } from './query-enricher'; import { chatLog } from '@/lib/terminal-logger'; +export type SearchStrategy = 'literal' | 'semantic' | 'related' | 'expanded' | 'contextual'; + +export interface RoundConfig { + strategy: SearchStrategy; + queries: string[]; + minResults: number; +} + export interface FallbackConfig { maxRounds: number; // Máximo de rodadas de busca (padrão: 4) maxResultsPerRound: number; // Máximo de resultados por rodada (padrão: 10) maxTotalResults: number; // Máximo total de resultados analisados (padrão: 40) minRelevanceScore: number; // Score mínimo de relevância (0-1, padrão: 0.3) enableQueryExpansion: boolean; // Ativar expansão de queries (padrão: true) + useSemanticSearch?: boolean; // Usar multi-round semantic search (padrão: false) + context?: QueryContext; // Contexto analisado (para semantic search) + enrichedQueries?: EnrichedQueries; // Queries enriquecidas (para semantic search) } export interface FallbackAttempt { round: number; + strategy?: SearchStrategy; query: string; + queries?: string[]; // Múltiplas queries se usar estratégia results: ScrapedContent[]; relevanceScore: number; timestamp: number; + duration: number; } export interface FallbackResult { @@ -108,8 +126,9 @@ Query expandida:`; /** * Calcula score de relevância de um resultado baseado na query + * (Mantido para compatibilidade, mas agora usa função de relevance-scorer) */ -export function calculateRelevanceScore( +export function calculateRelevanceScoreForFallback( result: ScrapedContent, query: string ): number { @@ -167,7 +186,7 @@ export function areResultsRelevant( } // Calcular scores de relevância - const scores = results.map(r => calculateRelevanceScore(r, query)); + const scores = results.map(r => calculateRelevanceScoreForFallback(r, query)); // Verificar se pelo menos um resultado tem score suficiente const maxScore = Math.max(...scores); @@ -181,8 +200,66 @@ export function areResultsRelevant( return maxScore >= minScore || avgScore >= (minScore * 0.7); } +/** + * Cria configuração de rounds para multi-round semantic search + */ +function createSemanticRounds( + enriched: EnrichedQueries, + originalQuery: string +): RoundConfig[] { + const rounds: RoundConfig[] = []; + + // Round 1: Literal + Semântico (mais direto) + if (enriched.literal.length > 0 || enriched.semantic.length > 0) { + rounds.push({ + strategy: 'literal', + queries: [...enriched.literal, ...enriched.semantic].slice(0, 5), + minResults: 3, + }); + } + + // Round 2: Relacionadas (se Round 1 não foi suficiente) + if (enriched.related.length > 0) { + rounds.push({ + strategy: 'related', + queries: enriched.related.slice(0, 4), + minResults: 2, + }); + } + + // Round 3: Contextual (adicionar contexto temporal/geográfico) + if (enriched.contextual.length > 0) { + rounds.push({ + strategy: 'contextual', + queries: enriched.contextual.slice(0, 3), + minResults: 2, + }); + } + + // Round 4: Expandido (temas específicos, exemplos concretos) + if (enriched.expanded.length > 0) { + rounds.push({ + strategy: 'expanded', + queries: enriched.expanded.slice(0, 3), + minResults: 1, + }); + } + + // Fallback: se não há queries enriquecidas, usar original + if (rounds.length === 0) { + rounds.push({ + strategy: 'literal', + queries: [originalQuery], + minResults: 1, + }); + } + + return rounds; +} + /** * Executa busca progressiva com fallback + * Agora suporta multi-round semantic search com estratégias diferentes */ export async function executeProgressiveSearch( originalQuery: string, @@ -198,21 +275,63 @@ export async function executeProgressiveSearch( chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`); chatLog.info(`Original Query: "${originalQuery}"`); - chatLog.info(`Config: ${JSON.stringify(finalConfig)}`); + chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`); - for (let round = 1; round <= finalConfig.maxRounds; round++) { - chatLog.info(`\n--- Round ${round}/${finalConfig.maxRounds} ---`); + // Se usar semantic search, criar rounds baseados em estratégias + let rounds: Array<{ round: number; config: RoundConfig }> = []; + + if (finalConfig.useSemanticSearch && finalConfig.enrichedQueries) { + const semanticRounds = createSemanticRounds(finalConfig.enrichedQueries, originalQuery); + rounds = semanticRounds.map((roundConfig, idx) => ({ + round: idx + 1, + config: roundConfig, + })); + chatLog.info(`[SemanticSearch] Created ${rounds.length} semantic rounds with strategies`); + } else { + // Modo tradicional: rounds sequenciais com expansão + for (let i = 1; i <= finalConfig.maxRounds; i++) { + rounds.push({ + round: i, + config: { + strategy: 'literal', + queries: [originalQuery], + minResults: finalConfig.maxResultsPerRound, + }, + }); + } + } + + // Limitar número de rounds + rounds = rounds.slice(0, finalConfig.maxRounds); + + for (const { round, config: roundConfig } of rounds) { + chatLog.info(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`); - // Expandir query se necessário - const query = finalConfig.enableQueryExpansion - ? await expandQuery(originalQuery, model, round) - : originalQuery; + const roundStartTime = Date.now(); + let roundResults: ScrapedContent[] = []; + let roundQueries: string[] = []; - chatLog.info(`Searching with query: "${query}"`); + // Executar todas as queries do round em paralelo + const queryPromises = roundConfig.queries.map(async (query) => { + // Expandir query se necessário (modo tradicional) + const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch + ? await expandQuery(query, model, round) + : query; + + try { + const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound); + return { query: finalQuery, results }; + } catch (error) { + chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, error); + return { query: finalQuery, results: [] }; + } + }); - try { - // Executar busca - const results = await searchFn(query, finalConfig.maxResultsPerRound); + const queryResults = await Promise.all(queryPromises); + + // Agregar resultados de todas as queries do round + for (const { query, results } of queryResults) { + roundQueries.push(query); // Filtrar duplicatas const uniqueResults = results.filter(r => { @@ -223,61 +342,78 @@ export async function executeProgressiveSearch( return true; }); - chatLog.info(`Found ${uniqueResults.length} unique results (${results.length} total)`); - - // Calcular relevância - const relevanceScore = uniqueResults.length > 0 - ? uniqueResults.reduce((sum, r) => sum + calculateRelevanceScore(r, query), 0) / uniqueResults.length - : 0; - - // Registrar tentativa - const attempt: FallbackAttempt = { - round, - query, - results: uniqueResults, - relevanceScore, - timestamp: Date.now(), - }; - attempts.push(attempt); - - allResults.push(...uniqueResults); - totalResultsAnalyzed += uniqueResults.length; - - // Verificar se encontrou resultados relevantes - if (areResultsRelevant(uniqueResults, query, finalConfig.minRelevanceScore)) { - chatLog.info(`✓ Relevant results found in round ${round}!`); - chatLog.info(`Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`); - - return { - success: true, - knowledgeBaseContext: '', // Será preenchido pelo caller - scrapedSources: allResults, - attempts, - totalResultsAnalyzed, - usedFallback: false, - finalQuery: query, - }; - } - - // Verificar se atingiu limite de resultados - if (totalResultsAnalyzed >= finalConfig.maxTotalResults) { - chatLog.warn(`⚠️ Reached max total results limit (${finalConfig.maxTotalResults})`); - break; - } - - // Se não encontrou resultados relevantes, continuar para próxima rodada - chatLog.warn(`⚠️ Round ${round} did not find relevant results, continuing...`); - - } catch (error) { - chatLog.error(`✗ Error in round ${round}:`, error); - attempts.push({ - round, - query, - results: [], - relevanceScore: 0, - timestamp: Date.now(), - }); + roundResults.push(...uniqueResults); } + + const roundDuration = Date.now() - roundStartTime; + + chatLog.info(`[Round ${round}] Executed ${roundQueries.length} queries, found ${roundResults.length} unique results in ${roundDuration}ms`); + + // Log detalhado se houver resultados + if (roundResults.length > 0) { + roundResults.slice(0, 3).forEach((result, idx) => { + chatLog.info(` → Result ${idx + 1}: ${result.title.substring(0, 60)}... (${result.url.substring(0, 50)}...)`); + }); + if (roundResults.length > 3) { + chatLog.info(` ... and ${roundResults.length - 3} more results`); + } + } + + // Calcular relevância usando contexto se disponível + const relevanceScore = roundResults.length > 0 + ? roundResults.reduce((sum, r) => { + // Usar contexto para cálculo de relevância se disponível + if (finalConfig.context) { + // Importar função dinamicamente para evitar circular dependency + const { calculateSemanticSimilarity } = require('./relevance-scorer'); + return sum + calculateSemanticSimilarity(finalConfig.context, r.markdown || r.title); + } + return sum + calculateRelevanceScoreForFallback(r, originalQuery); + }, 0) / roundResults.length + : 0; + + // Registrar tentativa + const attempt: FallbackAttempt = { + round, + strategy: roundConfig.strategy, + query: roundQueries[0] || originalQuery, + queries: roundQueries.length > 1 ? roundQueries : undefined, + results: roundResults, + relevanceScore, + timestamp: Date.now(), + duration: roundDuration, + }; + attempts.push(attempt); + + allResults.push(...roundResults); + totalResultsAnalyzed += roundResults.length; + + // Verificar se encontrou resultados relevantes + const queryForRelevance = roundQueries[0] || originalQuery; + if (areResultsRelevant(roundResults, queryForRelevance, finalConfig.minRelevanceScore)) { + chatLog.info(`[Round ${round}] ✓ Relevant results found!`); + chatLog.info(`[Round ${round}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`); + chatLog.info(`[Round ${round}] Final queries: ${roundQueries.join(', ')}`); + + return { + success: true, + knowledgeBaseContext: '', // Será preenchido pelo caller + scrapedSources: allResults, + attempts, + totalResultsAnalyzed, + usedFallback: false, + finalQuery: roundQueries.join(' | '), + }; + } + + // Verificar se atingiu limite de resultados + if (totalResultsAnalyzed >= finalConfig.maxTotalResults) { + chatLog.warn(`[Round ${round}] ⚠️ Reached max total results limit (${finalConfig.maxTotalResults})`); + break; + } + + // Se não encontrou resultados relevantes, continuar para próxima rodada + chatLog.warn(`[Round ${round}] ⚠️ Did not find relevant results (min score: ${finalConfig.minRelevanceScore}), continuing...`); } // Se chegou aqui, não encontrou resultados relevantes diff --git a/services/webSearch.ts b/services/webSearch.ts index ee1bb87..6df9fce 100644 --- a/services/webSearch.ts +++ b/services/webSearch.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; -import { SearchCategory } from '@/store/settings-store'; +import { SearchCategory, useSettingsStore } from '@/store/settings-store'; +import { retryWithBackoff, withTimeout, isRetryableError, type RetryConfig } from '@/lib/retry-utils'; export interface ScrapedContent { title: string; @@ -224,35 +225,101 @@ class WebSearchService { private async executeMetadataSearch( query: string, limit: number = 5, - searchConfig?: SearchConfig + searchConfig?: SearchConfig, + timeoutMs: number = 15000 ): Promise { - try { - let rustConfig: any = undefined; - if (searchConfig) { - rustConfig = { - max_concurrent_tabs: searchConfig.maxConcurrentTabs, - total_sources_limit: searchConfig.totalSourcesLimit, - categories: searchConfig.categories.map(cat => ({ - id: cat.id, - name: cat.name, - base_sites: cat.baseSites, - enabled: cat.enabled, - })), - user_custom_sites: searchConfig.userCustomSites, - excluded_domains: searchConfig.excludedDomains, - }; - } - - const results = await invoke('search_web_metadata', { - query: query.trim(), - limit, - search_config: rustConfig, - }); - return results || []; - } catch (error) { - console.error('Erro ao buscar metadados:', error); - throw error instanceof Error ? error : new Error('Falha ao buscar metadados'); + // Obter configurações de motores do store + const settings = useSettingsStore.getState(); + const engineOrder = settings.webSearch.engineOrder || ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage']; + + let rustConfig: any = undefined; + if (searchConfig) { + rustConfig = { + max_concurrent_tabs: searchConfig.maxConcurrentTabs, + total_sources_limit: searchConfig.totalSourcesLimit, + categories: searchConfig.categories.map(cat => ({ + id: cat.id, + name: cat.name, + base_sites: cat.baseSites, + enabled: cat.enabled, + })), + user_custom_sites: searchConfig.userCustomSites, + excluded_domains: searchConfig.excludedDomains, + }; } + + // Log da tentativa + console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`); + console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`); + console.log(`[WebSearch] Limit: ${limit}, Timeout: ${timeoutMs}ms`); + + // Executar com retry e timeout + const retryResult = await retryWithBackoff( + async () => { + const startTime = Date.now(); + try { + const promise = invoke('search_web_metadata', { + query: query.trim(), + limit, + search_config: rustConfig, + engineOrder: engineOrder, // Passar ordem de motores + }); + + const result = await withTimeout( + promise, + timeoutMs, + `Timeout ao buscar metadados para "${query}"` + ); + + const duration = Date.now() - startTime; + console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`); + + return result; + } catch (error) { + const duration = Date.now() - startTime; + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[WebSearch] Erro após ${duration}ms:`, errorMsg); + throw error; + } + }, + { + maxAttempts: 3, + initialDelay: 1000, + maxDelay: 5000, + } + ); + + if (retryResult.success && retryResult.result) { + const results = retryResult.result || []; + if (results.length === 0) { + console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`); + console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}, Motores testados: ${engineOrder.join(', ')}`); + } + return results; + } + + // Se falhou mas é erro recuperável, logar detalhadamente + if (retryResult.lastError && isRetryableError(retryResult.lastError)) { + console.warn( + `[WebSearch] Falha após ${retryResult.attempts} tentativas:`, + retryResult.lastError.message + ); + console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`); + console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`); + return []; + } + + // Para erros não recuperáveis, logar detalhadamente + if (retryResult.lastError) { + console.error( + `[WebSearch] Erro não recuperável:`, + retryResult.lastError.message + ); + console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`); + console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`); + } + + return []; } /** @@ -340,22 +407,43 @@ class WebSearchService { async smartSearchRag( query: string, limit: number = 3, - searchConfig?: SearchConfig + searchConfig?: SearchConfig, + timeoutMs: number = 15000 ): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> { if (!query || !query.trim()) { return { metadata: [], contents: [] }; } - const metas = await this.executeMetadataSearch(query, Math.max(limit * 2, 5), searchConfig); + // Buscar metadados com retry e timeout (já implementado em executeMetadataSearch) + const metas = await this.executeMetadataSearch( + query, + Math.max(limit * 2, 5), + searchConfig, + timeoutMs + ); + + // Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente) const topUrls = metas.map(m => m.url).slice(0, limit); + if (topUrls.length === 0) { + // Retornar resultados parciais (metadados vazios mas sem erro) return { metadata: metas, contents: [] }; } + + // Tentar fazer scraping mesmo se metadados foram parciais try { - const contents = await invoke('scrape_urls', { urls: topUrls }); + const contents = await withTimeout( + invoke('scrape_urls', { urls: topUrls }), + timeoutMs, + `Timeout ao fazer scraping para "${query}"` + ); return { metadata: metas, contents: contents || [] }; } catch (error) { - console.error('Erro ao fazer scraping em lote:', error); + // Logar erro mas retornar metadados disponíveis (resultado parcial) + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('Erro ao fazer scraping em lote (continuando com metadados):', errorMessage); + + // Retornar metadados mesmo se scraping falhou return { metadata: metas, contents: [] }; } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e9ff85f..9b8cf78 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ "readability", "regex", "reqwest 0.12.24", + "rfd", "scraper", "serde", "serde_json", @@ -139,6 +140,24 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd884d7c72877a94102c3715f3b1cd09ff4fac28221add3e57cfbe25c236d093" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.8.5", + "serde", + "serde_repr", + "url", + "zbus 4.4.0", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -177,6 +196,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -206,6 +236,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -375,6 +416,12 @@ dependencies = [ "wyz", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -2579,6 +2626,15 @@ dependencies = [ "time", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.11.0" @@ -2758,6 +2814,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -2798,7 +2867,7 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus", + "zbus 5.12.0", ] [[package]] @@ -2867,6 +2936,26 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3141,6 +3230,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3531,6 +3629,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + [[package]] name = "potential_utf" version = "0.1.4" @@ -3990,6 +4094,29 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a73a7337fc24366edfca76ec521f51877b114e42dab584008209cca6719251" +dependencies = [ + "ashpd", + "block", + "dispatch", + "js-sys", + "log", + "objc", + "objc-foundation", + "objc_id", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -6728,6 +6855,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xml5ever" version = "0.17.0" @@ -6762,6 +6899,44 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.12.0" @@ -6782,7 +6957,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", @@ -6791,9 +6966,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.13", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.12.0", + "zbus_names 4.2.0", + "zvariant 5.8.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.110", + "zvariant_utils 2.1.0", ] [[package]] @@ -6806,9 +6994,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.110", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.2.0", + "zvariant 5.8.0", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -6820,7 +7019,7 @@ dependencies = [ "serde", "static_assertions", "winnow 0.7.13", - "zvariant", + "zvariant 5.8.0", ] [[package]] @@ -6952,6 +7151,20 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.8.0" @@ -6962,8 +7175,21 @@ dependencies = [ "enumflags2", "serde", "winnow 0.7.13", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.8.0", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.110", + "zvariant_utils 2.1.0", ] [[package]] @@ -6976,7 +7202,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.110", - "zvariant_utils", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1e2cea2..f61ea7e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,3 +45,4 @@ regex = "1.10" walkdir = "2.5" tauri-plugin-autostart = "2" dirs = "5.0" +rfd = "0.14" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 15dacca..3b4cdbf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,13 +30,15 @@ use web_scraper::{ scrape_url, SearchConfig, search_duckduckgo_metadata, + search_multi_engine_metadata, + SearchEngine, smart_search, scrape_urls_bulk, }; use headless_chrome::Browser; use scheduler::{SentinelTask, SchedulerService, SchedulerState, TaskAction}; use sources_config::{SourcesConfig, load_sources_config, save_sources_config}; -use system_monitor::{SystemStats, SystemMonitorState, GpuInfo}; +use system_monitor::{SystemStats, SystemMonitorState, GpuInfo, GpuStats}; // CommandExt é importado localmente onde necessário @@ -591,6 +593,168 @@ fn check_if_model_installed(name: String) -> bool { } } +/// Instala um modelo GGUF a partir de um arquivo local +#[command] +async fn install_gguf_model( + app_handle: AppHandle, + file_path: String, + model_name: Option, +) -> Result { + use std::path::Path; + + let source_path = Path::new(&file_path); + + // Validar que o arquivo existe + if !source_path.exists() { + return Err("Arquivo não encontrado".to_string()); + } + + // Validar extensão (mas aceitar arquivos sem extensão também) + let is_gguf = if let Some(ext) = source_path.extension() { + ext.to_string_lossy().to_lowercase() == "gguf" + } else { + // Arquivo sem extensão - verificar pelo tamanho (modelos GGUF são grandes) + let metadata = fs::metadata(source_path) + .map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?; + metadata.len() >= 50 * 1024 * 1024 // Pelo menos 50MB + }; + + if !is_gguf { + // Verificar se é um arquivo grande sem extensão (pode ser GGUF) + let metadata = fs::metadata(source_path) + .map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?; + if metadata.len() < 50 * 1024 * 1024 { + return Err("Arquivo muito pequeno ou não é um modelo GGUF válido".to_string()); + } + // Se for grande o suficiente, aceitar mesmo sem extensão + } + + // Validar tamanho mínimo (100MB) + let metadata = fs::metadata(source_path) + .map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?; + let min_size = 100 * 1024 * 1024; // 100MB + if metadata.len() < min_size { + return Err("Arquivo muito pequeno. Modelos GGUF geralmente têm pelo menos 100MB".to_string()); + } + + // Determinar nome do modelo + let final_model_name = if let Some(name) = model_name { + name.trim().to_string() + } else { + // Extrair nome do arquivo sem extensão + source_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("model") + .to_string() + }; + + if final_model_name.is_empty() { + return Err("Nome do modelo não pode estar vazio".to_string()); + } + + // Obter diretório de modelos do Ollama + // Ollama armazena modelos em ~/.ollama/models (Linux/Mac) ou %USERPROFILE%\.ollama\models (Windows) + let models_dir = dirs::home_dir() + .ok_or_else(|| "Não foi possível determinar diretório home".to_string())? + .join(".ollama") + .join("models"); + + // Criar diretório se não existir + fs::create_dir_all(&models_dir) + .map_err(|e| format!("Erro ao criar diretório de modelos: {}", e))?; + + // Criar diretório para o modelo específico + let model_dir = models_dir.join(&final_model_name); + fs::create_dir_all(&model_dir) + .map_err(|e| format!("Erro ao criar diretório do modelo: {}", e))?; + + // Nome do arquivo de destino (usar nome do modelo + .gguf) + let dest_file = model_dir.join(format!("{}.gguf", final_model_name)); + + // Copiar arquivo + log::info!("Copiando arquivo GGUF de {} para {}", file_path, dest_file.display()); + fs::copy(source_path, &dest_file) + .map_err(|e| format!("Erro ao copiar arquivo: {}", e))?; + + log::info!("Arquivo copiado com sucesso. Tentando registrar no Ollama..."); + + // Tentar criar Modelfile e importar modelo no Ollama + // Ollama pode importar modelos GGUF usando: ollama create -f + // Mas para GGUF direto, podemos usar: ollama create --file + // Ou simplesmente copiar para o diretório e o Ollama detecta automaticamente + + // Tentar criar Modelfile e registrar modelo no Ollama + // Ollama requer um Modelfile para criar modelos GGUF + let modelfile_path = model_dir.join("Modelfile"); + let modelfile_content = format!("FROM {}\n", dest_file.display()); + + // Escrever Modelfile + if let Err(e) = fs::write(&modelfile_path, &modelfile_content) { + log::warn!("Erro ao criar Modelfile: {}. Tentando método alternativo...", e); + } + + // Tentar usar ollama create com Modelfile + let create_output = Command::new("ollama") + .arg("create") + .arg(&final_model_name) + .arg("-f") + .arg(&modelfile_path) + .output(); + + match create_output { + Ok(output) => { + if output.status.success() { + log::info!("Modelo {} registrado com sucesso no Ollama", final_model_name); + Ok(final_model_name) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + // Se o modelo já existe, ainda consideramos sucesso + if stderr.contains("already exists") || stderr.contains("model already exists") { + log::info!("Modelo {} já existe no Ollama", final_model_name); + Ok(final_model_name) + } else { + // Tentar método alternativo: usar FROM diretamente + log::warn!("Primeira tentativa falhou: {}. Tentando método alternativo...", stderr); + + // Método alternativo: criar modelo usando FROM diretamente + let alt_output = Command::new("ollama") + .arg("create") + .arg(&final_model_name) + .arg("--file") + .arg(&dest_file) + .output(); + + match alt_output { + Ok(alt_out) => { + if alt_out.status.success() { + log::info!("Modelo {} registrado com sucesso (método alternativo)", final_model_name); + Ok(final_model_name) + } else { + let alt_stderr = String::from_utf8_lossy(&alt_out.stderr); + // Se falhar, ainda retornamos sucesso pois o arquivo foi copiado + log::warn!("Não foi possível registrar modelo automaticamente: {}. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", alt_stderr, dest_file.display(), final_model_name, modelfile_path.display()); + Ok(final_model_name) + } + } + Err(_) => { + // Se ambos falharem, ainda retornamos sucesso pois o arquivo foi copiado + log::warn!("Não foi possível registrar modelo automaticamente. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", dest_file.display(), final_model_name, modelfile_path.display()); + Ok(final_model_name) + } + } + } + } + } + Err(e) => { + // Se ollama create falhar, ainda retornamos sucesso pois o arquivo foi copiado + // O usuário pode registrar manualmente depois + log::warn!("Não foi possível registrar modelo automaticamente: {}. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", e, dest_file.display(), final_model_name, modelfile_path.display()); + Ok(final_model_name) + } + } +} + // Função auxiliar para ler linha até encontrar \r ou \n (mantida para fallback) #[allow(dead_code)] fn read_line_until_delimiter(reader: &mut BufReader, buffer: &mut Vec) -> Result { @@ -1599,6 +1763,7 @@ async fn search_web_metadata( query: String, limit: Option, search_config: Option, + engine_order: Option>, ) -> Result, String> { if query.trim().is_empty() { return Err("Query não pode estar vazia".to_string()); @@ -1606,23 +1771,63 @@ async fn search_web_metadata( let lim = limit.unwrap_or(5); - if let Some(config) = search_config { - // Usar smart_search para obter URLs e convertê-las em metadados simples - match smart_search(&query, &config).await { - Ok(mut urls) => { - urls.truncate(lim); - let metas = urls - .into_iter() - .map(|u| SearchResultMetadata { title: u.clone(), url: u, snippet: String::new() }) - .collect::>(); - Ok(metas) - } - Err(e) => Err(format!("Erro ao executar smart_search: {}", e)), - } + // Converter engine_order (strings) para Vec + let engines: Vec = if let Some(order) = engine_order { + order.iter() + .filter_map(|s| SearchEngine::from_str(s)) + .collect() } else { - search_duckduckgo_metadata(&query, lim) + // Ordem padrão: Google primeiro, depois outros + vec![ + SearchEngine::Google, + SearchEngine::Bing, + SearchEngine::Yahoo, + SearchEngine::DuckDuckGo, + SearchEngine::Startpage, + ] + }; + + // Se não há engines configuradas, usar DuckDuckGo como fallback + if engines.is_empty() { + log::warn!("No valid engines in order, using DuckDuckGo as fallback"); + return search_duckduckgo_metadata(&query, lim) .await - .map_err(|e| format!("Erro ao buscar metadados: {}", e)) + .map_err(|e| format!("Erro ao buscar metadados: {}", e)); + } + + // Usar multi-engine search + let min_results = 1; // Mínimo de 1 resultado para considerar sucesso + match search_multi_engine_metadata(&query, lim, &engines, min_results).await { + Ok(results) => { + if results.is_empty() && search_config.is_some() { + // Fallback para smart_search se multi-engine retornou vazio + log::info!("Multi-engine returned empty, trying smart_search fallback"); + if let Some(config) = search_config { + match smart_search(&query, &config).await { + Ok(mut urls) => { + urls.truncate(lim); + let metas = urls + .into_iter() + .map(|u| SearchResultMetadata { title: u.clone(), url: u, snippet: String::new() }) + .collect::>(); + Ok(metas) + } + Err(e) => Err(format!("Erro ao executar smart_search: {}", e)), + } + } else { + Ok(results) + } + } else { + Ok(results) + } + } + Err(e) => { + // Se multi-engine falhou completamente, tentar DuckDuckGo como último recurso + log::warn!("Multi-engine search failed: {}, trying DuckDuckGo fallback", e); + search_duckduckgo_metadata(&query, lim) + .await + .map_err(|e| format!("Erro ao buscar metadados: {}", e)) + } } } @@ -1849,6 +2054,45 @@ fn get_app_data_dir(app_handle: AppHandle) -> Result { Ok(format!("{}", app_data_dir.display())) } +/// Salva um arquivo temporário e retorna o caminho +#[command] +fn save_temp_file(app_handle: AppHandle, data: Vec, extension: String) -> Result { + use std::time::{SystemTime, UNIX_EPOCH}; + + // Obter diretório temporário + let temp_dir = std::env::temp_dir(); + + // Criar nome de arquivo único + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + let filename = format!("ollama_model_{}.{}", timestamp, extension); + let temp_path = temp_dir.join(&filename); + + // Escrever arquivo + fs::write(&temp_path, data) + .map_err(|e| format!("Erro ao salvar arquivo temporário: {}", e))?; + + Ok(temp_path.to_string_lossy().to_string()) +} + +/// Abre um dialog de seleção de arquivo GGUF usando dialog nativo do sistema +#[command] +async fn open_gguf_file_dialog() -> Result, String> { + use rfd::FileDialog; + + // No rfd, o filtro "*" não funciona corretamente no Windows. + // Para garantir que todos os arquivos sejam mostrados, vamos criar + // um dialog sem filtro algum. O dialog nativo do Windows mostrará + // todos os arquivos por padrão quando não há filtro. + let file = FileDialog::new() + .set_title("Selecionar modelo GGUF") + .pick_file(); + + Ok(file.map(|p| p.to_string_lossy().to_string())) +} + // ========== Sources Config Commands ========== /// Carrega a configuração de fontes de busca @@ -2264,6 +2508,12 @@ fn get_system_stats( Ok(monitor.get_stats()) } +/// Obtém estatísticas detalhadas de uma GPU específica +#[command] +fn get_gpu_stats(gpu_id: Option) -> Result, String> { + Ok(system_monitor::get_gpu_stats(gpu_id.as_deref())) +} + // ========== Task Scheduler Commands ========== #[command] @@ -2434,8 +2684,12 @@ pub fn run() { get_operating_system, check_if_model_installed, pull_model, + install_gguf_model, + save_temp_file, + open_gguf_file_dialog, start_ollama_server, start_system_monitor, + get_gpu_stats, list_local_models, delete_model, save_chat_session, diff --git a/src-tauri/src/system_monitor.rs b/src-tauri/src/system_monitor.rs index f9ff433..c930aeb 100644 --- a/src-tauri/src/system_monitor.rs +++ b/src-tauri/src/system_monitor.rs @@ -10,6 +10,36 @@ pub struct GpuInfo { pub memory_mb: Option, } +/// Estatísticas detalhadas de uma GPU +#[derive(Serialize, Clone, Debug, Default)] +pub struct GpuStats { + pub id: String, + pub name: String, + pub vendor: Option, + // Memória VRAM + pub vram_used_mb: Option, + pub vram_total_mb: Option, + pub vram_percent: Option, + // Uso de processamento + pub compute_usage_percent: Option, + pub graphics_usage_percent: Option, + pub overall_usage_percent: Option, + // Temperatura + pub temperature_celsius: Option, + pub temperature_max_celsius: Option, + // Energia + pub power_watts: Option, + pub power_max_watts: Option, + // Ventilador + pub fan_speed_rpm: Option, + pub fan_speed_percent: Option, + // Processos + pub processes_count: Option, + // Driver/API + pub driver_version: Option, + pub api: Option, // CUDA, Vulkan, OpenCL, etc. +} + /// Estatísticas do sistema em tempo real #[derive(Serialize, Clone, Debug)] pub struct SystemStats { @@ -615,3 +645,158 @@ fn parse_memory_string(s: &str) -> Option { None } +/// Obtém estatísticas detalhadas de uma GPU específica +pub fn get_gpu_stats(gpu_id: Option<&str>) -> Option { + let gpus = detect_all_gpus(); + + // Se gpu_id fornecido, buscar GPU específica, senão usar primeira GPU + let target_gpu = if let Some(id) = gpu_id { + gpus.iter().find(|g| g.id == id) + } else { + gpus.first() + }?; + + // Tentar obter stats detalhados baseado no vendor + if let Some(vendor) = &target_gpu.vendor { + match vendor.as_str() { + "NVIDIA" => get_nvidia_gpu_stats(target_gpu), + "AMD" => get_amd_gpu_stats(target_gpu), + "Intel" => get_intel_gpu_stats(target_gpu), + _ => get_generic_gpu_stats(target_gpu), + } + } else { + get_generic_gpu_stats(target_gpu) + } +} + +/// Obtém estatísticas detalhadas de GPU NVIDIA via nvidia-smi +fn get_nvidia_gpu_stats(gpu: &GpuInfo) -> Option { + use std::process::Command; + + log::info!("Coletando stats detalhados da GPU NVIDIA: {}", gpu.name); + + // Query nvidia-smi para obter todas as métricas + let query = "name,memory.used,memory.total,utilization.gpu,utilization.memory,temperature.gpu,temperature.max,power.draw,power.limit,fan.speed,driver_version"; + + let output = Command::new("nvidia-smi") + .args(&[ + "--query-gpu", query, + "--format=csv,noheader,nounits" + ]) + .output() + .ok()?; + + if !output.status.success() { + log::warn!("nvidia-smi falhou ao coletar stats"); + return get_generic_gpu_stats(gpu); + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let line = stdout.lines().next()?; + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + + if parts.len() < 11 { + log::warn!("nvidia-smi retornou formato inesperado"); + return get_generic_gpu_stats(gpu); + } + + // Parse dos valores + let vram_used_mb = parts[1].parse::().ok(); + let vram_total_mb = parts[2].parse::().ok(); + let vram_percent = if let (Some(used), Some(total)) = (vram_used_mb, vram_total_mb) { + if total > 0 { + Some((used as f32 / total as f32) * 100.0) + } else { + None + } + } else { + None + }; + + let compute_usage_percent = parts[3].parse::().ok(); + let memory_usage_percent = parts[4].parse::().ok(); + let overall_usage_percent = compute_usage_percent; + + let temperature_celsius = parts[5].parse::().ok(); + let temperature_max_celsius = parts[6].parse::().ok(); + + let power_watts = parts[7].parse::().ok(); + let power_max_watts = parts[8].parse::().ok(); + + let fan_speed_percent = parts[9].parse::().ok(); + let fan_speed_rpm = None; // nvidia-smi não retorna RPM diretamente + + let driver_version = Some(parts[10].to_string()); + + // Contar processos usando GPU + let processes_count = count_nvidia_gpu_processes().unwrap_or(0); + + Some(GpuStats { + id: gpu.id.clone(), + name: gpu.name.clone(), + vendor: gpu.vendor.clone(), + vram_used_mb, + vram_total_mb, + vram_percent, + compute_usage_percent, + graphics_usage_percent: compute_usage_percent, // NVIDIA não diferencia + overall_usage_percent, + temperature_celsius, + temperature_max_celsius, + power_watts, + power_max_watts, + fan_speed_rpm, + fan_speed_percent, + processes_count: Some(processes_count), + driver_version, + api: Some("CUDA".to_string()), + }) +} + +/// Conta processos usando GPU NVIDIA +fn count_nvidia_gpu_processes() -> Result { + use std::process::Command; + + let output = Command::new("nvidia-smi") + .args(&["--query-compute-apps=pid", "--format=csv,noheader"]) + .output() + .map_err(|e| format!("nvidia-smi não encontrado: {}", e))?; + + if !output.status.success() { + return Ok(0); + } + + let stdout = String::from_utf8(output.stdout) + .map_err(|e| format!("Erro ao parsear output: {}", e))?; + + // Contar linhas não vazias + let count = stdout.lines().filter(|l| !l.trim().is_empty()).count(); + Ok(count) +} + +/// Obtém estatísticas de GPU AMD (implementação básica) +fn get_amd_gpu_stats(gpu: &GpuInfo) -> Option { + log::info!("Coletando stats da GPU AMD: {} (suporte limitado)", gpu.name); + // AMD requer rocm-smi ou outras ferramentas específicas + // Por enquanto, retornar stats genéricos + get_generic_gpu_stats(gpu) +} + +/// Obtém estatísticas de GPU Intel (implementação básica) +fn get_intel_gpu_stats(gpu: &GpuInfo) -> Option { + log::info!("Coletando stats da GPU Intel: {} (suporte limitado)", gpu.name); + // Intel requer intel_gpu_top ou outras ferramentas específicas + get_generic_gpu_stats(gpu) +} + +/// Retorna stats genéricos quando não há suporte específico +fn get_generic_gpu_stats(gpu: &GpuInfo) -> Option { + Some(GpuStats { + id: gpu.id.clone(), + name: gpu.name.clone(), + vendor: gpu.vendor.clone(), + vram_total_mb: gpu.memory_mb, + ..Default::default() + }) +} + diff --git a/src-tauri/src/web_scraper.rs b/src-tauri/src/web_scraper.rs index 6599f90..32f84f5 100644 --- a/src-tauri/src/web_scraper.rs +++ b/src-tauri/src/web_scraper.rs @@ -8,6 +8,7 @@ use url::Url; use rand::Rng; use tokio::sync::Semaphore; use regex::Regex; +use std::time::Instant; /// Resultado da extração de conteúdo de uma URL #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] @@ -57,6 +58,178 @@ fn default_total_sources() -> usize { 100 } +/// Enum para identificar diferentes motores de busca +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchEngine { + Google, + Bing, + Yahoo, + DuckDuckGo, + Startpage, +} + +impl SearchEngine { + /// Converte string para SearchEngine + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "google" => Some(SearchEngine::Google), + "bing" => Some(SearchEngine::Bing), + "yahoo" => Some(SearchEngine::Yahoo), + "duckduckgo" | "duck_duck_go" => Some(SearchEngine::DuckDuckGo), + "startpage" => Some(SearchEngine::Startpage), + _ => None, + } + } + + /// Retorna nome do motor como string + pub fn as_str(&self) -> &'static str { + match self { + SearchEngine::Google => "Google", + SearchEngine::Bing => "Bing", + SearchEngine::Yahoo => "Yahoo", + SearchEngine::DuckDuckGo => "DuckDuckGo", + SearchEngine::Startpage => "Startpage", + } + } + + /// Retorna URL base de busca + fn base_url(&self) -> &'static str { + match self { + SearchEngine::Google => "https://www.google.com/search", + SearchEngine::Bing => "https://www.bing.com/search", + SearchEngine::Yahoo => "https://search.yahoo.com/search", + SearchEngine::DuckDuckGo => "https://html.duckduckgo.com/html", + SearchEngine::Startpage => "https://www.startpage.com/sp/search", + } + } + + /// Retorna selectors CSS específicos para cada motor + fn selectors(&self) -> SearchSelectors { + match self { + SearchEngine::Google => SearchSelectors { + container: vec![ + "div.g", + "div[data-ved]", + ".tF2Cxc", + ], + title: vec![ + "h3", + ".LC20lb", + ".DKV0Md", + ], + url: vec![ + "a[href]", + "cite", + ], + snippet: vec![ + ".VwiC3b", + ".s", + ".st", + ], + }, + SearchEngine::Bing => SearchSelectors { + container: vec![ + ".b_algo", + "li.b_algo", + ], + title: vec![ + "h2 a", + ".b_title a", + ], + url: vec![ + "h2 a[href]", + ".b_title a[href]", + ], + snippet: vec![ + ".b_caption p", + ".b_caption", + ], + }, + SearchEngine::Yahoo => SearchSelectors { + container: vec![ + ".dd.algo", + ".Sr", + ], + title: vec![ + "h3 a", + ".ac-algo h3 a", + ], + url: vec![ + "h3 a[href]", + ".ac-algo h3 a[href]", + ], + snippet: vec![ + ".ac-algo .ac-text", + ".compText", + ], + }, + SearchEngine::DuckDuckGo => SearchSelectors { + container: vec![ + ".result", + ".web-result", + ".result__body", + ], + title: vec![ + ".result__a", + ".web-result__link", + "a.result__a", + ], + url: vec![ + ".result__a[href]", + ".web-result__link[href]", + ], + snippet: vec![ + ".result__snippet", + ".result__snippet.js-result-snippet", + ".web-result__snippet", + ], + }, + SearchEngine::Startpage => SearchSelectors { + container: vec![ + ".w-gl__result", + ".result", + ], + title: vec![ + ".w-gl__result-title a", + "h3 a", + ], + url: vec![ + ".w-gl__result-title a[href]", + "h3 a[href]", + ], + snippet: vec![ + ".w-gl__result-snippet", + ".snippet", + ], + }, + } + } + + /// Normaliza query para o motor específico + fn normalize_query(&self, query: &str) -> String { + // Todos os motores usam encoding padrão, mas alguns podem ter requisitos específicos + query.trim().to_string() + } +} + +/// Estrutura para selectors CSS de cada motor +struct SearchSelectors { + container: Vec<&'static str>, + title: Vec<&'static str>, + url: Vec<&'static str>, + snippet: Vec<&'static str>, +} + +/// Log de tentativa de busca em um motor +struct SearchAttemptLog { + engine: SearchEngine, + query: String, + success: bool, + results_count: usize, + duration_ms: u64, + error: Option, +} + /// Pool de User-Agents para rotação (evita bloqueios 429) const USER_AGENTS: &[&str] = &[ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", @@ -128,6 +301,389 @@ pub async fn search_duckduckgo(query: &str, limit: usize) -> Result> Ok(links) } +/// Busca no Google retornando apenas metadados (título, URL, snippet) +pub async fn search_google_metadata(query: &str, limit: usize) -> Result> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + + let url = format!("{}?q={}&num={}", + SearchEngine::Google.base_url(), + urlencoding::encode(query), + limit.min(100) + ); + + let user_agent = get_random_user_agent(); + let start_time = Instant::now(); + + log::info!("[SearchEngine:Google] Query: '{}', Attempting...", query); + + let res = match client + .get(&url) + .header(USER_AGENT, user_agent) + .send() + .await + { + Ok(r) => r.text().await?, + Err(e) => { + let duration = start_time.elapsed().as_millis() as u64; + log::warn!("[SearchEngine:Google] Failed: {} ({}ms)", e, duration); + return Err(anyhow::anyhow!("Google search failed: {}", e)); + } + }; + + let mut results: Vec = Vec::new(); + let selectors = SearchEngine::Google.selectors(); + let document = Html::parse_document(&res); + + for cont_sel in &selectors.container { + if results.len() >= limit { break; } + if let Ok(container) = Selector::parse(cont_sel) { + for node in document.select(&container) { + if results.len() >= limit { break; } + + let mut found_url: Option = None; + let mut found_title: Option = None; + + // Buscar título + for tsel in &selectors.title { + if let Ok(ts) = Selector::parse(tsel) { + if let Some(a) = node.select(&ts).next() { + // Extrair URL + if let Some(href) = a.value().attr("href") { + let cleaned = clean_url(href); + if cleaned.is_some() { + found_url = cleaned; + } + } + // Extrair título + let text = a.text().collect::>().join(" ").trim().to_string(); + if !text.is_empty() { found_title = Some(text); } + } + if found_url.is_some() && found_title.is_some() { break; } + } + } + + if found_url.is_none() { continue; } + + // Buscar snippet + let mut snippet_text = String::new(); + for ssel in &selectors.snippet { + if let Ok(ss) = Selector::parse(ssel) { + if let Some(s) = node.select(&ss).next() { + let t = s.text().collect::>().join(" "); + let norm = t.split_whitespace().collect::>().join(" "); + if !norm.is_empty() { snippet_text = norm; break; } + } + } + } + + let url_final = found_url.unwrap(); + if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; } + + results.push(SearchResultMetadata { + title: found_title.unwrap_or_else(|| url_final.clone()), + url: url_final, + snippet: snippet_text, + }); + } + } + } + + let duration = start_time.elapsed().as_millis() as u64; + if results.is_empty() { + log::warn!("[SearchEngine:Google] No results found ({}ms)", duration); + } else { + log::info!("[SearchEngine:Google] Found {} results ({}ms)", results.len(), duration); + } + + Ok(results) +} + +/// Busca no Bing retornando apenas metadados (título, URL, snippet) +pub async fn search_bing_metadata(query: &str, limit: usize) -> Result> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + + let url = format!("{}?q={}&count={}", + SearchEngine::Bing.base_url(), + urlencoding::encode(query), + limit.min(50) + ); + + let user_agent = get_random_user_agent(); + let start_time = Instant::now(); + + log::info!("[SearchEngine:Bing] Query: '{}', Attempting...", query); + + let res = match client + .get(&url) + .header(USER_AGENT, user_agent) + .send() + .await + { + Ok(r) => r.text().await?, + Err(e) => { + let duration = start_time.elapsed().as_millis() as u64; + log::warn!("[SearchEngine:Bing] Failed: {} ({}ms)", e, duration); + return Err(anyhow::anyhow!("Bing search failed: {}", e)); + } + }; + + let mut results: Vec = Vec::new(); + let selectors = SearchEngine::Bing.selectors(); + let document = Html::parse_document(&res); + + for cont_sel in &selectors.container { + if results.len() >= limit { break; } + if let Ok(container) = Selector::parse(cont_sel) { + for node in document.select(&container) { + if results.len() >= limit { break; } + + let mut found_url: Option = None; + let mut found_title: Option = None; + + for tsel in &selectors.title { + if let Ok(ts) = Selector::parse(tsel) { + if let Some(a) = node.select(&ts).next() { + if let Some(href) = a.value().attr("href") { + let cleaned = clean_url(href); + if cleaned.is_some() { + found_url = cleaned; + } + } + let text = a.text().collect::>().join(" ").trim().to_string(); + if !text.is_empty() { found_title = Some(text); } + } + if found_url.is_some() && found_title.is_some() { break; } + } + } + + if found_url.is_none() { continue; } + + let mut snippet_text = String::new(); + for ssel in &selectors.snippet { + if let Ok(ss) = Selector::parse(ssel) { + if let Some(s) = node.select(&ss).next() { + let t = s.text().collect::>().join(" "); + let norm = t.split_whitespace().collect::>().join(" "); + if !norm.is_empty() { snippet_text = norm; break; } + } + } + } + + let url_final = found_url.unwrap(); + if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; } + + results.push(SearchResultMetadata { + title: found_title.unwrap_or_else(|| url_final.clone()), + url: url_final, + snippet: snippet_text, + }); + } + } + } + + let duration = start_time.elapsed().as_millis() as u64; + if results.is_empty() { + log::warn!("[SearchEngine:Bing] No results found ({}ms)", duration); + } else { + log::info!("[SearchEngine:Bing] Found {} results ({}ms)", results.len(), duration); + } + + Ok(results) +} + +/// Busca no Yahoo retornando apenas metadados (título, URL, snippet) +pub async fn search_yahoo_metadata(query: &str, limit: usize) -> Result> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + + let url = format!("{}?p={}&n={}", + SearchEngine::Yahoo.base_url(), + urlencoding::encode(query), + limit.min(40) + ); + + let user_agent = get_random_user_agent(); + let start_time = Instant::now(); + + log::info!("[SearchEngine:Yahoo] Query: '{}', Attempting...", query); + + let res = match client + .get(&url) + .header(USER_AGENT, user_agent) + .send() + .await + { + Ok(r) => r.text().await?, + Err(e) => { + let duration = start_time.elapsed().as_millis() as u64; + log::warn!("[SearchEngine:Yahoo] Failed: {} ({}ms)", e, duration); + return Err(anyhow::anyhow!("Yahoo search failed: {}", e)); + } + }; + + let mut results: Vec = Vec::new(); + let selectors = SearchEngine::Yahoo.selectors(); + let document = Html::parse_document(&res); + + for cont_sel in &selectors.container { + if results.len() >= limit { break; } + if let Ok(container) = Selector::parse(cont_sel) { + for node in document.select(&container) { + if results.len() >= limit { break; } + + let mut found_url: Option = None; + let mut found_title: Option = None; + + for tsel in &selectors.title { + if let Ok(ts) = Selector::parse(tsel) { + if let Some(a) = node.select(&ts).next() { + if let Some(href) = a.value().attr("href") { + let cleaned = clean_url(href); + if cleaned.is_some() { + found_url = cleaned; + } + } + let text = a.text().collect::>().join(" ").trim().to_string(); + if !text.is_empty() { found_title = Some(text); } + } + if found_url.is_some() && found_title.is_some() { break; } + } + } + + if found_url.is_none() { continue; } + + let mut snippet_text = String::new(); + for ssel in &selectors.snippet { + if let Ok(ss) = Selector::parse(ssel) { + if let Some(s) = node.select(&ss).next() { + let t = s.text().collect::>().join(" "); + let norm = t.split_whitespace().collect::>().join(" "); + if !norm.is_empty() { snippet_text = norm; break; } + } + } + } + + let url_final = found_url.unwrap(); + if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; } + + results.push(SearchResultMetadata { + title: found_title.unwrap_or_else(|| url_final.clone()), + url: url_final, + snippet: snippet_text, + }); + } + } + } + + let duration = start_time.elapsed().as_millis() as u64; + if results.is_empty() { + log::warn!("[SearchEngine:Yahoo] No results found ({}ms)", duration); + } else { + log::info!("[SearchEngine:Yahoo] Found {} results ({}ms)", results.len(), duration); + } + + Ok(results) +} + +/// Busca no Startpage retornando apenas metadados (título, URL, snippet) +pub async fn search_startpage_metadata(query: &str, limit: usize) -> Result> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + + let url = format!("{}?query={}&page=1", + SearchEngine::Startpage.base_url(), + urlencoding::encode(query) + ); + + let user_agent = get_random_user_agent(); + let start_time = Instant::now(); + + log::info!("[SearchEngine:Startpage] Query: '{}', Attempting...", query); + + let res = match client + .get(&url) + .header(USER_AGENT, user_agent) + .send() + .await + { + Ok(r) => r.text().await?, + Err(e) => { + let duration = start_time.elapsed().as_millis() as u64; + log::warn!("[SearchEngine:Startpage] Failed: {} ({}ms)", e, duration); + return Err(anyhow::anyhow!("Startpage search failed: {}", e)); + } + }; + + let mut results: Vec = Vec::new(); + let selectors = SearchEngine::Startpage.selectors(); + let document = Html::parse_document(&res); + + for cont_sel in &selectors.container { + if results.len() >= limit { break; } + if let Ok(container) = Selector::parse(cont_sel) { + for node in document.select(&container) { + if results.len() >= limit { break; } + + let mut found_url: Option = None; + let mut found_title: Option = None; + + for tsel in &selectors.title { + if let Ok(ts) = Selector::parse(tsel) { + if let Some(a) = node.select(&ts).next() { + if let Some(href) = a.value().attr("href") { + let cleaned = clean_url(href); + if cleaned.is_some() { + found_url = cleaned; + } + } + let text = a.text().collect::>().join(" ").trim().to_string(); + if !text.is_empty() { found_title = Some(text); } + } + if found_url.is_some() && found_title.is_some() { break; } + } + } + + if found_url.is_none() { continue; } + + let mut snippet_text = String::new(); + for ssel in &selectors.snippet { + if let Ok(ss) = Selector::parse(ssel) { + if let Some(s) = node.select(&ss).next() { + let t = s.text().collect::>().join(" "); + let norm = t.split_whitespace().collect::>().join(" "); + if !norm.is_empty() { snippet_text = norm; break; } + } + } + } + + let url_final = found_url.unwrap(); + if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; } + + results.push(SearchResultMetadata { + title: found_title.unwrap_or_else(|| url_final.clone()), + url: url_final, + snippet: snippet_text, + }); + } + } + } + + let duration = start_time.elapsed().as_millis() as u64; + if results.is_empty() { + log::warn!("[SearchEngine:Startpage] No results found ({}ms)", duration); + } else { + log::info!("[SearchEngine:Startpage] Found {} results ({}ms)", results.len(), duration); + } + + Ok(results) +} + /// Busca no DuckDuckGo retornando apenas metadados (título, URL, snippet) pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result> { let client = reqwest::Client::builder() @@ -237,6 +793,216 @@ pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result f32 { + let query_lower = query.to_lowercase(); + let query_words: Vec<&str> = query_lower.split_whitespace() + .filter(|w| w.len() > 2) + .collect(); + + if query_words.is_empty() { + return 0.5; // Score neutro se não há palavras-chave + } + + let title_lower = result.title.to_lowercase(); + let snippet_lower = result.snippet.to_lowercase(); + let combined = format!("{} {}", title_lower, snippet_lower); + + let mut matches = 0; + for word in &query_words { + if combined.contains(word) { + matches += 1; + } + } + + let base_score = matches as f32 / query_words.len() as f32; + + // Bônus se palavra está no título + let title_matches = query_words.iter() + .filter(|w| title_lower.contains(*w)) + .count(); + let title_bonus = (title_matches as f32 / query_words.len() as f32) * 0.3; + + // Bônus se snippet não está vazio + let snippet_bonus = if !result.snippet.is_empty() { 0.1 } else { 0.0 }; + + (base_score + title_bonus + snippet_bonus).min(1.0) +} + +/// Busca multi-engine com fallback automático +pub async fn search_multi_engine_metadata( + query: &str, + limit: usize, + engine_order: &[SearchEngine], + min_results: usize, +) -> Result> { + let mut all_results: Vec = Vec::new(); + let mut seen_urls = std::collections::HashSet::new(); + let mut attempt_logs: Vec = Vec::new(); + + log::info!("[MultiEngine] Starting search for: '{}'", query); + log::info!("[MultiEngine] Engine order: {:?}", engine_order.iter().map(|e| e.as_str()).collect::>()); + log::info!("[MultiEngine] Min results required: {}", min_results); + + for engine in engine_order { + let start_time = Instant::now(); + let mut attempt_log = SearchAttemptLog { + engine: *engine, + query: query.to_string(), + success: false, + results_count: 0, + duration_ms: 0, + error: None, + }; + + let result = match *engine { + SearchEngine::Google => search_google_metadata(query, limit).await, + SearchEngine::Bing => search_bing_metadata(query, limit).await, + SearchEngine::Yahoo => search_yahoo_metadata(query, limit).await, + SearchEngine::DuckDuckGo => search_duckduckgo_metadata(query, limit).await, + SearchEngine::Startpage => search_startpage_metadata(query, limit).await, + }; + + attempt_log.duration_ms = start_time.elapsed().as_millis() as u64; + + match result { + Ok(mut engine_results) => { + // Filtrar duplicatas + engine_results.retain(|r| { + if seen_urls.contains(&r.url) { + false + } else { + seen_urls.insert(r.url.clone()); + true + } + }); + + attempt_log.results_count = engine_results.len(); + attempt_log.success = true; + + if !engine_results.is_empty() { + log::info!("[MultiEngine:{}] Found {} unique results ({}ms)", + engine.as_str(), engine_results.len(), attempt_log.duration_ms); + all_results.extend(engine_results); + + // Se atingiu mínimo necessário, pode parar + if all_results.len() >= min_results { + log::info!("[MultiEngine] Minimum results ({}) reached, stopping early", min_results); + break; + } + } else { + log::warn!("[MultiEngine:{}] No results found ({}ms), trying next engine...", + engine.as_str(), attempt_log.duration_ms); + } + } + Err(e) => { + let error_msg = format!("{}", e); + attempt_log.error = Some(error_msg.clone()); + log::warn!("[MultiEngine:{}] Failed: {} ({}ms), trying next engine...", + engine.as_str(), error_msg, attempt_log.duration_ms); + } + } + + attempt_logs.push(attempt_log); + } + + // Ranquear resultados por relevância + let mut scored_results: Vec<(SearchResultMetadata, f32)> = all_results + .into_iter() + .map(|r| { + let score = calculate_relevance_score(&r, query); + (r, score) + }) + .collect(); + + // Ordenar por score (maior primeiro) + scored_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // Retornar top limit resultados + let final_results: Vec = scored_results + .into_iter() + .take(limit) + .map(|(r, _)| r) + .collect(); + + // Log resumo + log::info!("[MultiEngine] Final results: {} (from {} engines)", + final_results.len(), attempt_logs.len()); + for log_entry in &attempt_logs { + if log_entry.success { + log::info!(" ✓ {}: {} results ({}ms)", + log_entry.engine.as_str(), log_entry.results_count, log_entry.duration_ms); + } else { + log::warn!(" ✗ {}: Failed - {} ({}ms)", + log_entry.engine.as_str(), + log_entry.error.as_ref().unwrap_or(&"Unknown error".to_string()), + log_entry.duration_ms); + } + } + + Ok(final_results) +} + +/// Expande query semanticamente (adiciona sinônimos, remove stopwords) +pub fn expand_query_semantic(query: &str, language: &str) -> Vec { + let mut variants = Vec::new(); + + // Query original sempre incluída + variants.push(query.trim().to_string()); + + // Stopwords por idioma + let stopwords: Vec<&str> = match language { + "pt-BR" | "pt" => vec!["o", "a", "os", "as", "de", "do", "da", "dos", "das", "em", "no", "na", "nos", "nas", "para", "por", "com", "sem", "que", "qual", "quais"], + "en" => vec!["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by"], + "es" => vec!["el", "la", "los", "las", "de", "del", "en", "un", "una", "para", "por", "con", "sin"], + _ => vec![], + }; + + // Remover stopwords + let words: Vec<&str> = query.split_whitespace() + .filter(|w| !stopwords.contains(&w.to_lowercase().as_str())) + .collect(); + + if words.len() > 1 { + let without_stopwords = words.join(" "); + if without_stopwords != query.trim() { + variants.push(without_stopwords); + } + } + + // Sinônimos comuns (básico - pode ser expandido) + let synonyms: Vec<(&str, &str)> = match language { + "pt-BR" | "pt" => vec![ + ("pesquisa", "estudo investigação"), + ("resultado", "achado descoberta"), + ("acadêmico", "científico universitário"), + ], + "en" => vec![ + ("research", "study investigation"), + ("result", "finding discovery"), + ("academic", "scientific scholarly"), + ], + _ => vec![], + }; + + // Adicionar variantes com sinônimos + for (original, replacements) in synonyms { + if query.to_lowercase().contains(original) { + for replacement in replacements.split_whitespace() { + let variant = query.to_lowercase().replace(original, replacement); + if variant != query.to_lowercase() { + variants.push(variant); + } + } + } + } + + // Remover duplicatas e retornar + variants.sort(); + variants.dedup(); + variants +} + /// Extrai a URL real do redirecionamento do DuckDuckGo fn extract_real_url(ddg_redirect: &str) -> Option { // DuckDuckGo usa formato: /l/?kh=-1&uddg= diff --git a/store/settings-store.ts b/store/settings-store.ts index 438a5b5..8bf5255 100644 --- a/store/settings-store.ts +++ b/store/settings-store.ts @@ -27,6 +27,10 @@ export interface SettingsState { totalSourcesLimit: number; categories: SearchCategory[]; userCustomSites: string[]; + engineOrder: string[]; // Ordem dos motores de busca: ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'] + minResultsPerEngine: number; // Mínimo de resultados para considerar sucesso + enableSemanticExpansion: boolean; // Habilitar expansão semântica de queries + semanticExpansionLanguage: string; // Idioma para expansão: 'pt-BR', 'en', 'es' }; // Content Processing @@ -72,6 +76,10 @@ export interface SettingsState { setWebSearchTimeout: (timeout: number) => void; setWebSearchMaxConcurrentTabs: (max: number) => void; setWebSearchTotalSourcesLimit: (limit: number) => void; + setWebSearchEngineOrder: (order: string[]) => void; + setWebSearchMinResultsPerEngine: (min: number) => void; + setWebSearchSemanticExpansion: (enabled: boolean) => void; + setWebSearchSemanticExpansionLanguage: (language: string) => void; addExcludedDomain: (domain: string) => void; removeExcludedDomain: (domain: string) => void; toggleCategory: (categoryId: string) => void; @@ -196,12 +204,16 @@ const initialState = { webSearch: { enabled: true, maxResults: 10, - timeout: 10, + timeout: 15000, // Timeout em milissegundos (15 segundos) excludedDomains: ['youtube.com', 'linkedin.com'], maxConcurrentTabs: 5, totalSourcesLimit: 100, categories: defaultCategories, userCustomSites: [], + engineOrder: ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'], // Ordem padrão + minResultsPerEngine: 1, // Mínimo de 1 resultado para considerar sucesso + enableSemanticExpansion: true, // Habilitado por padrão + semanticExpansionLanguage: 'pt-BR', // Português brasileiro por padrão }, sourcesConfig: null, autoStart: false, @@ -266,6 +278,22 @@ export const useSettingsStore = create()( set((state) => ({ webSearch: { ...state.webSearch, totalSourcesLimit: limit }, })), + setWebSearchEngineOrder: (order) => + set((state) => ({ + webSearch: { ...state.webSearch, engineOrder: order }, + })), + setWebSearchMinResultsPerEngine: (min) => + set((state) => ({ + webSearch: { ...state.webSearch, minResultsPerEngine: min }, + })), + setWebSearchSemanticExpansion: (enabled) => + set((state) => ({ + webSearch: { ...state.webSearch, enableSemanticExpansion: enabled }, + })), + setWebSearchSemanticExpansionLanguage: (language) => + set((state) => ({ + webSearch: { ...state.webSearch, semanticExpansionLanguage: language }, + })), addExcludedDomain: (domain) => set((state) => { const normalized = domain.toLowerCase().trim().replace(/^https?:\/\//, '').replace(/\/$/, '');