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:
1 parent
84ea2dec19
commit
e5f062040c
33 files changed
+4670
-465
No files matched your search
+165
-80
@@ -5,7 +5,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { ChatInput } from "@/components/chat/chat-input";
|
||||||
import { ChatMessage } from "@/components/chat/chat-message";
|
import { ChatMessage } from "@/components/chat/chat-message";
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export default function ChatPage() {
|
|||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
// Initialize with default format prompt
|
// Initialize with default format prompt
|
||||||
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
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 [thinkingStep, setThinkingStep] = useState<ThinkingStep | null>(null);
|
||||||
const [processSteps, setProcessSteps] = useState<ProcessStep[]>([]);
|
const [processSteps, setProcessSteps] = useState<ProcessStep[]>([]);
|
||||||
const [error, setError] = useState<string | Error | null>(null);
|
const [error, setError] = useState<string | Error | null>(null);
|
||||||
@@ -398,7 +399,11 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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(
|
const fallbackResult = await executeProgressiveSearch(
|
||||||
query,
|
query,
|
||||||
async (q, limit) => {
|
async (q, limit) => {
|
||||||
@@ -410,7 +415,10 @@ export default function ChatPage() {
|
|||||||
maxResultsPerRound: maxResults,
|
maxResultsPerRound: maxResults,
|
||||||
maxTotalResults: 30, // Máximo 30 resultados por query
|
maxTotalResults: 30, // Máximo 30 resultados por query
|
||||||
minRelevanceScore: 0.3,
|
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 };
|
return { query, results: fallbackResult.scrapedSources, fallbackResult };
|
||||||
} catch (err) {
|
} 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 };
|
return { query, results: [] as ScrapedContent[], fallbackResult: null };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -563,25 +587,19 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Tratar erro graciosamente sem quebrar o fluxo
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
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', {
|
updateThinkingMessage('web-research', {
|
||||||
status: 'error',
|
status: 'completed', // Marcar como completed mesmo com erro parcial
|
||||||
error: errorMsg,
|
details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`,
|
||||||
duration: Date.now() - webResearchStart,
|
duration: Date.now() - webResearchStart,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atualizar step com erro (para compatibilidade)
|
// Não definir erro global - permitir que o fluxo continue
|
||||||
setProcessSteps(prev => prev.map(s =>
|
// O sistema continuará com conhecimento interno se necessário
|
||||||
s.id === 'web-research'
|
|
||||||
? { ...s, status: 'error' as const, error: errorMsg }
|
|
||||||
: s
|
|
||||||
));
|
|
||||||
|
|
||||||
// Definir erro global
|
|
||||||
setError(error);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!webSearch.isEnabled) {
|
if (!webSearch.isEnabled) {
|
||||||
@@ -849,7 +867,12 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
const panel = chatsSidebarRef.current;
|
const panel = chatsSidebarRef.current;
|
||||||
if (panel) {
|
if (panel) {
|
||||||
if (isChatsSidebarCollapsed) {
|
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 {
|
} else {
|
||||||
panel.collapse();
|
panel.collapse();
|
||||||
}
|
}
|
||||||
@@ -905,7 +928,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
defaultSize={4}
|
defaultSize={4}
|
||||||
minSize={4}
|
minSize={4}
|
||||||
maxSize={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">
|
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
|
||||||
<MessageSquare className="w-5 h-5" />
|
<MessageSquare className="w-5 h-5" />
|
||||||
@@ -932,48 +955,63 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
</Button>
|
</Button>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle />
|
<ResizableHandle className="hidden md:block" />
|
||||||
|
|
||||||
{/* Sessions List */}
|
{/* Sessions List - Desktop: ResizablePanel, Mobile: Overlay */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
ref={chatsSidebarRef}
|
ref={chatsSidebarRef}
|
||||||
defaultSize={15}
|
defaultSize={0}
|
||||||
minSize={10}
|
minSize={0}
|
||||||
maxSize={25}
|
maxSize={25}
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
collapsedSize={0}
|
collapsedSize={0}
|
||||||
onCollapse={() => setIsChatsSidebarCollapsed(true)}
|
onCollapse={() => setIsChatsSidebarCollapsed(true)}
|
||||||
onExpand={() => setIsChatsSidebarCollapsed(false)}
|
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
|
<SidebarList
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
currentSessionId={currentSessionId}
|
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}
|
onDeleteSession={deleteSession}
|
||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChat}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle />
|
<ResizableHandle className="hidden md:block" />
|
||||||
|
|
||||||
{/* Main Chat Area */}
|
{/* 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">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="flex items-center gap-2">
|
||||||
{isChatsSidebarCollapsed ? (
|
<Button
|
||||||
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
|
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" />
|
<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" />
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
</Button>
|
)}
|
||||||
)}
|
</Button>
|
||||||
<div className="font-semibold flex items-center gap-2">
|
<div className="font-semibold flex items-center gap-2 text-sm sm:text-base">
|
||||||
Chat
|
<span className="hidden sm:inline">Chat</span>
|
||||||
{isGeneratingTitle && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />}
|
{isGeneratingTitle && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />}
|
||||||
{isDownloading && (
|
{isDownloading && (
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 max-w-[420px] flex items-center gap-2">
|
<div className="flex-1 max-w-full sm:max-w-[420px] flex items-center gap-2 min-w-0">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<Select
|
<TooltipProvider>
|
||||||
value={selectedModel || undefined}
|
<Select
|
||||||
onValueChange={(v) => {
|
value={selectedModel || undefined}
|
||||||
if (v === "__add_model__") {
|
onValueChange={(v) => {
|
||||||
setShowDownloadDialog(true);
|
if (v === "__add_model__") {
|
||||||
// Não alterar selectedModel quando abrir dialog
|
setShowDownloadDialog(true);
|
||||||
} else {
|
// Não alterar selectedModel quando abrir dialog
|
||||||
setSelectedModel(v);
|
} else {
|
||||||
settings.setSelectedModel(v);
|
setSelectedModel(v);
|
||||||
}
|
settings.setSelectedModel(v);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="h-9">
|
>
|
||||||
<SelectValue placeholder="Selecione um modelo..." />
|
<Tooltip>
|
||||||
</SelectTrigger>
|
<TooltipTrigger asChild>
|
||||||
<SelectContent>
|
<SelectTrigger className="h-9 min-w-0 max-w-full">
|
||||||
{models.map(m => (
|
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
|
||||||
<SelectItem key={m.name} value={m.name}>
|
</SelectTrigger>
|
||||||
<span className="flex items-center justify-between w-full">
|
</TooltipTrigger>
|
||||||
<span className="truncate">{m.name}</span>
|
{selectedModel && (
|
||||||
<span className="ml-3 text-xs text-muted-foreground">{m.size}</span>
|
<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>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
</SelectContent>
|
||||||
<SelectItem value="__add_model__" className="text-primary font-medium">
|
</Select>
|
||||||
<span className="flex items-center gap-2">
|
</TooltipProvider>
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Adicionar modelo...
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
||||||
<Loader2 className="w-4 h-4" />
|
<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 */}
|
{/* Messages Area with Floating Input Layout */}
|
||||||
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
|
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
|
||||||
{/* Scrollable Content Area */}
|
{/* Scrollable Content Area */}
|
||||||
<div className="flex-1 overflow-y-auto scroll-smooth w-full">
|
<div className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0">
|
||||||
<div className="max-w-3xl mx-auto w-full px-4 md:px-0 pt-6 pb-4">
|
<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 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
|
<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">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4 sm:gap-6">
|
||||||
{messages.map((msg, i) => {
|
{messages.map((msg, i) => {
|
||||||
const isLastMessage = i === messages.length - (isLoading ? 1 : 0); // Ajuste simples
|
const isLastMessage = i === messages.length - (isLoading ? 1 : 0); // Ajuste simples
|
||||||
// Streaming logica mantida simples
|
// Streaming logica mantida simples
|
||||||
@@ -1062,11 +1122,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Elemento invisível para scroll automático */}
|
|
||||||
<div ref={messagesEndRef} className="h-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Debug Info (Moved inside scroll) */}
|
{/* Debug Info (Moved inside scroll) */}
|
||||||
{(deepResearch.state.logs.length > 0 || lastWebContext) && (
|
{(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">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Fixed Input Container */}
|
{/* 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 */}
|
{/* Gradient Overlay */}
|
||||||
<div className="absolute inset-0 -top-20 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none" />
|
<div className="absolute inset-0 -top-20 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
{/* Input Wrapper */}
|
{/* 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
|
<ChatInput
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
@@ -1163,9 +1219,38 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</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
|
<ModelDownloadDialog
|
||||||
open={showDownloadDialog}
|
open={showDownloadDialog}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
+145
@@ -152,3 +152,148 @@
|
|||||||
@apply bg-background text-foreground;
|
@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
@@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -304,21 +305,43 @@ export default function SettingsPage() {
|
|||||||
Carregando modelos...
|
Carregando modelos...
|
||||||
</div>
|
</div>
|
||||||
) : availableModels.length > 0 ? (
|
) : availableModels.length > 0 ? (
|
||||||
<Select
|
<TooltipProvider>
|
||||||
value={settings.selectedModel}
|
<Select
|
||||||
onValueChange={settings.setSelectedModel}
|
value={settings.selectedModel}
|
||||||
>
|
onValueChange={settings.setSelectedModel}
|
||||||
<SelectTrigger id="model-select">
|
>
|
||||||
<SelectValue placeholder="Selecione um modelo..." />
|
<Tooltip>
|
||||||
</SelectTrigger>
|
<TooltipTrigger asChild>
|
||||||
<SelectContent>
|
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
|
||||||
{availableModels.map((model) => (
|
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
|
||||||
<SelectItem key={model.name} value={model.name}>
|
</SelectTrigger>
|
||||||
{model.name}
|
</TooltipTrigger>
|
||||||
</SelectItem>
|
{settings.selectedModel && (
|
||||||
))}
|
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
||||||
</SelectContent>
|
<p className="text-sm">{settings.selectedModel}</p>
|
||||||
</Select>
|
</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
|
<Input
|
||||||
id="model-input"
|
id="model-input"
|
||||||
@@ -463,12 +486,12 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span>10s</span>
|
<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>
|
<span>60s</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[settings.webSearch.timeout]}
|
value={[settings.webSearch.timeout / 1000]}
|
||||||
onValueChange={([value]) => settings.setWebSearchTimeout(value)}
|
onValueChange={([value]) => settings.setWebSearchTimeout(value * 1000)}
|
||||||
min={10}
|
min={10}
|
||||||
max={60}
|
max={60}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -487,6 +510,142 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab: Sources & Knowledge */}
|
{/* Tab: Sources & Knowledge */}
|
||||||
|
|||||||
@@ -112,14 +112,14 @@ export function ChatInput({
|
|||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Pergunte qualquer coisa..."
|
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' : ''
|
input.trim() && !isValid ? 'text-destructive' : ''
|
||||||
}`}
|
}`}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right Actions */}
|
{/* 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 && (
|
{onWebSearchToggle && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
|
|||||||
@@ -123,3 +123,5 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3,10 +3,12 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { useState, useEffect, useRef } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
// Usar file dialog do Tauri v2
|
||||||
|
|
||||||
interface ModelDownloadDialogProps {
|
interface ModelDownloadDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -24,6 +26,7 @@ interface DownloadInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
|
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"download" | "local">("download");
|
||||||
const [modelName, setModelName] = useState("");
|
const [modelName, setModelName] = useState("");
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
@@ -33,6 +36,12 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
|||||||
const [isInstalled, setIsInstalled] = useState(false);
|
const [isInstalled, setIsInstalled] = useState(false);
|
||||||
const isDownloadingRef = useRef(false);
|
const isDownloadingRef = useRef(false);
|
||||||
const modelNameRef = useRef("");
|
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
|
// Atualizar refs quando estado muda
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,6 +52,7 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
|||||||
// Resetar estado quando dialog abre/fecha
|
// Resetar estado quando dialog abre/fecha
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
setActiveTab("download");
|
||||||
setModelName("");
|
setModelName("");
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
@@ -52,6 +62,11 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
|||||||
setIsInstalled(false);
|
setIsInstalled(false);
|
||||||
isDownloadingRef.current = false;
|
isDownloadingRef.current = false;
|
||||||
modelNameRef.current = "";
|
modelNameRef.current = "";
|
||||||
|
// Reset estados locais
|
||||||
|
setIsInstallingLocal(false);
|
||||||
|
setLocalInstallProgress(0);
|
||||||
|
setLocalInstallError(null);
|
||||||
|
setLocalModelName("");
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [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());
|
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -251,114 +329,198 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
|||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Adicionar Modelo</DialogTitle>
|
<DialogTitle>Adicionar Modelo</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "download" | "local")} className="w-full">
|
||||||
<div className="space-y-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<Label htmlFor="model-name">Nome do Modelo</Label>
|
<TabsTrigger value="download" className="flex items-center gap-2">
|
||||||
<Input
|
<Globe className="w-4 h-4" />
|
||||||
id="model-name"
|
Baixar do Ollama
|
||||||
value={modelName}
|
</TabsTrigger>
|
||||||
onChange={(e) => setModelName(e.target.value)}
|
<TabsTrigger value="local" className="flex items-center gap-2">
|
||||||
placeholder="ex: llama3.2:1b"
|
<Upload className="w-4 h-4" />
|
||||||
disabled={isDownloading}
|
Instalar Local
|
||||||
onKeyDown={(e) => {
|
</TabsTrigger>
|
||||||
if (e.key === 'Enter' && isValid && !isDownloading) {
|
</TabsList>
|
||||||
handleDownload();
|
|
||||||
}
|
<TabsContent value="download" className="space-y-4 mt-4">
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{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 && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<Label htmlFor="model-name">Nome do Modelo</Label>
|
||||||
<span>{downloadStatus || "Iniciando..."}</span>
|
<Input
|
||||||
<span>{downloadProgress}%</span>
|
id="model-name"
|
||||||
</div>
|
value={modelName}
|
||||||
<Progress value={downloadProgress} className="h-2" />
|
onChange={(e) => setModelName(e.target.value)}
|
||||||
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
|
placeholder="ex: llama3.2:1b"
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
disabled={isDownloading}
|
||||||
<span>
|
onKeyDown={(e) => {
|
||||||
{downloadInfo.downloaded && downloadInfo.total
|
if (e.key === 'Enter' && isValid && !isDownloading) {
|
||||||
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
|
handleDownload();
|
||||||
: downloadInfo.downloaded || ""}
|
}
|
||||||
</span>
|
}}
|
||||||
{downloadInfo.speed && (
|
/>
|
||||||
<span>{downloadInfo.speed}</span>
|
{error && (
|
||||||
)}
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
</div>
|
)}
|
||||||
|
{isInstalled && !isDownloading && (
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
✓ Este modelo já está instalado
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{isInstalled && !isDownloading && (
|
{isDownloading && (
|
||||||
<div className="bg-muted/50 p-3 rounded-md text-sm">
|
<div className="space-y-2">
|
||||||
<p className="text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
O modelo está instalado e pronto para uso.
|
<span>{downloadStatus || "Iniciando..."}</span>
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</Tabs>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
|
disabled={isInstallingLocal}
|
||||||
>
|
>
|
||||||
{isDownloading ? (
|
<X className="mr-2 h-4 w-4" />
|
||||||
<>
|
Cancelar
|
||||||
<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
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Sparkles, Loader2 } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { usePromptGenerator } from "@/hooks/use-prompt-generator";
|
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">
|
<label htmlFor="model-select" className="text-sm font-medium">
|
||||||
Modelo para Geração
|
Modelo para Geração
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<TooltipProvider>
|
||||||
value={selectedModel || undefined}
|
<Select
|
||||||
onValueChange={setSelectedModel}
|
value={selectedModel || undefined}
|
||||||
disabled={loading || models.length === 0}
|
onValueChange={setSelectedModel}
|
||||||
>
|
disabled={loading || models.length === 0}
|
||||||
<SelectTrigger id="model-select">
|
>
|
||||||
<SelectValue placeholder={
|
<Tooltip>
|
||||||
loading
|
<TooltipTrigger asChild>
|
||||||
? "Carregando modelos..."
|
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
|
||||||
: models.length === 0
|
<SelectValue placeholder={
|
||||||
? "Nenhum modelo disponível"
|
loading
|
||||||
: "Selecione um modelo..."
|
? "Carregando modelos..."
|
||||||
} />
|
: models.length === 0
|
||||||
</SelectTrigger>
|
? "Nenhum modelo disponível"
|
||||||
<SelectContent>
|
: "Selecione um modelo..."
|
||||||
{models.length === 0 ? (
|
} className="truncate" />
|
||||||
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
</SelectTrigger>
|
||||||
) : (
|
</TooltipTrigger>
|
||||||
models.map(m => (
|
{selectedModel && (
|
||||||
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
|
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
||||||
))
|
<p className="text-sm">{selectedModel}</p>
|
||||||
)}
|
</TooltipContent>
|
||||||
</SelectContent>
|
)}
|
||||||
</Select>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
|
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SessionSummary } from "@/hooks/use-chat-storage";
|
import { SessionSummary } from "@/hooks/use-chat-storage";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SidebarListProps {
|
interface SidebarListProps {
|
||||||
@@ -13,11 +14,36 @@ interface SidebarListProps {
|
|||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
const date = new Date(dateString);
|
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', {
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
hour: '2-digit',
|
year: diffInDays > 365 ? 'numeric' : undefined
|
||||||
minute: '2-digit'
|
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,61 +55,122 @@ export function SidebarList({
|
|||||||
onNewChat
|
onNewChat
|
||||||
}: SidebarListProps) {
|
}: SidebarListProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full border-r bg-muted/10">
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="p-4 border-b">
|
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar min-w-[240px] max-w-[360px] w-full">
|
||||||
<Button onClick={onNewChat} className="w-full justify-start gap-2" variant="outline">
|
{/* Header com botão Nova Conversa */}
|
||||||
<Plus className="w-4 h-4" />
|
<div className="p-3 sm:p-4 border-b border-sidebar-border flex-shrink-0">
|
||||||
Nova Conversa
|
<Button
|
||||||
</Button>
|
onClick={onNewChat}
|
||||||
</div>
|
className="w-full justify-start gap-2 text-sm font-medium h-9 sm:h-10"
|
||||||
|
variant="outline"
|
||||||
<div className="flex-1 overflow-y-auto">
|
>
|
||||||
<div className="p-2 space-y-2">
|
<Plus className="w-4 h-4 shrink-0" />
|
||||||
{sessions.length === 0 && (
|
<span className="truncate">Nova Conversa</span>
|
||||||
<div className="text-center text-muted-foreground text-sm py-8">
|
</Button>
|
||||||
Nenhuma conversa salva
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
{/* Lista de chats com scroll customizado */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
|
||||||
{sessions.map((session) => (
|
<div className="p-3 sm:p-4 space-y-2 sm:space-y-2.5">
|
||||||
<div
|
{sessions.length === 0 && (
|
||||||
key={session.id}
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||||
className={cn(
|
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
|
||||||
"group flex items-center justify-between p-2 rounded-lg cursor-pointer hover:bg-accent transition-colors",
|
<p className="text-sm text-muted-foreground">
|
||||||
currentSessionId === session.id && "bg-accent"
|
Nenhuma conversa salva
|
||||||
)}
|
</p>
|
||||||
onClick={() => onSelectSession(session.id)}
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
>
|
Comece uma nova conversa para começar
|
||||||
<div className="flex items-start gap-3 overflow-hidden flex-1">
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const isSelected = currentSessionId === session.id;
|
||||||
|
const showTooltip = session.title.length > 30;
|
||||||
|
|
||||||
<Button
|
return (
|
||||||
variant="ghost"
|
<Tooltip key={session.id} delayDuration={300} disableHoverableContent>
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
<div
|
||||||
onClick={(e) => {
|
className={cn(
|
||||||
e.stopPropagation();
|
"group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
|
||||||
onDeleteSession(session.id);
|
"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",
|
||||||
<Trash2 className="w-3 h-3 text-destructive" />
|
"active:scale-[0.98]"
|
||||||
</Button>
|
)}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useSystemMonitor } from "@/hooks/use-system-monitor";
|
||||||
import { useLocalModels } from "@/hooks/use-local-models";
|
import { useLocalModels } from "@/hooks/use-local-models";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ModelDownloadDialog } from "./model-download-dialog";
|
||||||
|
|
||||||
export function SystemPanel() {
|
export function SystemPanel() {
|
||||||
const stats = useSystemMonitor();
|
const stats = useSystemMonitor();
|
||||||
const { models, deleteModel, refresh } = useLocalModels();
|
const { models, deleteModel, refresh } = useLocalModels();
|
||||||
|
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
||||||
|
|
||||||
const ramUsagePercent = stats
|
const ramUsagePercent = stats
|
||||||
? (stats.memory_used / stats.memory_total) * 100
|
? (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">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Modelos Instalados
|
Modelos Instalados
|
||||||
</h3>
|
</h3>
|
||||||
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
|
<div className="flex items-center gap-2">
|
||||||
Atualizar
|
<Button
|
||||||
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{models.map((model) => (
|
{models.map((model) => {
|
||||||
<Card key={model.id} className="overflow-hidden">
|
// Tentar detectar se é modelo local/GGUF (heurística: modelos GGUF geralmente têm nomes específicos)
|
||||||
<CardContent className="p-3 flex items-center justify-between">
|
// Por enquanto, vamos mostrar todos como disponíveis
|
||||||
<div className="min-w-0">
|
const isLocal = model.name.toLowerCase().includes('gguf') ||
|
||||||
<div className="font-medium text-sm truncate" title={model.name}>
|
model.name.toLowerCase().endsWith('.gguf');
|
||||||
{model.name}
|
|
||||||
|
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>
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
<TooltipProvider>
|
||||||
<Database className="w-3 h-3" />
|
<Tooltip>
|
||||||
{model.size}
|
<TooltipTrigger asChild>
|
||||||
</div>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
<Button
|
size="icon"
|
||||||
variant="ghost"
|
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 flex-shrink-0"
|
||||||
size="icon"
|
onClick={async () => {
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
if (confirm(`Tem certeza que deseja remover o modelo "${model.name}"?`)) {
|
||||||
onClick={() => deleteModel(model.name)}
|
try {
|
||||||
>
|
await deleteModel(model.name);
|
||||||
<Trash2 className="w-4 h-4" />
|
} catch (error) {
|
||||||
</Button>
|
console.error('Erro ao remover modelo:', error);
|
||||||
</CardContent>
|
}
|
||||||
</Card>
|
}
|
||||||
))}
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Remover modelo</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{models.length === 0 && (
|
{models.length === 0 && (
|
||||||
<div className="text-center text-sm text-muted-foreground py-4">
|
<div className="text-center text-sm text-muted-foreground py-8 space-y-2">
|
||||||
Nenhum modelo encontrado.
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ModelDownloadDialog
|
||||||
|
open={showDownloadDialog}
|
||||||
|
onOpenChange={setShowDownloadDialog}
|
||||||
|
onSuccess={(modelName) => {
|
||||||
|
refresh();
|
||||||
|
setShowDownloadDialog(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,3 +65,5 @@ export function WelcomeNotification() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import { Progress } from '@/components/ui/progress';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Cpu, MemoryStick, HardDrive, Activity, Clock, Monitor } from 'lucide-react';
|
import { Cpu, MemoryStick, HardDrive, Activity, Clock, Monitor } from 'lucide-react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { GpuDashboard } from './GpuDashboard';
|
||||||
|
|
||||||
interface SystemStats {
|
interface SystemStats {
|
||||||
cpu_usage: number;
|
cpu_usage: number;
|
||||||
@@ -85,7 +86,8 @@ export function HardwareDashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* CPU Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -135,25 +137,6 @@ export function HardwareDashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* System Info Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -207,6 +190,10 @@ export function HardwareDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPU Dashboard - Substitui o card simples */}
|
||||||
|
<GpuDashboard pollInterval={2000} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
export const DEEP_RESEARCH_PROMPTS = {
|
export const DEEP_RESEARCH_PROMPTS = {
|
||||||
DECOMPOSITION: `Role: Research Lead.
|
DECOMPOSITION: `Role: Research Lead specializing in web search optimization.
|
||||||
Task: Decompose the user's query into 3-5 atomic, search-friendly questions to cover different angles.
|
Task: Decompose the user's query into 3-5 atomic, search-friendly questions optimized for web search engines.
|
||||||
Output: JSON array of strings ONLY. No markdown, no explanations.
|
|
||||||
|
|
||||||
User Query: "{{userQuery}}"
|
User Query: "{{userQuery}}"
|
||||||
|
|
||||||
Constraints:
|
## CRITICAL INSTRUCTIONS
|
||||||
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.
|
|
||||||
|
|
||||||
Example Output for "Onde fica Garuva?":
|
1. **PRESERVE THE SUBJECT**: If the user asks about "Garuva", ALL queries must mention "Garuva".
|
||||||
["localização garuva sc", "mapa garuva santa catarina", "história cidade 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.
|
CONTEXT_CHECK: `Role: Fact Checker.
|
||||||
Task: Analyze the Knowledge Base below and determine if it contains sufficient information to answer the user's query.
|
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 inline citations: "Garuva está localizada em Santa Catarina [ID: 1]."
|
||||||
- For multiple sources: "A cidade possui praias e montanhas [ID: 1, 3]."
|
- For multiple sources: "A cidade possui praias e montanhas [ID: 1, 3]."
|
||||||
- At the end, list all sources used with their URLs.
|
- 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.`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ import type { ScrapedContent } from '@/services/webSearch';
|
|||||||
import { deepResearchLog as log } from '@/lib/terminal-logger';
|
import { deepResearchLog as log } from '@/lib/terminal-logger';
|
||||||
import { condenseKnowledgeBase, validateCondensedContext } from '@/lib/knowledge-base-processor';
|
import { condenseKnowledgeBase, validateCondensedContext } from '@/lib/knowledge-base-processor';
|
||||||
import type { CondensationResult } 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';
|
export type DeepResearchStep = 'idle' | 'planning' | 'searching' | 'aggregating' | 'validating' | 'formulating' | 'complete' | 'error';
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ export interface DeepResearchState {
|
|||||||
validationReport: string;
|
validationReport: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
logs: DeepResearchLog[];
|
logs: DeepResearchLog[];
|
||||||
|
context?: QueryContext;
|
||||||
|
enrichedQueries?: EnrichedQueries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== LOGGING UTILITIES ==========
|
// ========== 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 ---
|
// --- DECOMPOSITION ---
|
||||||
const decompose = useCallback(async (query: string, model: string) => {
|
const decompose = useCallback(async (query: string, model: string) => {
|
||||||
logSection('DECOMPOSITION');
|
logSection('DECOMPOSITION');
|
||||||
@@ -186,6 +233,12 @@ export function useDeepResearch() {
|
|||||||
setState(s => ({ ...s, step: 'planning', error: undefined, logs: [], knowledgeBase: [] }));
|
setState(s => ({ ...s, step: 'planning', error: undefined, logs: [], knowledgeBase: [] }));
|
||||||
|
|
||||||
try {
|
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);
|
const prompt = DEEP_RESEARCH_PROMPTS.DECOMPOSITION.replace('{{userQuery}}', query);
|
||||||
logStep('Sending decomposition prompt to LLM...');
|
logStep('Sending decomposition prompt to LLM...');
|
||||||
const response = await callLLM(model, prompt, true);
|
const response = await callLLM(model, prompt, true);
|
||||||
|
|||||||
@@ -54,3 +54,5 @@ export function useOperatingSystem() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+14
-2
@@ -147,7 +147,13 @@ export function useWebSearch() {
|
|||||||
|
|
||||||
// Etapa 1: metadados
|
// Etapa 1: metadados
|
||||||
setState(prev => ({ ...prev, status: 'searching' }));
|
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
|
// Etapa 2: scraping
|
||||||
setState(prev => ({ ...prev, status: 'scraping' }));
|
setState(prev => ({ ...prev, status: 'scraping' }));
|
||||||
@@ -162,16 +168,22 @@ export function useWebSearch() {
|
|||||||
error: null,
|
error: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Retornar resultados mesmo se parciais (não quebrar o fluxo)
|
||||||
return contents;
|
return contents;
|
||||||
} catch (error) {
|
} 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';
|
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
|
||||||
|
console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage);
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
scrapedSources: [],
|
scrapedSources: [],
|
||||||
}));
|
}));
|
||||||
throw error;
|
|
||||||
|
// Retornar vazio em vez de throw para não quebrar o fluxo
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ResearchEntry } from '@/hooks/use-deep-research';
|
import type { ResearchEntry } from '@/hooks/use-deep-research';
|
||||||
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
||||||
import { scoreAndSelectChunks } from './relevance-scorer';
|
import { scoreAndSelectChunks } from './relevance-scorer';
|
||||||
import { summarizeMarkdown } from './content-condenser';
|
import { summarizeMarkdown } from './content-condenser';
|
||||||
@@ -20,6 +21,7 @@ export interface CondensationOptions {
|
|||||||
autoSummarize?: boolean;
|
autoSummarize?: boolean;
|
||||||
summarizeThreshold?: number;
|
summarizeThreshold?: number;
|
||||||
fallbackToSummarization?: boolean;
|
fallbackToSummarization?: boolean;
|
||||||
|
context?: QueryContext; // Contexto analisado para ranqueamento semântico
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
|
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
|
||||||
@@ -129,12 +131,13 @@ export function condenseKnowledgeBase(
|
|||||||
// Calcular tokens disponíveis (reservar 20% para margem)
|
// Calcular tokens disponíveis (reservar 20% para margem)
|
||||||
const availableTokens = Math.floor(opts.maxTokens * 0.8);
|
const availableTokens = Math.floor(opts.maxTokens * 0.8);
|
||||||
|
|
||||||
// Selecionar chunks mais relevantes
|
// Selecionar chunks mais relevantes (usando contexto se disponível)
|
||||||
let selectedChunks = scoreAndSelectChunks(
|
let selectedChunks = scoreAndSelectChunks(
|
||||||
allChunks,
|
allChunks,
|
||||||
query,
|
query,
|
||||||
availableTokens,
|
availableTokens,
|
||||||
opts.minRelevanceScore
|
opts.minRelevanceScore,
|
||||||
|
opts.context
|
||||||
);
|
);
|
||||||
|
|
||||||
// Se não selecionou chunks suficientes ou método de fallback está ativo
|
// Se não selecionou chunks suficientes ou método de fallback está ativo
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import type { Chunk } from './content-condenser';
|
import type { Chunk } from './content-condenser';
|
||||||
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
import { normalizeText } from './text-normalization';
|
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);
|
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
|
* Calcula scores de relevância para todos os chunks
|
||||||
*
|
*
|
||||||
* @param chunks - Array de chunks
|
* @param chunks - Array de chunks
|
||||||
* @param query - Query do usuário
|
* @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
|
* @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) {
|
if (!query || chunks.length === 0) {
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
@@ -87,7 +236,9 @@ export function scoreChunks(chunks: Chunk[], query: string): Chunk[] {
|
|||||||
// Calcular score para cada chunk
|
// Calcular score para cada chunk
|
||||||
const scoredChunks = chunks.map(chunk => ({
|
const scoredChunks = chunks.map(chunk => ({
|
||||||
...chunk,
|
...chunk,
|
||||||
score: calculateSimilarity(query, chunk.content),
|
score: context
|
||||||
|
? calculateCombinedSimilarity(query, chunk.content, context)
|
||||||
|
: calculateSimilarity(query, chunk.content),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Ordenar por score (maior primeiro)
|
// 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 query - Query do usuário
|
||||||
* @param maxTokens - Limite máximo de tokens
|
* @param maxTokens - Limite máximo de tokens
|
||||||
* @param minScore - Score mínimo (opcional)
|
* @param minScore - Score mínimo (opcional)
|
||||||
|
* @param context - Contexto analisado (opcional, para ranqueamento semântico)
|
||||||
* @returns Chunks selecionados e ordenados por relevância
|
* @returns Chunks selecionados e ordenados por relevância
|
||||||
*/
|
*/
|
||||||
export function scoreAndSelectChunks(
|
export function scoreAndSelectChunks(
|
||||||
chunks: Chunk[],
|
chunks: Chunk[],
|
||||||
query: string,
|
query: string,
|
||||||
maxTokens: number,
|
maxTokens: number,
|
||||||
minScore?: number
|
minScore?: number,
|
||||||
|
context?: QueryContext
|
||||||
): Chunk[] {
|
): Chunk[] {
|
||||||
// Calcular scores
|
// Calcular scores
|
||||||
let scoredChunks = scoreChunks(chunks, query);
|
let scoredChunks = scoreChunks(chunks, query, context);
|
||||||
|
|
||||||
// Filtrar por score mínimo se especificado
|
// Filtrar por score mínimo se especificado
|
||||||
if (minScore !== undefined) {
|
if (minScore !== undefined) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -36,3 +36,5 @@ export const deepResearchLog = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+203
-67
@@ -3,25 +3,43 @@
|
|||||||
*
|
*
|
||||||
* Implementa múltiplas tentativas de busca com query expansion,
|
* Implementa múltiplas tentativas de busca com query expansion,
|
||||||
* validação de relevância e fallback para conhecimento interno.
|
* 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 { ScrapedContent } from '@/services/webSearch';
|
||||||
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
|
import type { EnrichedQueries } from './query-enricher';
|
||||||
import { chatLog } from '@/lib/terminal-logger';
|
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 {
|
export interface FallbackConfig {
|
||||||
maxRounds: number; // Máximo de rodadas de busca (padrão: 4)
|
maxRounds: number; // Máximo de rodadas de busca (padrão: 4)
|
||||||
maxResultsPerRound: number; // Máximo de resultados por rodada (padrão: 10)
|
maxResultsPerRound: number; // Máximo de resultados por rodada (padrão: 10)
|
||||||
maxTotalResults: number; // Máximo total de resultados analisados (padrão: 40)
|
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)
|
minRelevanceScore: number; // Score mínimo de relevância (0-1, padrão: 0.3)
|
||||||
enableQueryExpansion: boolean; // Ativar expansão de queries (padrão: true)
|
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 {
|
export interface FallbackAttempt {
|
||||||
round: number;
|
round: number;
|
||||||
|
strategy?: SearchStrategy;
|
||||||
query: string;
|
query: string;
|
||||||
|
queries?: string[]; // Múltiplas queries se usar estratégia
|
||||||
results: ScrapedContent[];
|
results: ScrapedContent[];
|
||||||
relevanceScore: number;
|
relevanceScore: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FallbackResult {
|
export interface FallbackResult {
|
||||||
@@ -108,8 +126,9 @@ Query expandida:`;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcula score de relevância de um resultado baseado na query
|
* 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,
|
result: ScrapedContent,
|
||||||
query: string
|
query: string
|
||||||
): number {
|
): number {
|
||||||
@@ -167,7 +186,7 @@ export function areResultsRelevant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calcular scores de relevância
|
// 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
|
// Verificar se pelo menos um resultado tem score suficiente
|
||||||
const maxScore = Math.max(...scores);
|
const maxScore = Math.max(...scores);
|
||||||
@@ -181,8 +200,66 @@ export function areResultsRelevant(
|
|||||||
return maxScore >= minScore || avgScore >= (minScore * 0.7);
|
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
|
* Executa busca progressiva com fallback
|
||||||
|
* Agora suporta multi-round semantic search com estratégias diferentes
|
||||||
*/
|
*/
|
||||||
export async function executeProgressiveSearch(
|
export async function executeProgressiveSearch(
|
||||||
originalQuery: string,
|
originalQuery: string,
|
||||||
@@ -198,21 +275,63 @@ export async function executeProgressiveSearch(
|
|||||||
|
|
||||||
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
||||||
chatLog.info(`Original Query: "${originalQuery}"`);
|
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++) {
|
// Se usar semantic search, criar rounds baseados em estratégias
|
||||||
chatLog.info(`\n--- Round ${round}/${finalConfig.maxRounds} ---`);
|
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 roundStartTime = Date.now();
|
||||||
const query = finalConfig.enableQueryExpansion
|
let roundResults: ScrapedContent[] = [];
|
||||||
? await expandQuery(originalQuery, model, round)
|
let roundQueries: string[] = [];
|
||||||
: originalQuery;
|
|
||||||
|
|
||||||
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 {
|
const queryResults = await Promise.all(queryPromises);
|
||||||
// Executar busca
|
|
||||||
const results = await searchFn(query, finalConfig.maxResultsPerRound);
|
// Agregar resultados de todas as queries do round
|
||||||
|
for (const { query, results } of queryResults) {
|
||||||
|
roundQueries.push(query);
|
||||||
|
|
||||||
// Filtrar duplicatas
|
// Filtrar duplicatas
|
||||||
const uniqueResults = results.filter(r => {
|
const uniqueResults = results.filter(r => {
|
||||||
@@ -223,61 +342,78 @@ export async function executeProgressiveSearch(
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
chatLog.info(`Found ${uniqueResults.length} unique results (${results.length} total)`);
|
roundResults.push(...uniqueResults);
|
||||||
|
|
||||||
// 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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Se chegou aqui, não encontrou resultados relevantes
|
||||||
|
|||||||
+120
-32
@@ -1,5 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
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 {
|
export interface ScrapedContent {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -224,35 +225,101 @@ class WebSearchService {
|
|||||||
private async executeMetadataSearch(
|
private async executeMetadataSearch(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 5,
|
limit: number = 5,
|
||||||
searchConfig?: SearchConfig
|
searchConfig?: SearchConfig,
|
||||||
|
timeoutMs: number = 15000
|
||||||
): Promise<SearchResultMetadata[]> {
|
): Promise<SearchResultMetadata[]> {
|
||||||
try {
|
// Obter configurações de motores do store
|
||||||
let rustConfig: any = undefined;
|
const settings = useSettingsStore.getState();
|
||||||
if (searchConfig) {
|
const engineOrder = settings.webSearch.engineOrder || ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
|
||||||
rustConfig = {
|
|
||||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
let rustConfig: any = undefined;
|
||||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
if (searchConfig) {
|
||||||
categories: searchConfig.categories.map(cat => ({
|
rustConfig = {
|
||||||
id: cat.id,
|
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||||
name: cat.name,
|
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||||
base_sites: cat.baseSites,
|
categories: searchConfig.categories.map(cat => ({
|
||||||
enabled: cat.enabled,
|
id: cat.id,
|
||||||
})),
|
name: cat.name,
|
||||||
user_custom_sites: searchConfig.userCustomSites,
|
base_sites: cat.baseSites,
|
||||||
excluded_domains: searchConfig.excludedDomains,
|
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
async smartSearchRag(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 3,
|
limit: number = 3,
|
||||||
searchConfig?: SearchConfig
|
searchConfig?: SearchConfig,
|
||||||
|
timeoutMs: number = 15000
|
||||||
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
||||||
if (!query || !query.trim()) {
|
if (!query || !query.trim()) {
|
||||||
return { metadata: [], contents: [] };
|
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);
|
const topUrls = metas.map(m => m.url).slice(0, limit);
|
||||||
|
|
||||||
if (topUrls.length === 0) {
|
if (topUrls.length === 0) {
|
||||||
|
// Retornar resultados parciais (metadados vazios mas sem erro)
|
||||||
return { metadata: metas, contents: [] };
|
return { metadata: metas, contents: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tentar fazer scraping mesmo se metadados foram parciais
|
||||||
try {
|
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 || [] };
|
return { metadata: metas, contents: contents || [] };
|
||||||
} catch (error) {
|
} 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: [] };
|
return { metadata: metas, contents: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+249
-12
@@ -114,6 +114,7 @@ dependencies = [
|
|||||||
"readability",
|
"readability",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.24",
|
"reqwest 0.12.24",
|
||||||
|
"rfd",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -139,6 +140,24 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -177,6 +196,17 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "async-io"
|
name = "async-io"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -206,6 +236,17 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "async-process"
|
name = "async-process"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -375,6 +416,12 @@ dependencies = [
|
|||||||
"wyz",
|
"wyz",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -2579,6 +2626,15 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "malloc_buf"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -2758,6 +2814,19 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2798,7 +2867,7 @@ dependencies = [
|
|||||||
"mac-notification-sys",
|
"mac-notification-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"tauri-winrt-notification",
|
"tauri-winrt-notification",
|
||||||
"zbus",
|
"zbus 5.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2867,6 +2936,26 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -3141,6 +3230,15 @@ dependencies = [
|
|||||||
"objc2-security",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -3531,6 +3629,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3990,6 +4094,29 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -6728,6 +6855,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"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]]
|
[[package]]
|
||||||
name = "xml5ever"
|
name = "xml5ever"
|
||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
@@ -6762,6 +6899,44 @@ dependencies = [
|
|||||||
"synstructure",
|
"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]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "5.12.0"
|
version = "5.12.0"
|
||||||
@@ -6782,7 +6957,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hex",
|
"hex",
|
||||||
"nix",
|
"nix 0.30.1",
|
||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -6791,9 +6966,22 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winnow 0.7.13",
|
"winnow 0.7.13",
|
||||||
"zbus_macros",
|
"zbus_macros 5.12.0",
|
||||||
"zbus_names",
|
"zbus_names 4.2.0",
|
||||||
"zvariant",
|
"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]]
|
[[package]]
|
||||||
@@ -6806,9 +6994,20 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
"zbus_names",
|
"zbus_names 4.2.0",
|
||||||
"zvariant",
|
"zvariant 5.8.0",
|
||||||
"zvariant_utils",
|
"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]]
|
[[package]]
|
||||||
@@ -6820,7 +7019,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"winnow 0.7.13",
|
"winnow 0.7.13",
|
||||||
"zvariant",
|
"zvariant 5.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6952,6 +7151,20 @@ dependencies = [
|
|||||||
"pkg-config",
|
"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]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.8.0"
|
version = "5.8.0"
|
||||||
@@ -6962,8 +7175,21 @@ dependencies = [
|
|||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"winnow 0.7.13",
|
"winnow 0.7.13",
|
||||||
"zvariant_derive",
|
"zvariant_derive 5.8.0",
|
||||||
"zvariant_utils",
|
"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]]
|
[[package]]
|
||||||
@@ -6976,7 +7202,18 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.110",
|
"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]]
|
[[package]]
|
||||||
|
|||||||
@@ -45,3 +45,4 @@ regex = "1.10"
|
|||||||
walkdir = "2.5"
|
walkdir = "2.5"
|
||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
|
rfd = "0.14"
|
||||||
+270
-16
@@ -30,13 +30,15 @@ use web_scraper::{
|
|||||||
scrape_url,
|
scrape_url,
|
||||||
SearchConfig,
|
SearchConfig,
|
||||||
search_duckduckgo_metadata,
|
search_duckduckgo_metadata,
|
||||||
|
search_multi_engine_metadata,
|
||||||
|
SearchEngine,
|
||||||
smart_search,
|
smart_search,
|
||||||
scrape_urls_bulk,
|
scrape_urls_bulk,
|
||||||
};
|
};
|
||||||
use headless_chrome::Browser;
|
use headless_chrome::Browser;
|
||||||
use scheduler::{SentinelTask, SchedulerService, SchedulerState, TaskAction};
|
use scheduler::{SentinelTask, SchedulerService, SchedulerState, TaskAction};
|
||||||
use sources_config::{SourcesConfig, load_sources_config, save_sources_config};
|
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
|
// 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)
|
// Função auxiliar para ler linha até encontrar \r ou \n (mantida para fallback)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn read_line_until_delimiter<R: Read>(reader: &mut BufReader<R>, buffer: &mut Vec<u8>) -> Result<usize, std::io::Error> {
|
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,
|
query: String,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
search_config: Option<SearchConfig>,
|
search_config: Option<SearchConfig>,
|
||||||
|
engine_order: Option<Vec<String>>,
|
||||||
) -> Result<Vec<SearchResultMetadata>, String> {
|
) -> Result<Vec<SearchResultMetadata>, String> {
|
||||||
if query.trim().is_empty() {
|
if query.trim().is_empty() {
|
||||||
return Err("Query não pode estar vazia".to_string());
|
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);
|
let lim = limit.unwrap_or(5);
|
||||||
|
|
||||||
if let Some(config) = search_config {
|
// Converter engine_order (strings) para Vec<SearchEngine>
|
||||||
// Usar smart_search para obter URLs e convertê-las em metadados simples
|
let engines: Vec<SearchEngine> = if let Some(order) = engine_order {
|
||||||
match smart_search(&query, &config).await {
|
order.iter()
|
||||||
Ok(mut urls) => {
|
.filter_map(|s| SearchEngine::from_str(s))
|
||||||
urls.truncate(lim);
|
.collect()
|
||||||
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 {
|
} 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
|
.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()))
|
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 ==========
|
// ========== Sources Config Commands ==========
|
||||||
|
|
||||||
/// Carrega a configuração de fontes de busca
|
/// Carrega a configuração de fontes de busca
|
||||||
@@ -2264,6 +2508,12 @@ fn get_system_stats(
|
|||||||
Ok(monitor.get_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 ==========
|
// ========== Task Scheduler Commands ==========
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -2434,8 +2684,12 @@ pub fn run() {
|
|||||||
get_operating_system,
|
get_operating_system,
|
||||||
check_if_model_installed,
|
check_if_model_installed,
|
||||||
pull_model,
|
pull_model,
|
||||||
|
install_gguf_model,
|
||||||
|
save_temp_file,
|
||||||
|
open_gguf_file_dialog,
|
||||||
start_ollama_server,
|
start_ollama_server,
|
||||||
start_system_monitor,
|
start_system_monitor,
|
||||||
|
get_gpu_stats,
|
||||||
list_local_models,
|
list_local_models,
|
||||||
delete_model,
|
delete_model,
|
||||||
save_chat_session,
|
save_chat_session,
|
||||||
|
|||||||
@@ -10,6 +10,36 @@ pub struct GpuInfo {
|
|||||||
pub memory_mb: Option<u64>,
|
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
|
/// Estatísticas do sistema em tempo real
|
||||||
#[derive(Serialize, Clone, Debug)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
pub struct SystemStats {
|
pub struct SystemStats {
|
||||||
@@ -615,3 +645,158 @@ fn parse_memory_string(s: &str) -> Option<u64> {
|
|||||||
None
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ use url::Url;
|
|||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Resultado da extração de conteúdo de uma URL
|
/// Resultado da extração de conteúdo de uma URL
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||||
@@ -57,6 +58,178 @@ fn default_total_sources() -> usize {
|
|||||||
100
|
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)
|
/// Pool de User-Agents para rotação (evita bloqueios 429)
|
||||||
const USER_AGENTS: &[&str] = &[
|
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",
|
"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)
|
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)
|
/// Busca no DuckDuckGo retornando apenas metadados (título, URL, snippet)
|
||||||
pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -237,6 +793,216 @@ pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec
|
|||||||
Ok(results)
|
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
|
/// Extrai a URL real do redirecionamento do DuckDuckGo
|
||||||
fn extract_real_url(ddg_redirect: &str) -> Option<String> {
|
fn extract_real_url(ddg_redirect: &str) -> Option<String> {
|
||||||
// DuckDuckGo usa formato: /l/?kh=-1&uddg=<URL_ENCODED>
|
// DuckDuckGo usa formato: /l/?kh=-1&uddg=<URL_ENCODED>
|
||||||
|
|||||||
+29
-1
@@ -27,6 +27,10 @@ export interface SettingsState {
|
|||||||
totalSourcesLimit: number;
|
totalSourcesLimit: number;
|
||||||
categories: SearchCategory[];
|
categories: SearchCategory[];
|
||||||
userCustomSites: string[];
|
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
|
// Content Processing
|
||||||
@@ -72,6 +76,10 @@ export interface SettingsState {
|
|||||||
setWebSearchTimeout: (timeout: number) => void;
|
setWebSearchTimeout: (timeout: number) => void;
|
||||||
setWebSearchMaxConcurrentTabs: (max: number) => void;
|
setWebSearchMaxConcurrentTabs: (max: number) => void;
|
||||||
setWebSearchTotalSourcesLimit: (limit: 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;
|
addExcludedDomain: (domain: string) => void;
|
||||||
removeExcludedDomain: (domain: string) => void;
|
removeExcludedDomain: (domain: string) => void;
|
||||||
toggleCategory: (categoryId: string) => void;
|
toggleCategory: (categoryId: string) => void;
|
||||||
@@ -196,12 +204,16 @@ const initialState = {
|
|||||||
webSearch: {
|
webSearch: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxResults: 10,
|
maxResults: 10,
|
||||||
timeout: 10,
|
timeout: 15000, // Timeout em milissegundos (15 segundos)
|
||||||
excludedDomains: ['youtube.com', 'linkedin.com'],
|
excludedDomains: ['youtube.com', 'linkedin.com'],
|
||||||
maxConcurrentTabs: 5,
|
maxConcurrentTabs: 5,
|
||||||
totalSourcesLimit: 100,
|
totalSourcesLimit: 100,
|
||||||
categories: defaultCategories,
|
categories: defaultCategories,
|
||||||
userCustomSites: [],
|
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,
|
sourcesConfig: null,
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
@@ -266,6 +278,22 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
webSearch: { ...state.webSearch, totalSourcesLimit: limit },
|
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) =>
|
addExcludedDomain: (domain) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const normalized = domain.toLowerCase().trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
const normalized = domain.toLowerCase().trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||||
|
|||||||
Reference in new issue
Block a user