feat: enhance UI and functionality across chat and settings

- Added custom scrollbar styles for improved aesthetics and usability across the interface.
- Implemented responsive adjustments for chat sidebar and input components to enhance mobile experience.
- Integrated tooltip components in select elements for better user guidance and information display.
- Updated chat input and message components for improved styling and functionality.
- Enhanced settings page with semantic query expansion options and improved model selection UI.
- Refactored sidebar list to include better date formatting and tooltip support for session titles.
- Introduced GPU stats monitoring in the system panel for better hardware insights.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-26 19:04:46 -03:00
1 parent 84ea2dec19
commit e5f062040c
33 files changed
+4670 -465

No files matched your search

+165 -80
View File
@@ -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<ThinkingStep | null>(null);
const [processSteps, setProcessSteps] = useState<ProcessStep[]>([]);
const [error, setError] = useState<string | Error | null>(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"
>
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
<MessageSquare className="w-5 h-5" />
@@ -932,48 +955,63 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</Button>
</ResizablePanel>
<ResizableHandle />
<ResizableHandle className="hidden md:block" />
{/* Sessions List */}
{/* Sessions List - Desktop: ResizablePanel, Mobile: Overlay */}
<ResizablePanel
ref={chatsSidebarRef}
defaultSize={15}
minSize={10}
defaultSize={0}
minSize={0}
maxSize={25}
collapsible={true}
collapsedSize={0}
onCollapse={() => 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',
}}
>
<SidebarList
sessions={sessions}
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onSelectSession={(id) => {
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}
/>
</ResizablePanel>
<ResizableHandle />
<ResizableHandle className="hidden md:block" />
{/* Main Chat Area */}
<ResizablePanel defaultSize={60} minSize={40}>
<ResizablePanel defaultSize={100} minSize={75} className="transition-all duration-300">
<div className="h-full flex flex-col">
{/* Header */}
<div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4">
<div className="h-14 border-b flex items-center px-3 sm:px-4 justify-between bg-background/50 backdrop-blur gap-2 sm:gap-4 flex-shrink-0" style={{ paddingLeft: 'clamp(12px, 2vw, 16px)', paddingRight: 'clamp(12px, 2vw, 16px)' }}>
<div className="flex items-center gap-2">
{isChatsSidebarCollapsed ? (
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
<Button
variant="ghost"
size="icon"
onClick={toggleChatsSidebar}
className="h-8 w-8 flex-shrink-0"
aria-label={isChatsSidebarCollapsed ? "Abrir conversas" : "Fechar conversas"}
>
{isChatsSidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
</Button>
) : (
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8 text-muted-foreground">
) : (
<PanelLeftClose className="w-4 h-4" />
</Button>
)}
<div className="font-semibold flex items-center gap-2">
Chat
)}
</Button>
<div className="font-semibold flex items-center gap-2 text-sm sm:text-base">
<span className="hidden sm:inline">Chat</span>
{isGeneratingTitle && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />}
{isDownloading && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
@@ -984,40 +1022,62 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div>
</div>
<div className="flex-1 max-w-[420px] flex items-center gap-2">
<div className="flex-1">
<Select
value={selectedModel || undefined}
onValueChange={(v) => {
if (v === "__add_model__") {
setShowDownloadDialog(true);
// Não alterar selectedModel quando abrir dialog
} else {
setSelectedModel(v);
settings.setSelectedModel(v);
}
}}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{models.map(m => (
<SelectItem key={m.name} value={m.name}>
<span className="flex items-center justify-between w-full">
<span className="truncate">{m.name}</span>
<span className="ml-3 text-xs text-muted-foreground">{m.size}</span>
<div className="flex-1 max-w-full sm:max-w-[420px] flex items-center gap-2 min-w-0">
<div className="flex-1 min-w-0">
<TooltipProvider>
<Select
value={selectedModel || undefined}
onValueChange={(v) => {
if (v === "__add_model__") {
setShowDownloadDialog(true);
// Não alterar selectedModel quando abrir dialog
} else {
setSelectedModel(v);
settings.setSelectedModel(v);
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="h-9 min-w-0 max-w-full">
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
</SelectTrigger>
</TooltipTrigger>
{selectedModel && (
<TooltipContent side="top" className="max-w-[70vw] break-words">
<p className="text-sm">{selectedModel}</p>
</TooltipContent>
)}
</Tooltip>
<SelectContent className="max-w-[90vw] sm:max-w-[420px]">
{models.map(m => (
<Tooltip key={m.name}>
<TooltipTrigger asChild>
<SelectItem value={m.name} className="min-w-0">
<span className="flex items-center justify-between w-full min-w-0 gap-2">
<span className="truncate flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
{m.name}
</span>
<span className="ml-2 text-xs text-muted-foreground shrink-0">{m.size}</span>
</span>
</SelectItem>
</TooltipTrigger>
{m.name.length > 40 && (
<TooltipContent side="right" className="max-w-[70vw] break-words">
<p className="text-sm">{m.name}</p>
</TooltipContent>
)}
</Tooltip>
))}
<SelectItem value="__add_model__" className="text-primary font-medium">
<span className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Adicionar modelo...
</span>
</SelectItem>
))}
<SelectItem value="__add_model__" className="text-primary font-medium">
<span className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Adicionar modelo...
</span>
</SelectItem>
</SelectContent>
</Select>
</SelectContent>
</Select>
</TooltipProvider>
</div>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
<Loader2 className="w-4 h-4" />
@@ -1028,8 +1088,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
{/* Messages Area with Floating Input Layout */}
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
{/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto scroll-smooth w-full">
<div className="max-w-3xl mx-auto w-full px-4 md:px-0 pt-6 pb-4">
<div className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0">
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
{messages.length === 0 ? (
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
@@ -1041,7 +1101,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div>
</div>
) : (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:gap-6">
{messages.map((msg, i) => {
const isLastMessage = i === messages.length - (isLoading ? 1 : 0); // Ajuste simples
// Streaming logica mantida simples
@@ -1062,11 +1122,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
)}
</div>
)}
{/* Elemento invisível para scroll automático */}
<div ref={messagesEndRef} className="h-1" />
</div>
{/* Debug Info (Moved inside scroll) */}
{(deepResearch.state.logs.length > 0 || lastWebContext) && (
<div className="mt-12 pt-6 border-t border-dashed border-muted/50 opacity-70 hover:opacity-100 transition-opacity">
@@ -1138,12 +1194,12 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div>
{/* Fixed Input Container */}
<div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-none">
<div className="absolute bottom-0 left-0 right-0 z-30 pointer-events-none">
{/* Gradient Overlay */}
<div className="absolute inset-0 -top-20 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none" />
{/* Input Wrapper */}
<div className="relative max-w-3xl mx-auto px-4 pb-6 pt-4 pointer-events-auto">
<div className="relative max-w-3xl mx-auto px-3 sm:px-4 md:px-6 pb-4 sm:pb-6 pt-4 pointer-events-auto transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
<ChatInput
onSend={handleSend}
onStop={stop}
@@ -1163,9 +1219,38 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div>
</div>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* Mobile Sidebar Overlay */}
{!isChatsSidebarCollapsed && (
<>
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden transition-opacity"
onClick={() => {
setIsChatsSidebarCollapsed(true);
chatsSidebarRef.current?.collapse();
}}
/>
<div className="fixed left-0 top-0 bottom-0 w-[280px] min-w-[220px] max-w-[85vw] bg-background border-r z-50 md:hidden shadow-xl animate-in slide-in-from-left duration-300 overflow-y-auto">
<SidebarList
sessions={sessions}
currentSessionId={currentSessionId}
onSelectSession={(id) => {
handleSelectSession(id);
setIsChatsSidebarCollapsed(true);
}}
onDeleteSession={deleteSession}
onNewChat={() => {
handleNewChat();
setIsChatsSidebarCollapsed(true);
}}
/>
</div>
</>
)}
<ModelDownloadDialog
open={showDownloadDialog}
onOpenChange={(open) => {
+145
View File
@@ -152,3 +152,148 @@
@apply bg-background text-foreground;
}
}
/* Custom Scrollbar Styles - Padronizado para toda interface */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--primary));
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.8);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--primary)) hsl(var(--muted));
}
/* Responsive adjustments */
@media (max-width: 640px) {
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
}
/* Responsive layout adjustments */
@media (max-width: 650px) {
/* Sidebar compact mode - hide labels, show only icons */
.sidebar-compact .sidebar-label {
display: none;
}
.sidebar-compact .sidebar-icon {
font-size: 24px;
}
}
/* Ensure proper spacing and padding at all resolutions */
@media (min-width: 320px) and (max-width: 2560px) {
/* Prevent layout breaking at extreme sizes */
.chat-container {
min-width: 320px;
max-width: 100vw;
}
.message-container {
padding-left: clamp(12px, 4vw, 24px);
padding-right: clamp(12px, 4vw, 24px);
}
}
/* Prevent text overflow in cards and messages */
.truncate-with-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Ensure cards never stick to edges */
.card-container {
padding: clamp(8px, 2vw, 16px);
}
/* Responsive font sizes */
@media (max-width: 640px) {
.responsive-text {
font-size: clamp(14px, 4vw, 16px);
}
}
/* Model name truncation - ensure long model names don't break layout */
.model-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70vw;
display: inline-block;
vertical-align: middle;
}
@media (max-width: 600px) {
.model-name {
max-width: 54vw;
font-size: 15px;
}
}
/* Select value truncation */
[data-radix-select-trigger] span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: block;
}
/* Ensure SelectItems also truncate properly */
[data-radix-select-item] {
min-width: 0;
}
[data-radix-select-item] span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70vw;
display: block;
}
/* Scrollbar específico para lista de chats - mais fino e discreto */
.sidebar-chat-list::-webkit-scrollbar {
width: 6px;
}
.sidebar-chat-list::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.sidebar-chat-list::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
transition: background 0.2s ease;
}
.sidebar-chat-list::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.6);
}
/* Firefox scrollbar para lista de chats */
.sidebar-chat-list {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
+177 -18
View File
@@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Dialog,
DialogContent,
@@ -304,21 +305,43 @@ export default function SettingsPage() {
Carregando modelos...
</div>
) : availableModels.length > 0 ? (
<Select
value={settings.selectedModel}
onValueChange={settings.setSelectedModel}
>
<SelectTrigger id="model-select">
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<Select
value={settings.selectedModel}
onValueChange={settings.setSelectedModel}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
</SelectTrigger>
</TooltipTrigger>
{settings.selectedModel && (
<TooltipContent side="top" className="max-w-[70vw] break-words">
<p className="text-sm">{settings.selectedModel}</p>
</TooltipContent>
)}
</Tooltip>
<SelectContent className="max-w-[90vw]">
{availableModels.map((model) => (
<Tooltip key={model.name}>
<TooltipTrigger asChild>
<SelectItem value={model.name} className="min-w-0">
<span className="truncate block" style={{ maxWidth: '70vw' }}>
{model.name}
</span>
</SelectItem>
</TooltipTrigger>
{model.name.length > 40 && (
<TooltipContent side="right" className="max-w-[70vw] break-words">
<p className="text-sm">{model.name}</p>
</TooltipContent>
)}
</Tooltip>
))}
</SelectContent>
</Select>
</TooltipProvider>
) : (
<Input
id="model-input"
@@ -463,12 +486,12 @@ export default function SettingsPage() {
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>10s</span>
<span className="font-medium">{settings.webSearch.timeout}s</span>
<span className="font-medium">{settings.webSearch.timeout / 1000}s</span>
<span>60s</span>
</div>
<Slider
value={[settings.webSearch.timeout]}
onValueChange={([value]) => settings.setWebSearchTimeout(value)}
value={[settings.webSearch.timeout / 1000]}
onValueChange={([value]) => settings.setWebSearchTimeout(value * 1000)}
min={10}
max={60}
step={5}
@@ -487,6 +510,142 @@ export default function SettingsPage() {
/>
</CardContent>
</Card>
{/* Card: Search Engines Configuration */}
<Card>
<CardHeader>
<CardTitle>Motores de Busca</CardTitle>
<CardDescription>
Configure a ordem e comportamento dos motores de busca
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Ordem dos Motores</Label>
<p className="text-xs text-muted-foreground">
Arraste para reordenar. O sistema tentará cada motor na ordem especificada até encontrar resultados suficientes.
</p>
<div className="space-y-2 border rounded-lg p-4">
{settings.webSearch.engineOrder.map((engine, index) => (
<div
key={engine}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono text-xs">
{index + 1}
</Badge>
<span className="text-sm font-medium capitalize">{engine}</span>
</div>
<div className="flex items-center gap-1">
{index > 0 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const newOrder = [...settings.webSearch.engineOrder];
[newOrder[index], newOrder[index - 1]] = [newOrder[index - 1], newOrder[index]];
settings.setWebSearchEngineOrder(newOrder);
}}
>
<ArrowLeft className="w-3 h-3" />
</Button>
)}
{index < settings.webSearch.engineOrder.length - 1 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const newOrder = [...settings.webSearch.engineOrder];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
settings.setWebSearchEngineOrder(newOrder);
}}
>
<ArrowLeft className="w-3 h-3 rotate-180" />
</Button>
)}
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="min-results-per-engine">Mínimo de Resultados por Motor</Label>
<div className="space-y-2">
<div className="flex items-center gap-3">
<Input
id="min-results-per-engine"
type="number"
value={settings.webSearch.minResultsPerEngine}
onChange={(e) => {
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}
/>
<div className="flex-1">
<Slider
value={[settings.webSearch.minResultsPerEngine]}
onValueChange={([value]) => settings.setWebSearchMinResultsPerEngine(value)}
min={1}
max={10}
step={1}
disabled={!settings.webSearch.enabled}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="semantic-expansion">Expansão Semântica de Queries</Label>
<p className="text-xs text-muted-foreground">
Adiciona sinônimos e variantes às queries para melhorar resultados
</p>
</div>
<Switch
id="semantic-expansion"
checked={settings.webSearch.enableSemanticExpansion}
onCheckedChange={settings.setWebSearchSemanticExpansion}
disabled={!settings.webSearch.enabled}
/>
</div>
{settings.webSearch.enableSemanticExpansion && (
<div className="space-y-2">
<Label htmlFor="semantic-language">Idioma para Expansão Semântica</Label>
<Select
value={settings.webSearch.semanticExpansionLanguage}
onValueChange={settings.setWebSearchSemanticExpansionLanguage}
disabled={!settings.webSearch.enabled}
>
<SelectTrigger id="semantic-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Español</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Idioma usado para identificar stopwords e sinônimos durante a expansão semântica.
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Tab: Sources & Knowledge */}
+2 -2
View File
@@ -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 */}
<div className="absolute right-3 bottom-2.5 flex items-center gap-1.5">
<div className="absolute right-2 sm:right-3 bottom-2 sm:bottom-2.5 flex items-center gap-1 sm:gap-1.5">
{onWebSearchToggle && (
<TooltipProvider>
<Tooltip delayDuration={300}>
+2
View File
@@ -123,3 +123,5 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
);
}
+253 -91
View File
@@ -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<string | null>(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<string | null>('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<string>('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();
}
}}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Adicionar Modelo</DialogTitle>
<DialogDescription>
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
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="model-name">Nome do Modelo</Label>
<Input
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ex: llama3.2:1b"
disabled={isDownloading}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid && !isDownloading) {
handleDownload();
}
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{isInstalled && !isDownloading && (
<p className="text-sm text-green-600 dark:text-green-400">
✓ Este modelo já está instalado
</p>
)}
</div>
{isDownloading && (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "download" | "local")} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="download" className="flex items-center gap-2">
<Globe className="w-4 h-4" />
Baixar do Ollama
</TabsTrigger>
<TabsTrigger value="local" className="flex items-center gap-2">
<Upload className="w-4 h-4" />
Instalar Local
</TabsTrigger>
</TabsList>
<TabsContent value="download" className="space-y-4 mt-4">
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{downloadStatus || "Iniciando..."}</span>
<span>{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadInfo.downloaded && downloadInfo.total
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
: downloadInfo.downloaded || ""}
</span>
{downloadInfo.speed && (
<span>{downloadInfo.speed}</span>
)}
</div>
<Label htmlFor="model-name">Nome do Modelo</Label>
<Input
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ex: llama3.2:1b"
disabled={isDownloading}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid && !isDownloading) {
handleDownload();
}
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{isInstalled && !isDownloading && (
<p className="text-sm text-green-600 dark:text-green-400">
✓ Este modelo já está instalado
</p>
)}
</div>
)}
{isInstalled && !isDownloading && (
<div className="bg-muted/50 p-3 rounded-md text-sm">
<p className="text-muted-foreground">
O modelo está instalado e pronto para uso.
</p>
{isDownloading && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{downloadStatus || "Iniciando..."}</span>
<span>{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadInfo.downloaded && downloadInfo.total
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
: downloadInfo.downloaded || ""}
</span>
{downloadInfo.speed && (
<span>{downloadInfo.speed}</span>
)}
</div>
)}
</div>
)}
{isInstalled && !isDownloading && (
<div className="bg-muted/50 p-3 rounded-md text-sm">
<p className="text-muted-foreground">
O modelo está instalado e pronto para uso.
</p>
</div>
)}
</TabsContent>
<TabsContent value="local" className="space-y-4 mt-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="local-model-name">Nome do Modelo (opcional)</Label>
<Input
id="local-model-name"
value={localModelName}
onChange={(e) => setLocalModelName(e.target.value)}
placeholder="Deixe vazio para usar o nome do arquivo"
disabled={isInstallingLocal}
/>
<p className="text-xs text-muted-foreground">
Se não especificar, o nome será extraído do arquivo
</p>
</div>
<div className="space-y-4">
{isInstallingLocal ? (
<div className="border rounded-xl p-6 bg-muted/30 space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium text-sm">Instalando modelo...</p>
<p className="text-xs text-muted-foreground mt-1">
Processando arquivo...
</p>
</div>
</div>
{localInstallProgress > 0 && (
<div className="space-y-2">
<Progress value={localInstallProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-center">
{localInstallProgress}%
</p>
</div>
)}
</div>
) : (
<>
<div className="border-2 border-dashed rounded-xl p-8 text-center bg-muted/30">
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Selecione um arquivo GGUF para instalar localmente
</p>
<Button
onClick={handleSelectFileButton}
disabled={isInstallingLocal}
className="w-full"
>
<File className="w-4 h-4 mr-2" />
Selecionar arquivo GGUF
</Button>
</div>
{localInstallError && (
<div className="border border-destructive/50 rounded-xl p-4 bg-destructive/5 space-y-2">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm text-destructive">Erro ao instalar modelo</p>
<p className="text-xs text-destructive/80 mt-1">{localInstallError}</p>
</div>
</div>
</div>
)}
</>
)}
</div>
<div className="bg-muted/30 border border-border/50 rounded-lg p-3 text-xs text-muted-foreground">
<p className="font-medium mb-1">ℹ️ Modelos instalados localmente</p>
<p>Funcionam sem conexão à internet. O arquivo será copiado para o diretório do Ollama.</p>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isInstallingLocal}
>
{isDownloading ? (
<>
<X className="mr-2 h-4 w-4" />
Cancelar Download
</>
) : (
<>
<X className="mr-2 h-4 w-4" />
Cancelar
</>
)}
</Button>
<Button
onClick={() => {
if (isInstalled && !isDownloading) {
handleSuccess(modelName.trim());
onOpenChange(false);
} else {
handleDownload();
}
}}
disabled={!isValid || isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Baixando...
</>
) : isInstalled ? (
<>
Usar Modelo
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Baixar
</>
)}
<X className="mr-2 h-4 w-4" />
Cancelar
</Button>
{activeTab === "download" && (
<Button
onClick={() => {
if (isInstalled && !isDownloading) {
handleSuccess(modelName.trim());
onOpenChange(false);
} else {
handleDownload();
}
}}
disabled={!isValid || isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Baixando...
</>
) : isInstalled ? (
<>
Usar Modelo
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Baixar
</>
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+49 -24
View File
@@ -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
<label htmlFor="model-select" className="text-sm font-medium">
Modelo para Geração
</label>
<Select
value={selectedModel || undefined}
onValueChange={setSelectedModel}
disabled={loading || models.length === 0}
>
<SelectTrigger id="model-select">
<SelectValue placeholder={
loading
? "Carregando modelos..."
: models.length === 0
? "Nenhum modelo disponível"
: "Selecione um modelo..."
} />
</SelectTrigger>
<SelectContent>
{models.length === 0 ? (
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
) : (
models.map(m => (
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
))
)}
</SelectContent>
</Select>
<TooltipProvider>
<Select
value={selectedModel || undefined}
onValueChange={setSelectedModel}
disabled={loading || models.length === 0}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
<SelectValue placeholder={
loading
? "Carregando modelos..."
: models.length === 0
? "Nenhum modelo disponível"
: "Selecione um modelo..."
} className="truncate" />
</SelectTrigger>
</TooltipTrigger>
{selectedModel && (
<TooltipContent side="top" className="max-w-[70vw] break-words">
<p className="text-sm">{selectedModel}</p>
</TooltipContent>
)}
</Tooltip>
<SelectContent className="max-w-[90vw]">
{models.length === 0 ? (
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
) : (
models.map(m => (
<Tooltip key={m.name}>
<TooltipTrigger asChild>
<SelectItem value={m.name} className="min-w-0">
<span className="truncate block" style={{ maxWidth: '70vw' }}>
{m.name}
</span>
</SelectItem>
</TooltipTrigger>
{m.name.length > 40 && (
<TooltipContent side="right" className="max-w-[70vw] break-words">
<p className="text-sm">{m.name}</p>
</TooltipContent>
)}
</Tooltip>
))
)}
</SelectContent>
</Select>
</TooltipProvider>
<p className="text-xs text-muted-foreground">
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
</p>
+141 -54
View File
@@ -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 (
<div className="flex flex-col h-full border-r bg-muted/10">
<div className="p-4 border-b">
<Button onClick={onNewChat} className="w-full justify-start gap-2" variant="outline">
<Plus className="w-4 h-4" />
Nova Conversa
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2 space-y-2">
{sessions.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
Nenhuma conversa salva
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
className={cn(
"group flex items-center justify-between p-2 rounded-lg cursor-pointer hover:bg-accent transition-colors",
currentSessionId === session.id && "bg-accent"
)}
onClick={() => onSelectSession(session.id)}
>
<div className="flex items-start gap-3 overflow-hidden flex-1">
<span className="text-lg shrink-0 mt-0.5" role="img" aria-label="emoji">
{session.emoji || '💬'}
</span>
<div className="flex flex-col overflow-hidden flex-1">
<span className="text-sm font-medium truncate" title={session.title}>
{session.title}
</span>
<span className="text-xs text-muted-foreground truncate">
{formatDate(session.updated_at)}
</span>
</div>
<TooltipProvider delayDuration={300}>
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar min-w-[240px] max-w-[360px] w-full">
{/* Header com botão Nova Conversa */}
<div className="p-3 sm:p-4 border-b border-sidebar-border flex-shrink-0">
<Button
onClick={onNewChat}
className="w-full justify-start gap-2 text-sm font-medium h-9 sm:h-10"
variant="outline"
>
<Plus className="w-4 h-4 shrink-0" />
<span className="truncate">Nova Conversa</span>
</Button>
</div>
{/* Lista de chats com scroll customizado */}
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
<div className="p-3 sm:p-4 space-y-2 sm:space-y-2.5">
{sessions.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
Nenhuma conversa salva
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Comece uma nova conversa para começar
</p>
</div>
)}
{sessions.map((session) => {
const isSelected = currentSessionId === session.id;
const showTooltip = session.title.length > 30;
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
return (
<Tooltip key={session.id} delayDuration={300} disableHoverableContent>
<TooltipTrigger asChild>
<div
className={cn(
"group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
"hover:shadow-md hover:border-primary/20 hover:-translate-y-0.5",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected && "border-primary/40 shadow-sm bg-accent/50",
"active:scale-[0.98]"
)}
onClick={() => onSelectSession(session.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectSession(session.id);
}
}}
>
{/* Conteúdo do card */}
<div className="p-3 sm:p-3.5 flex items-start gap-3 overflow-hidden">
{/* Ícone/Emoji */}
<div className="shrink-0 flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-md bg-muted/50 text-base sm:text-lg">
{session.emoji || '💬'}
</div>
{/* Texto do card */}
<div className="flex-1 min-w-0 overflow-hidden">
<h3
className="text-sm sm:text-base font-medium text-card-foreground truncate leading-tight mb-1"
>
{session.title}
</h3>
<p className="text-xs text-muted-foreground">
{formatDate(session.updated_at)}
</p>
</div>
{/* Botão de deletar - aparece no hover */}
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 sm:h-8 sm:w-8 shrink-0 transition-all duration-200",
"opacity-0 group-hover:opacity-100",
"hover:bg-destructive/10 hover:text-destructive",
"active:scale-95"
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onDeleteSession(session.id);
}}
aria-label={`Deletar conversa "${session.title}"`}
>
<Trash2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</Button>
</div>
{/* Indicador de seleção */}
{isSelected && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
)}
</div>
</TooltipTrigger>
{showTooltip && (
<TooltipContent side="right" className="max-w-xs">
<p className="break-words font-medium">{session.title}</p>
<p className="text-xs text-muted-foreground mt-1">
{formatDate(session.updated_at)}
</p>
</TooltipContent>
)}
</Tooltip>
);
})}
</div>
</div>
</div>
</div>
</TooltipProvider>
);
}
+94 -28
View File
@@ -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() {
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Modelos Instalados
</h3>
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
Atualizar
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDownloadDialog(true)}
className="h-6 text-xs"
>
<Upload className="w-3 h-3 mr-1" />
Adicionar
</Button>
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
Atualizar
</Button>
</div>
</div>
<div className="space-y-2">
{models.map((model) => (
<Card key={model.id} className="overflow-hidden">
<CardContent className="p-3 flex items-center justify-between">
<div className="min-w-0">
<div className="font-medium text-sm truncate" title={model.name}>
{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 (
<Card key={model.id} className="overflow-hidden hover:bg-muted/50 transition-colors">
<CardContent className="p-3 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<div className="font-medium text-sm truncate" title={model.name}>
{model.name}
</div>
{isLocal && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
<Upload className="w-2.5 h-2.5 mr-1" />
Local
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Database className="w-3 h-3" />
{model.size}
</div>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Database className="w-3 h-3" />
{model.size}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => deleteModel(model.name)}
>
<Trash2 className="w-4 h-4" />
</Button>
</CardContent>
</Card>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 flex-shrink-0"
onClick={async () => {
if (confirm(`Tem certeza que deseja remover o modelo "${model.name}"?`)) {
try {
await deleteModel(model.name);
} catch (error) {
console.error('Erro ao remover modelo:', error);
}
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Remover modelo</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
);
})}
{models.length === 0 && (
<div className="text-center text-sm text-muted-foreground py-4">
Nenhum modelo encontrado.
<div className="text-center text-sm text-muted-foreground py-8 space-y-2">
<p>Nenhum modelo encontrado.</p>
<Button
variant="outline"
size="sm"
onClick={() => setShowDownloadDialog(true)}
className="mt-2"
>
<Upload className="w-4 h-4 mr-2" />
Instalar Modelo
</Button>
</div>
)}
</div>
</div>
<ModelDownloadDialog
open={showDownloadDialog}
onOpenChange={setShowDownloadDialog}
onSuccess={(modelName) => {
refresh();
setShowDownloadDialog(false);
}}
/>
</div>
);
}
@@ -65,3 +65,5 @@ export function WelcomeNotification() {
+271
View File
@@ -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<GpuStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadStats = async () => {
try {
const data = await invoke<GpuStats | null>('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 (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Carregando estatísticas da GPU...
</CardContent>
</Card>
);
}
if (error || !stats) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{error || 'GPU não detectada ou sem suporte para monitoramento detalhado'}
</CardContent>
</Card>
);
}
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 (
<div className="space-y-4">
{/* Card Principal da GPU */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Monitor className="w-4 h-4" />
GPU
</CardTitle>
<div className="flex items-center gap-2">
{stats.vendor && (
<Badge variant="outline" className="text-xs">
{stats.vendor}
</Badge>
)}
{stats.api && (
<Badge variant="secondary" className="text-xs">
{stats.api}
</Badge>
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-1 truncate" title={stats.name}>
{stats.name}
</p>
{stats.driver_version && (
<p className="text-xs text-muted-foreground">Driver: {stats.driver_version}</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* VRAM */}
{stats.vram_total_mb && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<HardDrive className="w-3 h-3" />
Memória VRAM
</span>
<span className="font-medium">
{formatBytes(stats.vram_used_mb)} / {formatBytes(stats.vram_total_mb)}
{stats.vram_percent && ` (${stats.vram_percent.toFixed(1)}%)`}
</span>
</div>
{stats.vram_percent !== null && (
<Progress
value={stats.vram_percent}
className="h-2"
/>
)}
</div>
)}
{/* Uso de Processamento */}
{stats.overall_usage_percent !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Cpu className="w-3 h-3" />
Uso de Processamento
</span>
<span className="font-medium">{stats.overall_usage_percent.toFixed(1)}%</span>
</div>
<Progress
value={stats.overall_usage_percent}
className="h-2"
/>
{stats.compute_usage_percent !== null && stats.graphics_usage_percent !== null && (
<div className="flex gap-2 text-[10px] text-muted-foreground">
<span>Compute: {stats.compute_usage_percent.toFixed(1)}%</span>
<span>Graphics: {stats.graphics_usage_percent.toFixed(1)}%</span>
</div>
)}
</div>
)}
{/* Grid de Métricas */}
<div className="grid grid-cols-2 gap-3 pt-2">
{/* Temperatura */}
{stats.temperature_celsius !== null && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Thermometer className="w-3 h-3" />
Temperatura
</div>
<div className="flex items-baseline gap-1">
<span className={`text-lg font-semibold ${getTemperatureColor(stats.temperature_celsius)}`}>
{stats.temperature_celsius.toFixed(0)}
</span>
<span className="text-xs text-muted-foreground">°C</span>
{stats.temperature_max_celsius && (
<span className="text-[10px] text-muted-foreground">
/ {stats.temperature_max_celsius.toFixed(0)}°C
</span>
)}
</div>
</div>
)}
{/* Energia */}
{stats.power_watts !== null && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Zap className="w-3 h-3" />
Energia
</div>
<div className="flex items-baseline gap-1">
<span className="text-lg font-semibold">
{stats.power_watts.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">W</span>
{stats.power_max_watts && (
<span className="text-[10px] text-muted-foreground">
/ {stats.power_max_watts.toFixed(1)}W
</span>
)}
</div>
</div>
)}
{/* Ventilador */}
{(stats.fan_speed_percent !== null || stats.fan_speed_rpm !== null) && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Fan className="w-3 h-3" />
Ventilador
</div>
<div className="flex items-baseline gap-1">
{stats.fan_speed_percent !== null && (
<>
<span className="text-lg font-semibold">
{stats.fan_speed_percent.toFixed(0)}
</span>
<span className="text-xs text-muted-foreground">%</span>
</>
)}
{stats.fan_speed_rpm !== null && (
<span className="text-xs text-muted-foreground ml-1">
({stats.fan_speed_rpm} RPM)
</span>
)}
</div>
</div>
)}
{/* Processos */}
{stats.processes_count !== null && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Activity className="w-3 h-3" />
Processos
</div>
<div className="flex items-baseline gap-1">
<span className="text-lg font-semibold">
{stats.processes_count}
</span>
<span className="text-xs text-muted-foreground">
{stats.processes_count === 1 ? 'processo' : 'processos'}
</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+7 -20
View File
@@ -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 (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* CPU Card */}
<Card>
<CardHeader className="pb-3">
@@ -135,25 +137,6 @@ export function HardwareDashboard() {
</CardContent>
</Card>
{/* GPU Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Monitor className="w-4 h-4" />
GPU
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm">
{stats.gpu_name ? (
<span className="text-muted-foreground">{stats.gpu_name}</span>
) : (
<span className="text-muted-foreground italic">Não detectada</span>
)}
</div>
</CardContent>
</Card>
{/* System Info Card */}
<Card>
<CardHeader className="pb-3">
@@ -207,6 +190,10 @@ export function HardwareDashboard() {
</div>
</CardContent>
</Card>
</div>
{/* GPU Dashboard - Substitui o card simples */}
<GpuDashboard pollInterval={2000} />
</div>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"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",
"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}
+71 -11
View File
@@ -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.`
};
+53
View File
@@ -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<QueryContext | null> => {
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<EnrichedQueries | null> => {
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);
+2
View File
@@ -54,3 +54,5 @@ export function useOperatingSystem() {
+14 -2
View File
@@ -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 [];
}
}, []);
+320
View File
@@ -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<ExtractedFact[]> {
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<string, ExtractedFact[]> {
const groups = new Map<string, ExtractedFact[]>();
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<UnifiedContext> {
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<string, { title: string; url: string; factsCount: number }>();
// 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,
};
}
+276
View File
@@ -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<QueryContext> {
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<QueryContext>;
// 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',
};
}
+5 -2
View File
@@ -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<CondensationOptions> = {
@@ -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
+294
View File
@@ -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<EnrichedQueries> {
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<EnrichedQueries>;
// 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;
}
+157 -4
View File
@@ -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) {
+131
View File
@@ -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<T> {
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<T>(
fn: () => Promise<T>,
config: Partial<RetryConfig> = {}
): Promise<RetryResult<T>> {
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<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage = 'Operation timed out'
): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs)
),
]);
}
+213
View File
@@ -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');
}
+2
View File
@@ -36,3 +36,5 @@ export const deepResearchLog = {
+203 -67
View File
@@ -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
+120 -32
View File
@@ -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<SearchResultMetadata[]> {
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<SearchResultMetadata[]>('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<SearchResultMetadata[]>(
async () => {
const startTime = Date.now();
try {
const promise = invoke<SearchResultMetadata[]>('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<ScrapedContent[]>('scrape_urls', { urls: topUrls });
const contents = await withTimeout(
invoke<ScrapedContent[]>('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: [] };
}
}
+249 -12
View File
@@ -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]]
+1
View File
@@ -45,3 +45,4 @@ regex = "1.10"
walkdir = "2.5"
tauri-plugin-autostart = "2"
dirs = "5.0"
rfd = "0.14"
+270 -16
View File
@@ -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<String>,
) -> Result<String, String> {
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 <name> -f <modelfile>
// Mas para GGUF direto, podemos usar: ollama create <name> --file <path>
// 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<R: Read>(reader: &mut BufReader<R>, buffer: &mut Vec<u8>) -> Result<usize, std::io::Error> {
@@ -1599,6 +1763,7 @@ async fn search_web_metadata(
query: String,
limit: Option<usize>,
search_config: Option<SearchConfig>,
engine_order: Option<Vec<String>>,
) -> Result<Vec<SearchResultMetadata>, 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::<Vec<_>>();
Ok(metas)
}
Err(e) => Err(format!("Erro ao executar smart_search: {}", e)),
}
// Converter engine_order (strings) para Vec<SearchEngine>
let engines: Vec<SearchEngine> = 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::<Vec<_>>();
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<String, String> {
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<u8>, extension: String) -> Result<String, String> {
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<Option<String>, 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<String>) -> Result<Option<GpuStats>, 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,
+185
View File
@@ -10,6 +10,36 @@ pub struct GpuInfo {
pub memory_mb: Option<u64>,
}
/// Estatísticas detalhadas de uma GPU
#[derive(Serialize, Clone, Debug, Default)]
pub struct GpuStats {
pub id: String,
pub name: String,
pub vendor: Option<String>,
// Memória VRAM
pub vram_used_mb: Option<u64>,
pub vram_total_mb: Option<u64>,
pub vram_percent: Option<f32>,
// Uso de processamento
pub compute_usage_percent: Option<f32>,
pub graphics_usage_percent: Option<f32>,
pub overall_usage_percent: Option<f32>,
// Temperatura
pub temperature_celsius: Option<f32>,
pub temperature_max_celsius: Option<f32>,
// Energia
pub power_watts: Option<f32>,
pub power_max_watts: Option<f32>,
// Ventilador
pub fan_speed_rpm: Option<u32>,
pub fan_speed_percent: Option<f32>,
// Processos
pub processes_count: Option<usize>,
// Driver/API
pub driver_version: Option<String>,
pub api: Option<String>, // 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<u64> {
None
}
/// Obtém estatísticas detalhadas de uma GPU específica
pub fn get_gpu_stats(gpu_id: Option<&str>) -> Option<GpuStats> {
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<GpuStats> {
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::<u64>().ok();
let vram_total_mb = parts[2].parse::<u64>().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::<f32>().ok();
let memory_usage_percent = parts[4].parse::<f32>().ok();
let overall_usage_percent = compute_usage_percent;
let temperature_celsius = parts[5].parse::<f32>().ok();
let temperature_max_celsius = parts[6].parse::<f32>().ok();
let power_watts = parts[7].parse::<f32>().ok();
let power_max_watts = parts[8].parse::<f32>().ok();
let fan_speed_percent = parts[9].parse::<f32>().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<usize, String> {
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<GpuStats> {
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<GpuStats> {
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<GpuStats> {
Some(GpuStats {
id: gpu.id.clone(),
name: gpu.name.clone(),
vendor: gpu.vendor.clone(),
vram_total_mb: gpu.memory_mb,
..Default::default()
})
}
+766
View File
@@ -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<Self> {
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<String>,
}
/// 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<Vec<String>>
Ok(links)
}
/// Busca no Google retornando apenas metadados (título, URL, snippet)
pub async fn search_google_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
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<SearchResultMetadata> = 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<String> = None;
let mut found_title: Option<String> = 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::<Vec<_>>().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::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().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<Vec<SearchResultMetadata>> {
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<SearchResultMetadata> = 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<String> = None;
let mut found_title: Option<String> = 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::<Vec<_>>().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::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().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<Vec<SearchResultMetadata>> {
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<SearchResultMetadata> = 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<String> = None;
let mut found_title: Option<String> = 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::<Vec<_>>().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::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().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<Vec<SearchResultMetadata>> {
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<SearchResultMetadata> = 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<String> = None;
let mut found_title: Option<String> = 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::<Vec<_>>().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::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().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<Vec<SearchResultMetadata>> {
let client = reqwest::Client::builder()
@@ -237,6 +793,216 @@ pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec
Ok(results)
}
/// Calcula score de relevância baseado em matches de palavras-chave
fn calculate_relevance_score(result: &SearchResultMetadata, query: &str) -> 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<Vec<SearchResultMetadata>> {
let mut all_results: Vec<SearchResultMetadata> = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
let mut attempt_logs: Vec<SearchAttemptLog> = Vec::new();
log::info!("[MultiEngine] Starting search for: '{}'", query);
log::info!("[MultiEngine] Engine order: {:?}", engine_order.iter().map(|e| e.as_str()).collect::<Vec<_>>());
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<SearchResultMetadata> = 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<String> {
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<String> {
// DuckDuckGo usa formato: /l/?kh=-1&uddg=<URL_ENCODED>
+29 -1
View File
@@ -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<SettingsState>()(
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(/\/$/, '');