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 { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check } from "lucide-react";
|
||||
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
import { ChatMessage } from "@/components/chat/chat-message";
|
||||
|
||||
@@ -120,7 +121,7 @@ export default function ChatPage() {
|
||||
const initializedRef = useRef(false);
|
||||
// Initialize with default format prompt
|
||||
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
||||
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(false);
|
||||
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada
|
||||
const [thinkingStep, setThinkingStep] = useState<ThinkingStep | null>(null);
|
||||
const [processSteps, setProcessSteps] = useState<ProcessStep[]>([]);
|
||||
const [error, setError] = useState<string | Error | null>(null);
|
||||
@@ -398,7 +399,11 @@ export default function ChatPage() {
|
||||
});
|
||||
|
||||
try {
|
||||
// Executar busca progressiva
|
||||
// Obter contexto e queries enriquecidas do deep research
|
||||
const context = deepResearch.state.context;
|
||||
const enrichedQueries = deepResearch.state.enrichedQueries;
|
||||
|
||||
// Executar busca progressiva com semantic search se disponível
|
||||
const fallbackResult = await executeProgressiveSearch(
|
||||
query,
|
||||
async (q, limit) => {
|
||||
@@ -410,7 +415,10 @@ export default function ChatPage() {
|
||||
maxResultsPerRound: maxResults,
|
||||
maxTotalResults: 30, // Máximo 30 resultados por query
|
||||
minRelevanceScore: 0.3,
|
||||
enableQueryExpansion: true,
|
||||
enableQueryExpansion: !enrichedQueries, // Desabilitar expansão tradicional se usar enriquecimento
|
||||
useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas
|
||||
context: context || undefined,
|
||||
enrichedQueries: enrichedQueries || undefined,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -442,9 +450,25 @@ export default function ChatPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Se houve falhas mas ainda tem resultados, avisar discretamente
|
||||
const failedRounds = fallbackResult.attempts.filter(a => a.results.length === 0);
|
||||
if (failedRounds.length > 0 && fallbackResult.scrapedSources.length > 0) {
|
||||
updateThinkingMessage('web-research', {
|
||||
details: `Consulta ${idx + 1}: Algumas tentativas falharam, mas ${fallbackResult.scrapedSources.length} resultados foram encontrados.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { query, results: fallbackResult.scrapedSources, fallbackResult };
|
||||
} catch (err) {
|
||||
chatLog.error(` ✗ Progressive search ${idx + 1} failed: ${err}`);
|
||||
// Logar erro mas continuar (não quebrar o fluxo)
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
chatLog.warn(` ⚠️ Progressive search ${idx + 1} failed (continuando): ${errorMsg}`);
|
||||
|
||||
// Atualizar mensagem de processo com aviso
|
||||
updateThinkingMessage('web-research', {
|
||||
details: `Consulta ${idx + 1}: Erro na busca (${errorMsg}). Continuando com outras consultas...`,
|
||||
});
|
||||
|
||||
return { query, results: [] as ScrapedContent[], fallbackResult: null };
|
||||
}
|
||||
});
|
||||
@@ -563,25 +587,19 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Tratar erro graciosamente sem quebrar o fluxo
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
chatLog.error(`❌ Error in Deep Research pipeline: ${errorMsg}`);
|
||||
chatLog.warn(`⚠️ Error in Deep Research pipeline (continuing with partial results): ${errorMsg}`);
|
||||
|
||||
// Atualizar mensagem de processo com erro
|
||||
// Atualizar mensagem de processo com aviso (não erro fatal)
|
||||
updateThinkingMessage('web-research', {
|
||||
status: 'error',
|
||||
error: errorMsg,
|
||||
status: 'completed', // Marcar como completed mesmo com erro parcial
|
||||
details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`,
|
||||
duration: Date.now() - webResearchStart,
|
||||
});
|
||||
|
||||
// Atualizar step com erro (para compatibilidade)
|
||||
setProcessSteps(prev => prev.map(s =>
|
||||
s.id === 'web-research'
|
||||
? { ...s, status: 'error' as const, error: errorMsg }
|
||||
: s
|
||||
));
|
||||
|
||||
// Definir erro global
|
||||
setError(error);
|
||||
// Não definir erro global - permitir que o fluxo continue
|
||||
// O sistema continuará com conhecimento interno se necessário
|
||||
}
|
||||
} else {
|
||||
if (!webSearch.isEnabled) {
|
||||
@@ -849,7 +867,12 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
const panel = chatsSidebarRef.current;
|
||||
if (panel) {
|
||||
if (isChatsSidebarCollapsed) {
|
||||
panel.expand();
|
||||
// No mobile, apenas atualizar estado (overlay será mostrado)
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
setIsChatsSidebarCollapsed(false);
|
||||
} else {
|
||||
panel.expand();
|
||||
}
|
||||
} else {
|
||||
panel.collapse();
|
||||
}
|
||||
@@ -905,7 +928,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
defaultSize={4}
|
||||
minSize={4}
|
||||
maxSize={4}
|
||||
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px]"
|
||||
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px] min-w-[60px] max-w-[60px] flex-shrink-0"
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
@@ -932,48 +955,63 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
</Button>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
<ResizableHandle className="hidden md:block" />
|
||||
|
||||
{/* Sessions List */}
|
||||
{/* Sessions List - Desktop: ResizablePanel, Mobile: Overlay */}
|
||||
<ResizablePanel
|
||||
ref={chatsSidebarRef}
|
||||
defaultSize={15}
|
||||
minSize={10}
|
||||
defaultSize={0}
|
||||
minSize={0}
|
||||
maxSize={25}
|
||||
collapsible={true}
|
||||
collapsedSize={0}
|
||||
onCollapse={() => setIsChatsSidebarCollapsed(true)}
|
||||
onExpand={() => setIsChatsSidebarCollapsed(false)}
|
||||
className="min-w-[200px]"
|
||||
className="min-w-0 md:min-w-[220px] md:max-w-[360px] hidden md:block"
|
||||
style={{
|
||||
minWidth: 'clamp(220px, 20vw, 360px)',
|
||||
maxWidth: '360px',
|
||||
}}
|
||||
>
|
||||
<SidebarList
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
onSelectSession={(id) => {
|
||||
handleSelectSession(id);
|
||||
// Fechar sidebar no mobile após seleção
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
setIsChatsSidebarCollapsed(true);
|
||||
chatsSidebarRef.current?.collapse();
|
||||
}
|
||||
}}
|
||||
onDeleteSession={deleteSession}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
<ResizableHandle className="hidden md:block" />
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<ResizablePanel defaultSize={60} minSize={40}>
|
||||
<ResizablePanel defaultSize={100} minSize={75} className="transition-all duration-300">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4">
|
||||
<div className="h-14 border-b flex items-center px-3 sm:px-4 justify-between bg-background/50 backdrop-blur gap-2 sm:gap-4 flex-shrink-0" style={{ paddingLeft: 'clamp(12px, 2vw, 16px)', paddingRight: 'clamp(12px, 2vw, 16px)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isChatsSidebarCollapsed ? (
|
||||
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleChatsSidebar}
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
aria-label={isChatsSidebarCollapsed ? "Abrir conversas" : "Fechar conversas"}
|
||||
>
|
||||
{isChatsSidebarCollapsed ? (
|
||||
<PanelLeftOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8 text-muted-foreground">
|
||||
) : (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="font-semibold flex items-center gap-2">
|
||||
Chat
|
||||
)}
|
||||
</Button>
|
||||
<div className="font-semibold flex items-center gap-2 text-sm sm:text-base">
|
||||
<span className="hidden sm:inline">Chat</span>
|
||||
{isGeneratingTitle && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />}
|
||||
{isDownloading && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
@@ -984,40 +1022,62 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-[420px] flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onValueChange={(v) => {
|
||||
if (v === "__add_model__") {
|
||||
setShowDownloadDialog(true);
|
||||
// Não alterar selectedModel quando abrir dialog
|
||||
} else {
|
||||
setSelectedModel(v);
|
||||
settings.setSelectedModel(v);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Selecione um modelo..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map(m => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="flex items-center justify-between w-full">
|
||||
<span className="truncate">{m.name}</span>
|
||||
<span className="ml-3 text-xs text-muted-foreground">{m.size}</span>
|
||||
<div className="flex-1 max-w-full sm:max-w-[420px] flex items-center gap-2 min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onValueChange={(v) => {
|
||||
if (v === "__add_model__") {
|
||||
setShowDownloadDialog(true);
|
||||
// Não alterar selectedModel quando abrir dialog
|
||||
} else {
|
||||
setSelectedModel(v);
|
||||
settings.setSelectedModel(v);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="h-9 min-w-0 max-w-full">
|
||||
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
{selectedModel && (
|
||||
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{selectedModel}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<SelectContent className="max-w-[90vw] sm:max-w-[420px]">
|
||||
{models.map(m => (
|
||||
<Tooltip key={m.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={m.name} className="min-w-0">
|
||||
<span className="flex items-center justify-between w-full min-w-0 gap-2">
|
||||
<span className="truncate flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground shrink-0">{m.size}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
{m.name.length > 40 && (
|
||||
<TooltipContent side="right" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{m.name}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
<SelectItem value="__add_model__" className="text-primary font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Adicionar modelo...
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__add_model__" className="text-primary font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Adicionar modelo...
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
||||
<Loader2 className="w-4 h-4" />
|
||||
@@ -1028,8 +1088,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
{/* Messages Area with Floating Input Layout */}
|
||||
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
|
||||
{/* Scrollable Content Area */}
|
||||
<div className="flex-1 overflow-y-auto scroll-smooth w-full">
|
||||
<div className="max-w-3xl mx-auto w-full px-4 md:px-0 pt-6 pb-4">
|
||||
<div className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0">
|
||||
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
|
||||
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
|
||||
@@ -1041,7 +1101,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 sm:gap-6">
|
||||
{messages.map((msg, i) => {
|
||||
const isLastMessage = i === messages.length - (isLoading ? 1 : 0); // Ajuste simples
|
||||
// Streaming logica mantida simples
|
||||
@@ -1062,11 +1122,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Elemento invisível para scroll automático */}
|
||||
<div ref={messagesEndRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Debug Info (Moved inside scroll) */}
|
||||
{(deepResearch.state.logs.length > 0 || lastWebContext) && (
|
||||
<div className="mt-12 pt-6 border-t border-dashed border-muted/50 opacity-70 hover:opacity-100 transition-opacity">
|
||||
@@ -1138,12 +1194,12 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
</div>
|
||||
|
||||
{/* Fixed Input Container */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-none">
|
||||
<div className="absolute bottom-0 left-0 right-0 z-30 pointer-events-none">
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 -top-20 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none" />
|
||||
|
||||
{/* Input Wrapper */}
|
||||
<div className="relative max-w-3xl mx-auto px-4 pb-6 pt-4 pointer-events-auto">
|
||||
<div className="relative max-w-3xl mx-auto px-3 sm:px-4 md:px-6 pb-4 sm:pb-6 pt-4 pointer-events-auto transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={stop}
|
||||
@@ -1163,9 +1219,38 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{!isChatsSidebarCollapsed && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden transition-opacity"
|
||||
onClick={() => {
|
||||
setIsChatsSidebarCollapsed(true);
|
||||
chatsSidebarRef.current?.collapse();
|
||||
}}
|
||||
/>
|
||||
<div className="fixed left-0 top-0 bottom-0 w-[280px] min-w-[220px] max-w-[85vw] bg-background border-r z-50 md:hidden shadow-xl animate-in slide-in-from-left duration-300 overflow-y-auto">
|
||||
<SidebarList
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={(id) => {
|
||||
handleSelectSession(id);
|
||||
setIsChatsSidebarCollapsed(true);
|
||||
}}
|
||||
onDeleteSession={deleteSession}
|
||||
onNewChat={() => {
|
||||
handleNewChat();
|
||||
setIsChatsSidebarCollapsed(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ModelDownloadDialog
|
||||
open={showDownloadDialog}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
+145
@@ -152,3 +152,148 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar Styles - Padronizado para toda interface */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--primary)) hsl(var(--muted));
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive layout adjustments */
|
||||
@media (max-width: 650px) {
|
||||
/* Sidebar compact mode - hide labels, show only icons */
|
||||
.sidebar-compact .sidebar-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-compact .sidebar-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper spacing and padding at all resolutions */
|
||||
@media (min-width: 320px) and (max-width: 2560px) {
|
||||
/* Prevent layout breaking at extreme sizes */
|
||||
.chat-container {
|
||||
min-width: 320px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
padding-left: clamp(12px, 4vw, 24px);
|
||||
padding-right: clamp(12px, 4vw, 24px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent text overflow in cards and messages */
|
||||
.truncate-with-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ensure cards never stick to edges */
|
||||
.card-container {
|
||||
padding: clamp(8px, 2vw, 16px);
|
||||
}
|
||||
|
||||
/* Responsive font sizes */
|
||||
@media (max-width: 640px) {
|
||||
.responsive-text {
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Model name truncation - ensure long model names don't break layout */
|
||||
.model-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70vw;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.model-name {
|
||||
max-width: 54vw;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Select value truncation */
|
||||
[data-radix-select-trigger] span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ensure SelectItems also truncate properly */
|
||||
[data-radix-select-item] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-radix-select-item] span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70vw;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Scrollbar específico para lista de chats - mais fino e discreto */
|
||||
.sidebar-chat-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-chat-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-chat-list::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-chat-list::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary) / 0.6);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar para lista de chats */
|
||||
.sidebar-chat-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
}
|
||||
+177
-18
@@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -304,21 +305,43 @@ export default function SettingsPage() {
|
||||
Carregando modelos...
|
||||
</div>
|
||||
) : availableModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.selectedModel}
|
||||
onValueChange={settings.setSelectedModel}
|
||||
>
|
||||
<SelectTrigger id="model-select">
|
||||
<SelectValue placeholder="Selecione um modelo..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
value={settings.selectedModel}
|
||||
onValueChange={settings.setSelectedModel}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
|
||||
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
{settings.selectedModel && (
|
||||
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{settings.selectedModel}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<SelectContent className="max-w-[90vw]">
|
||||
{availableModels.map((model) => (
|
||||
<Tooltip key={model.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={model.name} className="min-w-0">
|
||||
<span className="truncate block" style={{ maxWidth: '70vw' }}>
|
||||
{model.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
{model.name.length > 40 && (
|
||||
<TooltipContent side="right" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{model.name}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Input
|
||||
id="model-input"
|
||||
@@ -463,12 +486,12 @@ export default function SettingsPage() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>10s</span>
|
||||
<span className="font-medium">{settings.webSearch.timeout}s</span>
|
||||
<span className="font-medium">{settings.webSearch.timeout / 1000}s</span>
|
||||
<span>60s</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[settings.webSearch.timeout]}
|
||||
onValueChange={([value]) => settings.setWebSearchTimeout(value)}
|
||||
value={[settings.webSearch.timeout / 1000]}
|
||||
onValueChange={([value]) => settings.setWebSearchTimeout(value * 1000)}
|
||||
min={10}
|
||||
max={60}
|
||||
step={5}
|
||||
@@ -487,6 +510,142 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card: Search Engines Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Motores de Busca</CardTitle>
|
||||
<CardDescription>
|
||||
Configure a ordem e comportamento dos motores de busca
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Ordem dos Motores</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Arraste para reordenar. O sistema tentará cada motor na ordem especificada até encontrar resultados suficientes.
|
||||
</p>
|
||||
<div className="space-y-2 border rounded-lg p-4">
|
||||
{settings.webSearch.engineOrder.map((engine, index) => (
|
||||
<div
|
||||
key={engine}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium capitalize">{engine}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
const newOrder = [...settings.webSearch.engineOrder];
|
||||
[newOrder[index], newOrder[index - 1]] = [newOrder[index - 1], newOrder[index]];
|
||||
settings.setWebSearchEngineOrder(newOrder);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{index < settings.webSearch.engineOrder.length - 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
const newOrder = [...settings.webSearch.engineOrder];
|
||||
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
|
||||
settings.setWebSearchEngineOrder(newOrder);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3 rotate-180" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min-results-per-engine">Mínimo de Resultados por Motor</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="min-results-per-engine"
|
||||
type="number"
|
||||
value={settings.webSearch.minResultsPerEngine}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value || '1', 10);
|
||||
const clamped = Math.min(10, Math.max(1, isNaN(v) ? 1 : v));
|
||||
settings.setWebSearchMinResultsPerEngine(clamped);
|
||||
}}
|
||||
className="w-24"
|
||||
min={1}
|
||||
max={10}
|
||||
disabled={!settings.webSearch.enabled}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={[settings.webSearch.minResultsPerEngine]}
|
||||
onValueChange={([value]) => settings.setWebSearchMinResultsPerEngine(value)}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
disabled={!settings.webSearch.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Número mínimo de resultados para considerar um motor como bem-sucedido. Se um motor retornar menos resultados, o sistema tentará o próximo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="semantic-expansion">Expansão Semântica de Queries</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Adiciona sinônimos e variantes às queries para melhorar resultados
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="semantic-expansion"
|
||||
checked={settings.webSearch.enableSemanticExpansion}
|
||||
onCheckedChange={settings.setWebSearchSemanticExpansion}
|
||||
disabled={!settings.webSearch.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.webSearch.enableSemanticExpansion && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="semantic-language">Idioma para Expansão Semântica</Label>
|
||||
<Select
|
||||
value={settings.webSearch.semanticExpansionLanguage}
|
||||
onValueChange={settings.setWebSearchSemanticExpansionLanguage}
|
||||
disabled={!settings.webSearch.enabled}
|
||||
>
|
||||
<SelectTrigger id="semantic-language">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Idioma usado para identificar stopwords e sinônimos durante a expansão semântica.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Sources & Knowledge */}
|
||||
|
||||
@@ -112,14 +112,14 @@ export function ChatInput({
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Pergunte qualquer coisa..."
|
||||
className={`min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-4 pl-6 pr-32 text-base sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${
|
||||
className={`min-h-[52px] sm:min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-3 sm:py-4 pl-4 sm:pl-6 pr-28 sm:pr-32 text-[16px] sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${
|
||||
input.trim() && !isValid ? 'text-destructive' : ''
|
||||
}`}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Right Actions */}
|
||||
<div className="absolute right-3 bottom-2.5 flex items-center gap-1.5">
|
||||
<div className="absolute right-2 sm:right-3 bottom-2 sm:bottom-2.5 flex items-center gap-1 sm:gap-1.5">
|
||||
{onWebSearchToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
|
||||
@@ -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 { Progress } from "@/components/ui/progress";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Download, Loader2, X } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Download, Loader2, X, Upload, Globe, AlertCircle, File } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
// Usar file dialog do Tauri v2
|
||||
|
||||
interface ModelDownloadDialogProps {
|
||||
open: boolean;
|
||||
@@ -24,6 +26,7 @@ interface DownloadInfo {
|
||||
}
|
||||
|
||||
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
|
||||
const [activeTab, setActiveTab] = useState<"download" | "local">("download");
|
||||
const [modelName, setModelName] = useState("");
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
@@ -33,6 +36,12 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const isDownloadingRef = useRef(false);
|
||||
const modelNameRef = useRef("");
|
||||
|
||||
// Estados para instalação local
|
||||
const [isInstallingLocal, setIsInstallingLocal] = useState(false);
|
||||
const [localInstallProgress, setLocalInstallProgress] = useState(0);
|
||||
const [localInstallError, setLocalInstallError] = useState<string | null>(null);
|
||||
const [localModelName, setLocalModelName] = useState("");
|
||||
|
||||
// Atualizar refs quando estado muda
|
||||
useEffect(() => {
|
||||
@@ -43,6 +52,7 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
||||
// Resetar estado quando dialog abre/fecha
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab("download");
|
||||
setModelName("");
|
||||
setIsDownloading(false);
|
||||
setDownloadProgress(0);
|
||||
@@ -52,6 +62,11 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
||||
setIsInstalled(false);
|
||||
isDownloadingRef.current = false;
|
||||
modelNameRef.current = "";
|
||||
// Reset estados locais
|
||||
setIsInstallingLocal(false);
|
||||
setLocalInstallProgress(0);
|
||||
setLocalInstallError(null);
|
||||
setLocalModelName("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -243,6 +258,69 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFileButton = async () => {
|
||||
try {
|
||||
// Usar comando Rust para abrir dialog nativo do sistema
|
||||
// Isso evita limitações do input file HTML5 com arquivos grandes
|
||||
const selectedPath = await invoke<string | null>('open_gguf_file_dialog');
|
||||
|
||||
if (!selectedPath) {
|
||||
// Usuário cancelou
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInstallingLocal(true);
|
||||
setLocalInstallError(null);
|
||||
setLocalInstallProgress(0);
|
||||
|
||||
try {
|
||||
setLocalInstallProgress(10);
|
||||
|
||||
// Extrair nome do arquivo do caminho
|
||||
const pathParts = selectedPath.split(/[/\\]/);
|
||||
const fileName = pathParts[pathParts.length - 1] || 'model';
|
||||
const fileNameLower = fileName.toLowerCase();
|
||||
const hasGgufExtension = fileNameLower.endsWith('.gguf');
|
||||
|
||||
// Extrair nome do modelo (remover extensão se for .gguf, senão manter)
|
||||
const fileNameWithoutExt = hasGgufExtension
|
||||
? fileName.replace(/\.gguf$/i, '')
|
||||
: fileName;
|
||||
const modelNameToUse = localModelName.trim() || fileNameWithoutExt;
|
||||
|
||||
setLocalInstallProgress(30);
|
||||
|
||||
// Instalar diretamente do caminho (sem precisar copiar para temp primeiro)
|
||||
const installedModelName = await invoke<string>('install_gguf_model', {
|
||||
filePath: selectedPath,
|
||||
modelName: modelNameToUse || undefined,
|
||||
});
|
||||
|
||||
setLocalInstallProgress(100);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(installedModelName);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
}, 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
const errorMsg = err?.message || 'Erro ao instalar modelo GGUF';
|
||||
setLocalInstallError(errorMsg);
|
||||
console.error('Erro ao instalar modelo GGUF:', err);
|
||||
} finally {
|
||||
setIsInstallingLocal(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Erro ao abrir dialog de arquivo:', err);
|
||||
setLocalInstallError('Erro ao abrir seletor de arquivo');
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
|
||||
|
||||
return (
|
||||
@@ -251,114 +329,198 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
|
||||
handleClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adicionar Modelo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Digite o nome do modelo do Ollama que deseja baixar (ex: llama3.2:1b, mistral:latest)
|
||||
Baixe modelos do Ollama ou instale arquivos GGUF locais
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-name">Nome do Modelo</Label>
|
||||
<Input
|
||||
id="model-name"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="ex: llama3.2:1b"
|
||||
disabled={isDownloading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isValid && !isDownloading) {
|
||||
handleDownload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{isInstalled && !isDownloading && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
✓ Este modelo já está instalado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDownloading && (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "download" | "local")} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="download" className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Baixar do Ollama
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="local" className="flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Instalar Local
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="download" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{downloadStatus || "Iniciando..."}</span>
|
||||
<span>{downloadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={downloadProgress} className="h-2" />
|
||||
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{downloadInfo.downloaded && downloadInfo.total
|
||||
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
|
||||
: downloadInfo.downloaded || ""}
|
||||
</span>
|
||||
{downloadInfo.speed && (
|
||||
<span>{downloadInfo.speed}</span>
|
||||
)}
|
||||
</div>
|
||||
<Label htmlFor="model-name">Nome do Modelo</Label>
|
||||
<Input
|
||||
id="model-name"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="ex: llama3.2:1b"
|
||||
disabled={isDownloading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isValid && !isDownloading) {
|
||||
handleDownload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{isInstalled && !isDownloading && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
✓ Este modelo já está instalado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInstalled && !isDownloading && (
|
||||
<div className="bg-muted/50 p-3 rounded-md text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
O modelo está instalado e pronto para uso.
|
||||
</p>
|
||||
{isDownloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{downloadStatus || "Iniciando..."}</span>
|
||||
<span>{downloadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={downloadProgress} className="h-2" />
|
||||
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{downloadInfo.downloaded && downloadInfo.total
|
||||
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
|
||||
: downloadInfo.downloaded || ""}
|
||||
</span>
|
||||
{downloadInfo.speed && (
|
||||
<span>{downloadInfo.speed}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInstalled && !isDownloading && (
|
||||
<div className="bg-muted/50 p-3 rounded-md text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
O modelo está instalado e pronto para uso.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="local" className="space-y-4 mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="local-model-name">Nome do Modelo (opcional)</Label>
|
||||
<Input
|
||||
id="local-model-name"
|
||||
value={localModelName}
|
||||
onChange={(e) => setLocalModelName(e.target.value)}
|
||||
placeholder="Deixe vazio para usar o nome do arquivo"
|
||||
disabled={isInstallingLocal}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Se não especificar, o nome será extraído do arquivo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isInstallingLocal ? (
|
||||
<div className="border rounded-xl p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Instalando modelo...</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Processando arquivo...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localInstallProgress > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={localInstallProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{localInstallProgress}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-2 border-dashed rounded-xl p-8 text-center bg-muted/30">
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Selecione um arquivo GGUF para instalar localmente
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSelectFileButton}
|
||||
disabled={isInstallingLocal}
|
||||
className="w-full"
|
||||
>
|
||||
<File className="w-4 h-4 mr-2" />
|
||||
Selecionar arquivo GGUF
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localInstallError && (
|
||||
<div className="border border-destructive/50 rounded-xl p-4 bg-destructive/5 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm text-destructive">Erro ao instalar modelo</p>
|
||||
<p className="text-xs text-destructive/80 mt-1">{localInstallError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 border border-border/50 rounded-lg p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-1">ℹ️ Modelos instalados localmente</p>
|
||||
<p>Funcionam sem conexão à internet. O arquivo será copiado para o diretório do Ollama.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isInstallingLocal}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancelar Download
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancelar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isInstalled && !isDownloading) {
|
||||
handleSuccess(modelName.trim());
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
handleDownload();
|
||||
}
|
||||
}}
|
||||
disabled={!isValid || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Baixando...
|
||||
</>
|
||||
) : isInstalled ? (
|
||||
<>
|
||||
Usar Modelo
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Baixar
|
||||
</>
|
||||
)}
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
{activeTab === "download" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isInstalled && !isDownloading) {
|
||||
handleSuccess(modelName.trim());
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
handleDownload();
|
||||
}
|
||||
}}
|
||||
disabled={!isValid || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Baixando...
|
||||
</>
|
||||
) : isInstalled ? (
|
||||
<>
|
||||
Usar Modelo
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Baixar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePromptGenerator } from "@/hooks/use-prompt-generator";
|
||||
@@ -92,30 +93,54 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp
|
||||
<label htmlFor="model-select" className="text-sm font-medium">
|
||||
Modelo para Geração
|
||||
</label>
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onValueChange={setSelectedModel}
|
||||
disabled={loading || models.length === 0}
|
||||
>
|
||||
<SelectTrigger id="model-select">
|
||||
<SelectValue placeholder={
|
||||
loading
|
||||
? "Carregando modelos..."
|
||||
: models.length === 0
|
||||
? "Nenhum modelo disponível"
|
||||
: "Selecione um modelo..."
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.length === 0 ? (
|
||||
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
||||
) : (
|
||||
models.map(m => (
|
||||
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onValueChange={setSelectedModel}
|
||||
disabled={loading || models.length === 0}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
|
||||
<SelectValue placeholder={
|
||||
loading
|
||||
? "Carregando modelos..."
|
||||
: models.length === 0
|
||||
? "Nenhum modelo disponível"
|
||||
: "Selecione um modelo..."
|
||||
} className="truncate" />
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
{selectedModel && (
|
||||
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{selectedModel}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<SelectContent className="max-w-[90vw]">
|
||||
{models.length === 0 ? (
|
||||
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
||||
) : (
|
||||
models.map(m => (
|
||||
<Tooltip key={m.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={m.name} className="min-w-0">
|
||||
<span className="truncate block" style={{ maxWidth: '70vw' }}>
|
||||
{m.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
{m.name.length > 40 && (
|
||||
<TooltipContent side="right" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{m.name}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipProvider>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SessionSummary } from "@/hooks/use-chat-storage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Trash2, Plus, MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarListProps {
|
||||
@@ -13,11 +14,36 @@ interface SidebarListProps {
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInHours = diffInMs / (1000 * 60 * 60);
|
||||
const diffInDays = diffInMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Se foi hoje, mostrar apenas hora
|
||||
if (diffInHours < 24) {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Se foi ontem
|
||||
if (diffInDays < 2) {
|
||||
return 'Ontem';
|
||||
}
|
||||
|
||||
// Se foi nos últimos 7 dias
|
||||
if (diffInDays < 7) {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
weekday: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Caso contrário, mostrar data completa
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
year: diffInDays > 365 ? 'numeric' : undefined
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
@@ -29,61 +55,122 @@ export function SidebarList({
|
||||
onNewChat
|
||||
}: SidebarListProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full border-r bg-muted/10">
|
||||
<div className="p-4 border-b">
|
||||
<Button onClick={onNewChat} className="w-full justify-start gap-2" variant="outline">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nova Conversa
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-2 space-y-2">
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
Nenhuma conversa salva
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"group flex items-center justify-between p-2 rounded-lg cursor-pointer hover:bg-accent transition-colors",
|
||||
currentSessionId === session.id && "bg-accent"
|
||||
)}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3 overflow-hidden flex-1">
|
||||
<span className="text-lg shrink-0 mt-0.5" role="img" aria-label="emoji">
|
||||
{session.emoji || '💬'}
|
||||
</span>
|
||||
<div className="flex flex-col overflow-hidden flex-1">
|
||||
<span className="text-sm font-medium truncate" title={session.title}>
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{formatDate(session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar min-w-[240px] max-w-[360px] w-full">
|
||||
{/* Header com botão Nova Conversa */}
|
||||
<div className="p-3 sm:p-4 border-b border-sidebar-border flex-shrink-0">
|
||||
<Button
|
||||
onClick={onNewChat}
|
||||
className="w-full justify-start gap-2 text-sm font-medium h-9 sm:h-10"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" />
|
||||
<span className="truncate">Nova Conversa</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lista de chats com scroll customizado */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
|
||||
<div className="p-3 sm:p-4 space-y-2 sm:space-y-2.5">
|
||||
{sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conversa salva
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Comece uma nova conversa para começar
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => {
|
||||
const isSelected = currentSessionId === session.id;
|
||||
const showTooltip = session.title.length > 30;
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<Tooltip key={session.id} delayDuration={300} disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
|
||||
"hover:shadow-md hover:border-primary/20 hover:-translate-y-0.5",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
isSelected && "border-primary/40 shadow-sm bg-accent/50",
|
||||
"active:scale-[0.98]"
|
||||
)}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelectSession(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Conteúdo do card */}
|
||||
<div className="p-3 sm:p-3.5 flex items-start gap-3 overflow-hidden">
|
||||
{/* Ícone/Emoji */}
|
||||
<div className="shrink-0 flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-md bg-muted/50 text-base sm:text-lg">
|
||||
{session.emoji || '💬'}
|
||||
</div>
|
||||
|
||||
{/* Texto do card */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<h3
|
||||
className="text-sm sm:text-base font-medium text-card-foreground truncate leading-tight mb-1"
|
||||
>
|
||||
{session.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(session.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Botão de deletar - aparece no hover */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 sm:h-8 sm:w-8 shrink-0 transition-all duration-200",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"hover:bg-destructive/10 hover:text-destructive",
|
||||
"active:scale-95"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
aria-label={`Deletar conversa "${session.title}"`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Indicador de seleção */}
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<p className="break-words font-medium">{session.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatDate(session.updated_at)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, HardDrive, Cpu, Database } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Trash2, HardDrive, Cpu, Database, Upload, Globe } from "lucide-react";
|
||||
import { useSystemMonitor } from "@/hooks/use-system-monitor";
|
||||
import { useLocalModels } from "@/hooks/use-local-models";
|
||||
import { useState } from "react";
|
||||
import { ModelDownloadDialog } from "./model-download-dialog";
|
||||
|
||||
export function SystemPanel() {
|
||||
const stats = useSystemMonitor();
|
||||
const { models, deleteModel, refresh } = useLocalModels();
|
||||
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
||||
|
||||
const ramUsagePercent = stats
|
||||
? (stats.memory_used / stats.memory_total) * 100
|
||||
@@ -63,42 +68,103 @@ export function SystemPanel() {
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Modelos Instalados
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
|
||||
Atualizar
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDownloadDialog(true)}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Upload className="w-3 h-3 mr-1" />
|
||||
Adicionar
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
|
||||
Atualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{models.map((model) => (
|
||||
<Card key={model.id} className="overflow-hidden">
|
||||
<CardContent className="p-3 flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate" title={model.name}>
|
||||
{model.name}
|
||||
{models.map((model) => {
|
||||
// Tentar detectar se é modelo local/GGUF (heurística: modelos GGUF geralmente têm nomes específicos)
|
||||
// Por enquanto, vamos mostrar todos como disponíveis
|
||||
const isLocal = model.name.toLowerCase().includes('gguf') ||
|
||||
model.name.toLowerCase().endsWith('.gguf');
|
||||
|
||||
return (
|
||||
<Card key={model.id} className="overflow-hidden hover:bg-muted/50 transition-colors">
|
||||
<CardContent className="p-3 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="font-medium text-sm truncate" title={model.name}>
|
||||
{model.name}
|
||||
</div>
|
||||
{isLocal && (
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
|
||||
<Upload className="w-2.5 h-2.5 mr-1" />
|
||||
Local
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Database className="w-3 h-3" />
|
||||
{model.size}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Database className="w-3 h-3" />
|
||||
{model.size}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => deleteModel(model.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 flex-shrink-0"
|
||||
onClick={async () => {
|
||||
if (confirm(`Tem certeza que deseja remover o modelo "${model.name}"?`)) {
|
||||
try {
|
||||
await deleteModel(model.name);
|
||||
} catch (error) {
|
||||
console.error('Erro ao remover modelo:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remover modelo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{models.length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
Nenhum modelo encontrado.
|
||||
<div className="text-center text-sm text-muted-foreground py-8 space-y-2">
|
||||
<p>Nenhum modelo encontrado.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDownloadDialog(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Instalar Modelo
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelDownloadDialog
|
||||
open={showDownloadDialog}
|
||||
onOpenChange={setShowDownloadDialog}
|
||||
onSuccess={(modelName) => {
|
||||
refresh();
|
||||
setShowDownloadDialog(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,3 +65,5 @@ export function WelcomeNotification() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 { Cpu, MemoryStick, HardDrive, Activity, Clock, Monitor } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { GpuDashboard } from './GpuDashboard';
|
||||
|
||||
interface SystemStats {
|
||||
cpu_usage: number;
|
||||
@@ -85,7 +86,8 @@ export function HardwareDashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* CPU Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -135,25 +137,6 @@ export function HardwareDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GPU Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
GPU
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm">
|
||||
{stats.gpu_name ? (
|
||||
<span className="text-muted-foreground">{stats.gpu_name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">Não detectada</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Info Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -207,6 +190,10 @@ export function HardwareDashboard() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* GPU Dashboard - Substitui o card simples */}
|
||||
<GpuDashboard pollInterval={2000} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 [&>span]:truncate [&>span]:min-w-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
export const DEEP_RESEARCH_PROMPTS = {
|
||||
DECOMPOSITION: `Role: Research Lead.
|
||||
Task: Decompose the user's query into 3-5 atomic, search-friendly questions to cover different angles.
|
||||
Output: JSON array of strings ONLY. No markdown, no explanations.
|
||||
DECOMPOSITION: `Role: Research Lead specializing in web search optimization.
|
||||
Task: Decompose the user's query into 3-5 atomic, search-friendly questions optimized for web search engines.
|
||||
|
||||
User Query: "{{userQuery}}"
|
||||
|
||||
Constraints:
|
||||
1. PRESERVE THE SUBJECT: If the user asks about "Garuva", ALL queries must mention "Garuva".
|
||||
2. DO NOT TRANSLATE proper names or specific terms unless necessary.
|
||||
3. AVOID generic queries like "what is this" without context.
|
||||
4. Return ONLY a JSON array of strings.
|
||||
## CRITICAL INSTRUCTIONS
|
||||
|
||||
Example Output for "Onde fica Garuva?":
|
||||
["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"]`,
|
||||
1. **PRESERVE THE SUBJECT**: If the user asks about "Garuva", ALL queries must mention "Garuva".
|
||||
2. **USE SPECIFIC KEYWORDS**: Include relevant technical terms, proper names, locations, dates, or context that will help search engines find precise results.
|
||||
3. **AVOID GENERIC QUERIES**: Never create queries like "what is this" or "tell me about" without specific context. Instead, use descriptive terms.
|
||||
4. **OPTIMIZE FOR SEARCH ENGINES**:
|
||||
- Use terms that are likely to appear in search results
|
||||
- Include synonyms or related terms when appropriate
|
||||
- For academic/research queries, include terms like "estudo", "pesquisa", "resultado", "premiado"
|
||||
- For location queries, include geographic context (state, country, region)
|
||||
5. **COVER DIFFERENT ANGLES**: Each query should explore a different aspect or perspective of the original question.
|
||||
6. **DO NOT TRANSLATE**: Preserve proper names, technical terms, and specific terminology in their original form.
|
||||
7. **OUTPUT FORMAT**: Return ONLY a JSON array of strings. No markdown, no explanations, no additional text.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
Input: "Onde fica Garuva?"
|
||||
Output: ["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"]
|
||||
|
||||
Input: "quais os ultimos resultados academicos mais premiados?"
|
||||
Output: ["resultados academicos premiados 2024", "pesquisas cientificas premiadas recentes", "estudos academicos reconhecidos premios", "descobertas academicas premiadas"]
|
||||
|
||||
Input: "como funciona inteligencia artificial?"
|
||||
Output: ["inteligencia artificial como funciona", "IA machine learning algoritmos", "sistemas inteligencia artificial aplicacoes"]
|
||||
|
||||
## YOUR TASK
|
||||
|
||||
Decompose this query into search-optimized sub-queries:
|
||||
"{{userQuery}}"
|
||||
|
||||
Return ONLY a JSON array of strings:`,
|
||||
|
||||
CONTEXT_CHECK: `Role: Fact Checker.
|
||||
Task: Analyze the Knowledge Base below and determine if it contains sufficient information to answer the user's query.
|
||||
@@ -59,9 +81,47 @@ When citing sources, use this format:
|
||||
- For inline citations: "Garuva está localizada em Santa Catarina [ID: 1]."
|
||||
- For multiple sources: "A cidade possui praias e montanhas [ID: 1, 3]."
|
||||
- At the end, list all sources used with their URLs.
|
||||
`
|
||||
`,
|
||||
|
||||
QUERY_ENRICHMENT: `Você é um especialista em otimização de queries de busca e expansão semântica.
|
||||
|
||||
Com base na query original e no contexto analisado, gere múltiplas variantes de queries enriquecidas para maximizar a cobertura de resultados relevantes.
|
||||
|
||||
## QUERY ORIGINAL
|
||||
"{{originalQuery}}"
|
||||
|
||||
## CONTEXTO ANALISADO
|
||||
- Intenção: {{intent}}
|
||||
- Tópicos: {{topics}}
|
||||
- Entidades: {{entities}}
|
||||
- Contexto Temporal: {{temporalContext}}
|
||||
- Contexto Geográfico: {{geographicContext}}
|
||||
- Palavras-chave: {{keywords}}
|
||||
- Sinônimos: {{synonyms}}
|
||||
- Domínio: {{domain}}
|
||||
|
||||
## INSTRUÇÕES
|
||||
|
||||
Gere queries enriquecidas nas seguintes categorias:
|
||||
|
||||
1. **LITERAL**: Variações diretas da query original (preservar termos principais)
|
||||
2. **SEMANTIC**: Variantes semânticas usando sinônimos e reformulações
|
||||
3. **RELATED**: Perguntas relacionadas e FAQs
|
||||
4. **EXPANDED**: Expansões temáticas específicas
|
||||
5. **CONTEXTUAL**: Queries com contexto adicional (datas, locais, domínios)
|
||||
|
||||
## REGRAS CRÍTICAS
|
||||
|
||||
- **PRESERVAR ENTIDADES**: Se a query menciona uma entidade, TODAS as queries devem mencioná-la
|
||||
- **NÃO TRADUZIR**: Preservar nomes próprios, termos técnicos na forma original
|
||||
- **OTIMIZAR PARA BUSCA**: Usar termos que aparecem em resultados reais
|
||||
- **COBERTURA AMPLA**: Cada categoria deve explorar diferentes ângulos
|
||||
- **QUALIDADE > QUANTIDADE**: 3-5 queries por categoria é suficiente
|
||||
|
||||
Retorne APENAS um JSON válido com arrays para cada categoria.`
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ScrapedContent } from '@/services/webSearch';
|
||||
import { deepResearchLog as log } from '@/lib/terminal-logger';
|
||||
import { condenseKnowledgeBase, validateCondensedContext } from '@/lib/knowledge-base-processor';
|
||||
import type { CondensationResult } from '@/lib/knowledge-base-processor';
|
||||
import { analyzeQueryContext, type QueryContext } from '@/lib/contextual-analyzer';
|
||||
import { enrichQueries, type EnrichedQueries } from '@/lib/query-enricher';
|
||||
|
||||
export type DeepResearchStep = 'idle' | 'planning' | 'searching' | 'aggregating' | 'validating' | 'formulating' | 'complete' | 'error';
|
||||
|
||||
@@ -32,6 +34,8 @@ export interface DeepResearchState {
|
||||
validationReport: string;
|
||||
error?: string;
|
||||
logs: DeepResearchLog[];
|
||||
context?: QueryContext;
|
||||
enrichedQueries?: EnrichedQueries;
|
||||
}
|
||||
|
||||
// ========== LOGGING UTILITIES ==========
|
||||
@@ -177,6 +181,49 @@ export function useDeepResearch() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- CONTEXTUAL ANALYSIS ---
|
||||
const analyzeContext = useCallback(async (query: string, model: string): Promise<QueryContext | null> => {
|
||||
logStep('--- Contextual Analysis ---');
|
||||
try {
|
||||
const context = await analyzeQueryContext(query, model);
|
||||
logData('Context Analysis', {
|
||||
intent: context.intent,
|
||||
entities: context.entities.length,
|
||||
topics: context.topics.length,
|
||||
keywords: context.keywords.length,
|
||||
});
|
||||
setState(s => ({ ...s, context }));
|
||||
return context;
|
||||
} catch (error) {
|
||||
log.warn('Contextual analysis failed, continuing without context:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- QUERY ENRICHMENT ---
|
||||
const enrich = useCallback(async (query: string, context: QueryContext | null, model: string): Promise<EnrichedQueries | null> => {
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logStep('--- Query Enrichment ---');
|
||||
try {
|
||||
const enriched = await enrichQueries(query, context, model);
|
||||
logData('Enriched Queries', {
|
||||
literal: enriched.literal.length,
|
||||
semantic: enriched.semantic.length,
|
||||
related: enriched.related.length,
|
||||
expanded: enriched.expanded.length,
|
||||
contextual: enriched.contextual.length,
|
||||
});
|
||||
setState(s => ({ ...s, enrichedQueries: enriched }));
|
||||
return enriched;
|
||||
} catch (error) {
|
||||
log.warn('Query enrichment failed, continuing without enrichment:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- DECOMPOSITION ---
|
||||
const decompose = useCallback(async (query: string, model: string) => {
|
||||
logSection('DECOMPOSITION');
|
||||
@@ -186,6 +233,12 @@ export function useDeepResearch() {
|
||||
setState(s => ({ ...s, step: 'planning', error: undefined, logs: [], knowledgeBase: [] }));
|
||||
|
||||
try {
|
||||
// Análise contextual (opcional, não bloqueia se falhar)
|
||||
const context = await analyzeContext(query, model);
|
||||
|
||||
// Enriquecimento de queries (opcional, não bloqueia se falhar)
|
||||
const enriched = context ? await enrich(query, context, model) : null;
|
||||
|
||||
const prompt = DEEP_RESEARCH_PROMPTS.DECOMPOSITION.replace('{{userQuery}}', query);
|
||||
logStep('Sending decomposition prompt to LLM...');
|
||||
const response = await callLLM(model, prompt, true);
|
||||
|
||||
@@ -54,3 +54,5 @@ export function useOperatingSystem() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+14
-2
@@ -147,7 +147,13 @@ export function useWebSearch() {
|
||||
|
||||
// Etapa 1: metadados
|
||||
setState(prev => ({ ...prev, status: 'searching' }));
|
||||
const { metadata, contents } = await webSearchService.smartSearchRag(query, limit, searchConfig);
|
||||
const timeoutMs = settings.webSearch.timeout || 15000;
|
||||
const { metadata, contents } = await webSearchService.smartSearchRag(
|
||||
query,
|
||||
limit,
|
||||
searchConfig,
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
// Etapa 2: scraping
|
||||
setState(prev => ({ ...prev, status: 'scraping' }));
|
||||
@@ -162,16 +168,22 @@ export function useWebSearch() {
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// Retornar resultados mesmo se parciais (não quebrar o fluxo)
|
||||
return contents;
|
||||
} catch (error) {
|
||||
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
|
||||
console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
scrapedSources: [],
|
||||
}));
|
||||
throw error;
|
||||
|
||||
// Retornar vazio em vez de throw para não quebrar o fluxo
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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 { QueryContext } from './contextual-analyzer';
|
||||
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
||||
import { scoreAndSelectChunks } from './relevance-scorer';
|
||||
import { summarizeMarkdown } from './content-condenser';
|
||||
@@ -20,6 +21,7 @@ export interface CondensationOptions {
|
||||
autoSummarize?: boolean;
|
||||
summarizeThreshold?: number;
|
||||
fallbackToSummarization?: boolean;
|
||||
context?: QueryContext; // Contexto analisado para ranqueamento semântico
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
|
||||
@@ -129,12 +131,13 @@ export function condenseKnowledgeBase(
|
||||
// Calcular tokens disponíveis (reservar 20% para margem)
|
||||
const availableTokens = Math.floor(opts.maxTokens * 0.8);
|
||||
|
||||
// Selecionar chunks mais relevantes
|
||||
// Selecionar chunks mais relevantes (usando contexto se disponível)
|
||||
let selectedChunks = scoreAndSelectChunks(
|
||||
allChunks,
|
||||
query,
|
||||
availableTokens,
|
||||
opts.minRelevanceScore
|
||||
opts.minRelevanceScore,
|
||||
opts.context
|
||||
);
|
||||
|
||||
// Se não selecionou chunks suficientes ou método de fallback está ativo
|
||||
|
||||
@@ -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 { QueryContext } from './contextual-analyzer';
|
||||
import { normalizeText } from './text-normalization';
|
||||
|
||||
/**
|
||||
@@ -72,14 +73,162 @@ export function calculateSimilarity(query: string, chunk: string): number {
|
||||
return Math.min(Math.max(score, 0), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula similaridade semântica avançada usando contexto completo
|
||||
* Considera entidades, tópicos, relacionamentos e intenção
|
||||
*
|
||||
* @param context - Contexto analisado da query
|
||||
* @param chunk - Chunk de conteúdo
|
||||
* @returns Score de similaridade semântica (0-1)
|
||||
*/
|
||||
export function calculateSemanticSimilarity(
|
||||
context: QueryContext,
|
||||
chunk: string
|
||||
): number {
|
||||
if (!chunk || !context) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalizedChunk = normalizeText(chunk).toLowerCase();
|
||||
let totalScore = 0;
|
||||
let weightSum = 0;
|
||||
|
||||
// 1. Match de keywords (peso: 0.3)
|
||||
if (context.keywords.length > 0) {
|
||||
let keywordMatches = 0;
|
||||
for (const keyword of context.keywords) {
|
||||
const keywordLower = keyword.toLowerCase();
|
||||
if (normalizedChunk.includes(keywordLower)) {
|
||||
keywordMatches++;
|
||||
}
|
||||
}
|
||||
const keywordScore = keywordMatches / context.keywords.length;
|
||||
totalScore += keywordScore * 0.3;
|
||||
weightSum += 0.3;
|
||||
}
|
||||
|
||||
// 2. Match de entidades (peso: 0.3)
|
||||
if (context.entities.length > 0) {
|
||||
let entityMatches = 0;
|
||||
for (const entity of context.entities) {
|
||||
const entityLower = entity.value.toLowerCase();
|
||||
// Buscar entidade completa (case-insensitive)
|
||||
if (normalizedChunk.includes(entityLower)) {
|
||||
entityMatches += entity.confidence; // Ponderar por confiança
|
||||
}
|
||||
}
|
||||
const entityScore = entityMatches / context.entities.length;
|
||||
totalScore += entityScore * 0.3;
|
||||
weightSum += 0.3;
|
||||
}
|
||||
|
||||
// 3. Match de tópicos (peso: 0.2)
|
||||
if (context.topics.length > 0) {
|
||||
let topicMatches = 0;
|
||||
for (const topic of context.topics) {
|
||||
const topicLower = topic.toLowerCase();
|
||||
// Verificar se tópico aparece no chunk (pode ser parcial)
|
||||
const topicWords = topicLower.split(/\s+/);
|
||||
const matches = topicWords.filter(word =>
|
||||
word.length > 3 && normalizedChunk.includes(word)
|
||||
);
|
||||
if (matches.length >= topicWords.length * 0.5) { // 50% das palavras do tópico
|
||||
topicMatches++;
|
||||
}
|
||||
}
|
||||
const topicScore = context.topics.length > 0
|
||||
? topicMatches / context.topics.length
|
||||
: 0;
|
||||
totalScore += topicScore * 0.2;
|
||||
weightSum += 0.2;
|
||||
}
|
||||
|
||||
// 4. Match de sinônimos (peso: 0.1)
|
||||
if (context.synonyms.length > 0) {
|
||||
let synonymMatches = 0;
|
||||
for (const synonym of context.synonyms) {
|
||||
const synonymLower = synonym.toLowerCase();
|
||||
if (normalizedChunk.includes(synonymLower)) {
|
||||
synonymMatches++;
|
||||
}
|
||||
}
|
||||
const synonymScore = context.synonyms.length > 0
|
||||
? Math.min(synonymMatches / context.synonyms.length, 0.5) // Cap em 0.5 para não dominar
|
||||
: 0;
|
||||
totalScore += synonymScore * 0.1;
|
||||
weightSum += 0.1;
|
||||
}
|
||||
|
||||
// 5. Relevância contextual (peso: 0.1)
|
||||
// Verificar se contexto temporal/geográfico aparece no chunk
|
||||
let contextualScore = 0;
|
||||
if (context.temporalContext?.period) {
|
||||
if (normalizedChunk.includes(context.temporalContext.period)) {
|
||||
contextualScore += 0.5;
|
||||
}
|
||||
}
|
||||
if (context.temporalContext?.relative) {
|
||||
const relativeTerms = ['recente', 'atual', 'novo', 'último', 'atualizado'];
|
||||
if (relativeTerms.some(term => normalizedChunk.includes(term))) {
|
||||
contextualScore += 0.3;
|
||||
}
|
||||
}
|
||||
if (context.geographicContext?.location) {
|
||||
const locationLower = context.geographicContext.location.toLowerCase();
|
||||
if (normalizedChunk.includes(locationLower)) {
|
||||
contextualScore += 0.5;
|
||||
}
|
||||
}
|
||||
totalScore += Math.min(contextualScore, 1) * 0.1;
|
||||
weightSum += 0.1;
|
||||
|
||||
// Normalizar pelo peso total
|
||||
const finalScore = weightSum > 0 ? totalScore / weightSum : 0;
|
||||
|
||||
// Garantir que score esteja entre 0 e 1
|
||||
return Math.min(Math.max(finalScore, 0), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula similaridade combinada (keywords + semântica)
|
||||
*
|
||||
* @param query - Query original
|
||||
* @param context - Contexto analisado (opcional)
|
||||
* @param chunk - Chunk de conteúdo
|
||||
* @returns Score combinado (0-1)
|
||||
*/
|
||||
export function calculateCombinedSimilarity(
|
||||
query: string,
|
||||
chunk: string,
|
||||
context?: QueryContext
|
||||
): number {
|
||||
// Score baseado em keywords
|
||||
const keywordScore = calculateSimilarity(query, chunk);
|
||||
|
||||
// Se temos contexto, calcular score semântico também
|
||||
if (context) {
|
||||
const semanticScore = calculateSemanticSimilarity(context, chunk);
|
||||
|
||||
// Combinar: 60% keywords, 40% semântica
|
||||
return keywordScore * 0.6 + semanticScore * 0.4;
|
||||
}
|
||||
|
||||
return keywordScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula scores de relevância para todos os chunks
|
||||
*
|
||||
* @param chunks - Array de chunks
|
||||
* @param query - Query do usuário
|
||||
* @param context - Contexto analisado (opcional, para ranqueamento semântico)
|
||||
* @returns Chunks com scores atribuídos, ordenados por relevância
|
||||
*/
|
||||
export function scoreChunks(chunks: Chunk[], query: string): Chunk[] {
|
||||
export function scoreChunks(
|
||||
chunks: Chunk[],
|
||||
query: string,
|
||||
context?: QueryContext
|
||||
): Chunk[] {
|
||||
if (!query || chunks.length === 0) {
|
||||
return chunks;
|
||||
}
|
||||
@@ -87,7 +236,9 @@ export function scoreChunks(chunks: Chunk[], query: string): Chunk[] {
|
||||
// Calcular score para cada chunk
|
||||
const scoredChunks = chunks.map(chunk => ({
|
||||
...chunk,
|
||||
score: calculateSimilarity(query, chunk.content),
|
||||
score: context
|
||||
? calculateCombinedSimilarity(query, chunk.content, context)
|
||||
: calculateSimilarity(query, chunk.content),
|
||||
}));
|
||||
|
||||
// Ordenar por score (maior primeiro)
|
||||
@@ -170,16 +321,18 @@ export function filterByMinScore(chunks: Chunk[], minScore: number = 0.1): Chunk
|
||||
* @param query - Query do usuário
|
||||
* @param maxTokens - Limite máximo de tokens
|
||||
* @param minScore - Score mínimo (opcional)
|
||||
* @param context - Contexto analisado (opcional, para ranqueamento semântico)
|
||||
* @returns Chunks selecionados e ordenados por relevância
|
||||
*/
|
||||
export function scoreAndSelectChunks(
|
||||
chunks: Chunk[],
|
||||
query: string,
|
||||
maxTokens: number,
|
||||
minScore?: number
|
||||
minScore?: number,
|
||||
context?: QueryContext
|
||||
): Chunk[] {
|
||||
// Calcular scores
|
||||
let scoredChunks = scoreChunks(chunks, query);
|
||||
let scoredChunks = scoreChunks(chunks, query, context);
|
||||
|
||||
// Filtrar por score mínimo se especificado
|
||||
if (minScore !== undefined) {
|
||||
|
||||
@@ -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,
|
||||
* validação de relevância e fallback para conhecimento interno.
|
||||
*
|
||||
* Agora suporta multi-round semantic search com estratégias diferentes.
|
||||
*/
|
||||
|
||||
import type { ScrapedContent } from '@/services/webSearch';
|
||||
import type { QueryContext } from './contextual-analyzer';
|
||||
import type { EnrichedQueries } from './query-enricher';
|
||||
import { chatLog } from '@/lib/terminal-logger';
|
||||
|
||||
export type SearchStrategy = 'literal' | 'semantic' | 'related' | 'expanded' | 'contextual';
|
||||
|
||||
export interface RoundConfig {
|
||||
strategy: SearchStrategy;
|
||||
queries: string[];
|
||||
minResults: number;
|
||||
}
|
||||
|
||||
export interface FallbackConfig {
|
||||
maxRounds: number; // Máximo de rodadas de busca (padrão: 4)
|
||||
maxResultsPerRound: number; // Máximo de resultados por rodada (padrão: 10)
|
||||
maxTotalResults: number; // Máximo total de resultados analisados (padrão: 40)
|
||||
minRelevanceScore: number; // Score mínimo de relevância (0-1, padrão: 0.3)
|
||||
enableQueryExpansion: boolean; // Ativar expansão de queries (padrão: true)
|
||||
useSemanticSearch?: boolean; // Usar multi-round semantic search (padrão: false)
|
||||
context?: QueryContext; // Contexto analisado (para semantic search)
|
||||
enrichedQueries?: EnrichedQueries; // Queries enriquecidas (para semantic search)
|
||||
}
|
||||
|
||||
export interface FallbackAttempt {
|
||||
round: number;
|
||||
strategy?: SearchStrategy;
|
||||
query: string;
|
||||
queries?: string[]; // Múltiplas queries se usar estratégia
|
||||
results: ScrapedContent[];
|
||||
relevanceScore: number;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FallbackResult {
|
||||
@@ -108,8 +126,9 @@ Query expandida:`;
|
||||
|
||||
/**
|
||||
* Calcula score de relevância de um resultado baseado na query
|
||||
* (Mantido para compatibilidade, mas agora usa função de relevance-scorer)
|
||||
*/
|
||||
export function calculateRelevanceScore(
|
||||
export function calculateRelevanceScoreForFallback(
|
||||
result: ScrapedContent,
|
||||
query: string
|
||||
): number {
|
||||
@@ -167,7 +186,7 @@ export function areResultsRelevant(
|
||||
}
|
||||
|
||||
// Calcular scores de relevância
|
||||
const scores = results.map(r => calculateRelevanceScore(r, query));
|
||||
const scores = results.map(r => calculateRelevanceScoreForFallback(r, query));
|
||||
|
||||
// Verificar se pelo menos um resultado tem score suficiente
|
||||
const maxScore = Math.max(...scores);
|
||||
@@ -181,8 +200,66 @@ export function areResultsRelevant(
|
||||
return maxScore >= minScore || avgScore >= (minScore * 0.7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria configuração de rounds para multi-round semantic search
|
||||
*/
|
||||
function createSemanticRounds(
|
||||
enriched: EnrichedQueries,
|
||||
originalQuery: string
|
||||
): RoundConfig[] {
|
||||
const rounds: RoundConfig[] = [];
|
||||
|
||||
// Round 1: Literal + Semântico (mais direto)
|
||||
if (enriched.literal.length > 0 || enriched.semantic.length > 0) {
|
||||
rounds.push({
|
||||
strategy: 'literal',
|
||||
queries: [...enriched.literal, ...enriched.semantic].slice(0, 5),
|
||||
minResults: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// Round 2: Relacionadas (se Round 1 não foi suficiente)
|
||||
if (enriched.related.length > 0) {
|
||||
rounds.push({
|
||||
strategy: 'related',
|
||||
queries: enriched.related.slice(0, 4),
|
||||
minResults: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Round 3: Contextual (adicionar contexto temporal/geográfico)
|
||||
if (enriched.contextual.length > 0) {
|
||||
rounds.push({
|
||||
strategy: 'contextual',
|
||||
queries: enriched.contextual.slice(0, 3),
|
||||
minResults: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Round 4: Expandido (temas específicos, exemplos concretos)
|
||||
if (enriched.expanded.length > 0) {
|
||||
rounds.push({
|
||||
strategy: 'expanded',
|
||||
queries: enriched.expanded.slice(0, 3),
|
||||
minResults: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: se não há queries enriquecidas, usar original
|
||||
if (rounds.length === 0) {
|
||||
rounds.push({
|
||||
strategy: 'literal',
|
||||
queries: [originalQuery],
|
||||
minResults: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return rounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa busca progressiva com fallback
|
||||
* Agora suporta multi-round semantic search com estratégias diferentes
|
||||
*/
|
||||
export async function executeProgressiveSearch(
|
||||
originalQuery: string,
|
||||
@@ -198,21 +275,63 @@ export async function executeProgressiveSearch(
|
||||
|
||||
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
||||
chatLog.info(`Original Query: "${originalQuery}"`);
|
||||
chatLog.info(`Config: ${JSON.stringify(finalConfig)}`);
|
||||
chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`);
|
||||
|
||||
for (let round = 1; round <= finalConfig.maxRounds; round++) {
|
||||
chatLog.info(`\n--- Round ${round}/${finalConfig.maxRounds} ---`);
|
||||
// Se usar semantic search, criar rounds baseados em estratégias
|
||||
let rounds: Array<{ round: number; config: RoundConfig }> = [];
|
||||
|
||||
if (finalConfig.useSemanticSearch && finalConfig.enrichedQueries) {
|
||||
const semanticRounds = createSemanticRounds(finalConfig.enrichedQueries, originalQuery);
|
||||
rounds = semanticRounds.map((roundConfig, idx) => ({
|
||||
round: idx + 1,
|
||||
config: roundConfig,
|
||||
}));
|
||||
chatLog.info(`[SemanticSearch] Created ${rounds.length} semantic rounds with strategies`);
|
||||
} else {
|
||||
// Modo tradicional: rounds sequenciais com expansão
|
||||
for (let i = 1; i <= finalConfig.maxRounds; i++) {
|
||||
rounds.push({
|
||||
round: i,
|
||||
config: {
|
||||
strategy: 'literal',
|
||||
queries: [originalQuery],
|
||||
minResults: finalConfig.maxResultsPerRound,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Limitar número de rounds
|
||||
rounds = rounds.slice(0, finalConfig.maxRounds);
|
||||
|
||||
for (const { round, config: roundConfig } of rounds) {
|
||||
chatLog.info(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`);
|
||||
|
||||
// Expandir query se necessário
|
||||
const query = finalConfig.enableQueryExpansion
|
||||
? await expandQuery(originalQuery, model, round)
|
||||
: originalQuery;
|
||||
const roundStartTime = Date.now();
|
||||
let roundResults: ScrapedContent[] = [];
|
||||
let roundQueries: string[] = [];
|
||||
|
||||
chatLog.info(`Searching with query: "${query}"`);
|
||||
// Executar todas as queries do round em paralelo
|
||||
const queryPromises = roundConfig.queries.map(async (query) => {
|
||||
// Expandir query se necessário (modo tradicional)
|
||||
const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch
|
||||
? await expandQuery(query, model, round)
|
||||
: query;
|
||||
|
||||
try {
|
||||
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound);
|
||||
return { query: finalQuery, results };
|
||||
} catch (error) {
|
||||
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, error);
|
||||
return { query: finalQuery, results: [] };
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Executar busca
|
||||
const results = await searchFn(query, finalConfig.maxResultsPerRound);
|
||||
const queryResults = await Promise.all(queryPromises);
|
||||
|
||||
// Agregar resultados de todas as queries do round
|
||||
for (const { query, results } of queryResults) {
|
||||
roundQueries.push(query);
|
||||
|
||||
// Filtrar duplicatas
|
||||
const uniqueResults = results.filter(r => {
|
||||
@@ -223,61 +342,78 @@ export async function executeProgressiveSearch(
|
||||
return true;
|
||||
});
|
||||
|
||||
chatLog.info(`Found ${uniqueResults.length} unique results (${results.length} total)`);
|
||||
|
||||
// Calcular relevância
|
||||
const relevanceScore = uniqueResults.length > 0
|
||||
? uniqueResults.reduce((sum, r) => sum + calculateRelevanceScore(r, query), 0) / uniqueResults.length
|
||||
: 0;
|
||||
|
||||
// Registrar tentativa
|
||||
const attempt: FallbackAttempt = {
|
||||
round,
|
||||
query,
|
||||
results: uniqueResults,
|
||||
relevanceScore,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
attempts.push(attempt);
|
||||
|
||||
allResults.push(...uniqueResults);
|
||||
totalResultsAnalyzed += uniqueResults.length;
|
||||
|
||||
// Verificar se encontrou resultados relevantes
|
||||
if (areResultsRelevant(uniqueResults, query, finalConfig.minRelevanceScore)) {
|
||||
chatLog.info(`✓ Relevant results found in round ${round}!`);
|
||||
chatLog.info(`Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
knowledgeBaseContext: '', // Será preenchido pelo caller
|
||||
scrapedSources: allResults,
|
||||
attempts,
|
||||
totalResultsAnalyzed,
|
||||
usedFallback: false,
|
||||
finalQuery: query,
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar se atingiu limite de resultados
|
||||
if (totalResultsAnalyzed >= finalConfig.maxTotalResults) {
|
||||
chatLog.warn(`⚠️ Reached max total results limit (${finalConfig.maxTotalResults})`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Se não encontrou resultados relevantes, continuar para próxima rodada
|
||||
chatLog.warn(`⚠️ Round ${round} did not find relevant results, continuing...`);
|
||||
|
||||
} catch (error) {
|
||||
chatLog.error(`✗ Error in round ${round}:`, error);
|
||||
attempts.push({
|
||||
round,
|
||||
query,
|
||||
results: [],
|
||||
relevanceScore: 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
roundResults.push(...uniqueResults);
|
||||
}
|
||||
|
||||
const roundDuration = Date.now() - roundStartTime;
|
||||
|
||||
chatLog.info(`[Round ${round}] Executed ${roundQueries.length} queries, found ${roundResults.length} unique results in ${roundDuration}ms`);
|
||||
|
||||
// Log detalhado se houver resultados
|
||||
if (roundResults.length > 0) {
|
||||
roundResults.slice(0, 3).forEach((result, idx) => {
|
||||
chatLog.info(` → Result ${idx + 1}: ${result.title.substring(0, 60)}... (${result.url.substring(0, 50)}...)`);
|
||||
});
|
||||
if (roundResults.length > 3) {
|
||||
chatLog.info(` ... and ${roundResults.length - 3} more results`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular relevância usando contexto se disponível
|
||||
const relevanceScore = roundResults.length > 0
|
||||
? roundResults.reduce((sum, r) => {
|
||||
// Usar contexto para cálculo de relevância se disponível
|
||||
if (finalConfig.context) {
|
||||
// Importar função dinamicamente para evitar circular dependency
|
||||
const { calculateSemanticSimilarity } = require('./relevance-scorer');
|
||||
return sum + calculateSemanticSimilarity(finalConfig.context, r.markdown || r.title);
|
||||
}
|
||||
return sum + calculateRelevanceScoreForFallback(r, originalQuery);
|
||||
}, 0) / roundResults.length
|
||||
: 0;
|
||||
|
||||
// Registrar tentativa
|
||||
const attempt: FallbackAttempt = {
|
||||
round,
|
||||
strategy: roundConfig.strategy,
|
||||
query: roundQueries[0] || originalQuery,
|
||||
queries: roundQueries.length > 1 ? roundQueries : undefined,
|
||||
results: roundResults,
|
||||
relevanceScore,
|
||||
timestamp: Date.now(),
|
||||
duration: roundDuration,
|
||||
};
|
||||
attempts.push(attempt);
|
||||
|
||||
allResults.push(...roundResults);
|
||||
totalResultsAnalyzed += roundResults.length;
|
||||
|
||||
// Verificar se encontrou resultados relevantes
|
||||
const queryForRelevance = roundQueries[0] || originalQuery;
|
||||
if (areResultsRelevant(roundResults, queryForRelevance, finalConfig.minRelevanceScore)) {
|
||||
chatLog.info(`[Round ${round}] ✓ Relevant results found!`);
|
||||
chatLog.info(`[Round ${round}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
|
||||
chatLog.info(`[Round ${round}] Final queries: ${roundQueries.join(', ')}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
knowledgeBaseContext: '', // Será preenchido pelo caller
|
||||
scrapedSources: allResults,
|
||||
attempts,
|
||||
totalResultsAnalyzed,
|
||||
usedFallback: false,
|
||||
finalQuery: roundQueries.join(' | '),
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar se atingiu limite de resultados
|
||||
if (totalResultsAnalyzed >= finalConfig.maxTotalResults) {
|
||||
chatLog.warn(`[Round ${round}] ⚠️ Reached max total results limit (${finalConfig.maxTotalResults})`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Se não encontrou resultados relevantes, continuar para próxima rodada
|
||||
chatLog.warn(`[Round ${round}] ⚠️ Did not find relevant results (min score: ${finalConfig.minRelevanceScore}), continuing...`);
|
||||
}
|
||||
|
||||
// Se chegou aqui, não encontrou resultados relevantes
|
||||
|
||||
+120
-32
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { SearchCategory } from '@/store/settings-store';
|
||||
import { SearchCategory, useSettingsStore } from '@/store/settings-store';
|
||||
import { retryWithBackoff, withTimeout, isRetryableError, type RetryConfig } from '@/lib/retry-utils';
|
||||
|
||||
export interface ScrapedContent {
|
||||
title: string;
|
||||
@@ -224,35 +225,101 @@ class WebSearchService {
|
||||
private async executeMetadataSearch(
|
||||
query: string,
|
||||
limit: number = 5,
|
||||
searchConfig?: SearchConfig
|
||||
searchConfig?: SearchConfig,
|
||||
timeoutMs: number = 15000
|
||||
): Promise<SearchResultMetadata[]> {
|
||||
try {
|
||||
let rustConfig: any = undefined;
|
||||
if (searchConfig) {
|
||||
rustConfig = {
|
||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||
categories: searchConfig.categories.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
base_sites: cat.baseSites,
|
||||
enabled: cat.enabled,
|
||||
})),
|
||||
user_custom_sites: searchConfig.userCustomSites,
|
||||
excluded_domains: searchConfig.excludedDomains,
|
||||
};
|
||||
}
|
||||
|
||||
const results = await invoke<SearchResultMetadata[]>('search_web_metadata', {
|
||||
query: query.trim(),
|
||||
limit,
|
||||
search_config: rustConfig,
|
||||
});
|
||||
return results || [];
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar metadados:', error);
|
||||
throw error instanceof Error ? error : new Error('Falha ao buscar metadados');
|
||||
// Obter configurações de motores do store
|
||||
const settings = useSettingsStore.getState();
|
||||
const engineOrder = settings.webSearch.engineOrder || ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
|
||||
|
||||
let rustConfig: any = undefined;
|
||||
if (searchConfig) {
|
||||
rustConfig = {
|
||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||
categories: searchConfig.categories.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
base_sites: cat.baseSites,
|
||||
enabled: cat.enabled,
|
||||
})),
|
||||
user_custom_sites: searchConfig.userCustomSites,
|
||||
excluded_domains: searchConfig.excludedDomains,
|
||||
};
|
||||
}
|
||||
|
||||
// Log da tentativa
|
||||
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
||||
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
|
||||
console.log(`[WebSearch] Limit: ${limit}, Timeout: ${timeoutMs}ms`);
|
||||
|
||||
// Executar com retry e timeout
|
||||
const retryResult = await retryWithBackoff<SearchResultMetadata[]>(
|
||||
async () => {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const promise = invoke<SearchResultMetadata[]>('search_web_metadata', {
|
||||
query: query.trim(),
|
||||
limit,
|
||||
search_config: rustConfig,
|
||||
engineOrder: engineOrder, // Passar ordem de motores
|
||||
});
|
||||
|
||||
const result = await withTimeout(
|
||||
promise,
|
||||
timeoutMs,
|
||||
`Timeout ao buscar metadados para "${query}"`
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[WebSearch] Erro após ${duration}ms:`, errorMsg);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
if (retryResult.success && retryResult.result) {
|
||||
const results = retryResult.result || [];
|
||||
if (results.length === 0) {
|
||||
console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`);
|
||||
console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}, Motores testados: ${engineOrder.join(', ')}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Se falhou mas é erro recuperável, logar detalhadamente
|
||||
if (retryResult.lastError && isRetryableError(retryResult.lastError)) {
|
||||
console.warn(
|
||||
`[WebSearch] Falha após ${retryResult.attempts} tentativas:`,
|
||||
retryResult.lastError.message
|
||||
);
|
||||
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`);
|
||||
console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Para erros não recuperáveis, logar detalhadamente
|
||||
if (retryResult.lastError) {
|
||||
console.error(
|
||||
`[WebSearch] Erro não recuperável:`,
|
||||
retryResult.lastError.message
|
||||
);
|
||||
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`);
|
||||
console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,22 +407,43 @@ class WebSearchService {
|
||||
async smartSearchRag(
|
||||
query: string,
|
||||
limit: number = 3,
|
||||
searchConfig?: SearchConfig
|
||||
searchConfig?: SearchConfig,
|
||||
timeoutMs: number = 15000
|
||||
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
||||
if (!query || !query.trim()) {
|
||||
return { metadata: [], contents: [] };
|
||||
}
|
||||
|
||||
const metas = await this.executeMetadataSearch(query, Math.max(limit * 2, 5), searchConfig);
|
||||
// Buscar metadados com retry e timeout (já implementado em executeMetadataSearch)
|
||||
const metas = await this.executeMetadataSearch(
|
||||
query,
|
||||
Math.max(limit * 2, 5),
|
||||
searchConfig,
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
||||
const topUrls = metas.map(m => m.url).slice(0, limit);
|
||||
|
||||
if (topUrls.length === 0) {
|
||||
// Retornar resultados parciais (metadados vazios mas sem erro)
|
||||
return { metadata: metas, contents: [] };
|
||||
}
|
||||
|
||||
// Tentar fazer scraping mesmo se metadados foram parciais
|
||||
try {
|
||||
const contents = await invoke<ScrapedContent[]>('scrape_urls', { urls: topUrls });
|
||||
const contents = await withTimeout(
|
||||
invoke<ScrapedContent[]>('scrape_urls', { urls: topUrls }),
|
||||
timeoutMs,
|
||||
`Timeout ao fazer scraping para "${query}"`
|
||||
);
|
||||
return { metadata: metas, contents: contents || [] };
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer scraping em lote:', error);
|
||||
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn('Erro ao fazer scraping em lote (continuando com metadados):', errorMessage);
|
||||
|
||||
// Retornar metadados mesmo se scraping falhou
|
||||
return { metadata: metas, contents: [] };
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+249
-12
@@ -114,6 +114,7 @@ dependencies = [
|
||||
"readability",
|
||||
"regex",
|
||||
"reqwest 0.12.24",
|
||||
"rfd",
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -139,6 +140,24 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd884d7c72877a94102c3715f3b1cd09ff4fac28221add3e57cfbe25c236d093"
|
||||
dependencies = [
|
||||
"async-fs",
|
||||
"async-net",
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"url",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -177,6 +196,17 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-fs"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -206,6 +236,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-net"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
@@ -375,6 +416,12 @@ dependencies = [
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -2579,6 +2626,15 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.11.0"
|
||||
@@ -2758,6 +2814,19 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@@ -2798,7 +2867,7 @@ dependencies = [
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
"zbus 5.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2867,6 +2936,26 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||
dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-foundation"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||
dependencies = [
|
||||
"block",
|
||||
"objc",
|
||||
"objc_id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
@@ -3141,6 +3230,15 @@ dependencies = [
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_id"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
|
||||
dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -3531,6 +3629,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -3990,6 +4094,29 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25a73a7337fc24366edfca76ec521f51877b114e42dab584008209cca6719251"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block",
|
||||
"dispatch",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"objc_id",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -6728,6 +6855,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml5ever"
|
||||
version = "0.17.0"
|
||||
@@ -6762,6 +6899,44 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-fs",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"ordered-stream",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
"static_assertions",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros 4.4.0",
|
||||
"zbus_names 3.0.0",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.12.0"
|
||||
@@ -6782,7 +6957,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"ordered-stream",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
@@ -6791,9 +6966,22 @@ dependencies = [
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.13",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 5.12.0",
|
||||
"zbus_names 4.2.0",
|
||||
"zvariant 5.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6806,9 +6994,20 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
"zbus_names 4.2.0",
|
||||
"zvariant 5.8.0",
|
||||
"zvariant_utils 3.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6820,7 +7019,7 @@ dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"winnow 0.7.13",
|
||||
"zvariant",
|
||||
"zvariant 5.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6952,6 +7151,20 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"url",
|
||||
"zvariant_derive 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.8.0"
|
||||
@@ -6962,8 +7175,21 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 0.7.13",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
"zvariant_derive 5.8.0",
|
||||
"zvariant_utils 3.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6976,7 +7202,18 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 3.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -45,3 +45,4 @@ regex = "1.10"
|
||||
walkdir = "2.5"
|
||||
tauri-plugin-autostart = "2"
|
||||
dirs = "5.0"
|
||||
rfd = "0.14"
|
||||
+270
-16
@@ -30,13 +30,15 @@ use web_scraper::{
|
||||
scrape_url,
|
||||
SearchConfig,
|
||||
search_duckduckgo_metadata,
|
||||
search_multi_engine_metadata,
|
||||
SearchEngine,
|
||||
smart_search,
|
||||
scrape_urls_bulk,
|
||||
};
|
||||
use headless_chrome::Browser;
|
||||
use scheduler::{SentinelTask, SchedulerService, SchedulerState, TaskAction};
|
||||
use sources_config::{SourcesConfig, load_sources_config, save_sources_config};
|
||||
use system_monitor::{SystemStats, SystemMonitorState, GpuInfo};
|
||||
use system_monitor::{SystemStats, SystemMonitorState, GpuInfo, GpuStats};
|
||||
|
||||
// CommandExt é importado localmente onde necessário
|
||||
|
||||
@@ -591,6 +593,168 @@ fn check_if_model_installed(name: String) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Instala um modelo GGUF a partir de um arquivo local
|
||||
#[command]
|
||||
async fn install_gguf_model(
|
||||
app_handle: AppHandle,
|
||||
file_path: String,
|
||||
model_name: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let source_path = Path::new(&file_path);
|
||||
|
||||
// Validar que o arquivo existe
|
||||
if !source_path.exists() {
|
||||
return Err("Arquivo não encontrado".to_string());
|
||||
}
|
||||
|
||||
// Validar extensão (mas aceitar arquivos sem extensão também)
|
||||
let is_gguf = if let Some(ext) = source_path.extension() {
|
||||
ext.to_string_lossy().to_lowercase() == "gguf"
|
||||
} else {
|
||||
// Arquivo sem extensão - verificar pelo tamanho (modelos GGUF são grandes)
|
||||
let metadata = fs::metadata(source_path)
|
||||
.map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?;
|
||||
metadata.len() >= 50 * 1024 * 1024 // Pelo menos 50MB
|
||||
};
|
||||
|
||||
if !is_gguf {
|
||||
// Verificar se é um arquivo grande sem extensão (pode ser GGUF)
|
||||
let metadata = fs::metadata(source_path)
|
||||
.map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?;
|
||||
if metadata.len() < 50 * 1024 * 1024 {
|
||||
return Err("Arquivo muito pequeno ou não é um modelo GGUF válido".to_string());
|
||||
}
|
||||
// Se for grande o suficiente, aceitar mesmo sem extensão
|
||||
}
|
||||
|
||||
// Validar tamanho mínimo (100MB)
|
||||
let metadata = fs::metadata(source_path)
|
||||
.map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?;
|
||||
let min_size = 100 * 1024 * 1024; // 100MB
|
||||
if metadata.len() < min_size {
|
||||
return Err("Arquivo muito pequeno. Modelos GGUF geralmente têm pelo menos 100MB".to_string());
|
||||
}
|
||||
|
||||
// Determinar nome do modelo
|
||||
let final_model_name = if let Some(name) = model_name {
|
||||
name.trim().to_string()
|
||||
} else {
|
||||
// Extrair nome do arquivo sem extensão
|
||||
source_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("model")
|
||||
.to_string()
|
||||
};
|
||||
|
||||
if final_model_name.is_empty() {
|
||||
return Err("Nome do modelo não pode estar vazio".to_string());
|
||||
}
|
||||
|
||||
// Obter diretório de modelos do Ollama
|
||||
// Ollama armazena modelos em ~/.ollama/models (Linux/Mac) ou %USERPROFILE%\.ollama\models (Windows)
|
||||
let models_dir = dirs::home_dir()
|
||||
.ok_or_else(|| "Não foi possível determinar diretório home".to_string())?
|
||||
.join(".ollama")
|
||||
.join("models");
|
||||
|
||||
// Criar diretório se não existir
|
||||
fs::create_dir_all(&models_dir)
|
||||
.map_err(|e| format!("Erro ao criar diretório de modelos: {}", e))?;
|
||||
|
||||
// Criar diretório para o modelo específico
|
||||
let model_dir = models_dir.join(&final_model_name);
|
||||
fs::create_dir_all(&model_dir)
|
||||
.map_err(|e| format!("Erro ao criar diretório do modelo: {}", e))?;
|
||||
|
||||
// Nome do arquivo de destino (usar nome do modelo + .gguf)
|
||||
let dest_file = model_dir.join(format!("{}.gguf", final_model_name));
|
||||
|
||||
// Copiar arquivo
|
||||
log::info!("Copiando arquivo GGUF de {} para {}", file_path, dest_file.display());
|
||||
fs::copy(source_path, &dest_file)
|
||||
.map_err(|e| format!("Erro ao copiar arquivo: {}", e))?;
|
||||
|
||||
log::info!("Arquivo copiado com sucesso. Tentando registrar no Ollama...");
|
||||
|
||||
// Tentar criar Modelfile e importar modelo no Ollama
|
||||
// Ollama pode importar modelos GGUF usando: ollama create <name> -f <modelfile>
|
||||
// Mas para GGUF direto, podemos usar: ollama create <name> --file <path>
|
||||
// Ou simplesmente copiar para o diretório e o Ollama detecta automaticamente
|
||||
|
||||
// Tentar criar Modelfile e registrar modelo no Ollama
|
||||
// Ollama requer um Modelfile para criar modelos GGUF
|
||||
let modelfile_path = model_dir.join("Modelfile");
|
||||
let modelfile_content = format!("FROM {}\n", dest_file.display());
|
||||
|
||||
// Escrever Modelfile
|
||||
if let Err(e) = fs::write(&modelfile_path, &modelfile_content) {
|
||||
log::warn!("Erro ao criar Modelfile: {}. Tentando método alternativo...", e);
|
||||
}
|
||||
|
||||
// Tentar usar ollama create com Modelfile
|
||||
let create_output = Command::new("ollama")
|
||||
.arg("create")
|
||||
.arg(&final_model_name)
|
||||
.arg("-f")
|
||||
.arg(&modelfile_path)
|
||||
.output();
|
||||
|
||||
match create_output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
log::info!("Modelo {} registrado com sucesso no Ollama", final_model_name);
|
||||
Ok(final_model_name)
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Se o modelo já existe, ainda consideramos sucesso
|
||||
if stderr.contains("already exists") || stderr.contains("model already exists") {
|
||||
log::info!("Modelo {} já existe no Ollama", final_model_name);
|
||||
Ok(final_model_name)
|
||||
} else {
|
||||
// Tentar método alternativo: usar FROM diretamente
|
||||
log::warn!("Primeira tentativa falhou: {}. Tentando método alternativo...", stderr);
|
||||
|
||||
// Método alternativo: criar modelo usando FROM diretamente
|
||||
let alt_output = Command::new("ollama")
|
||||
.arg("create")
|
||||
.arg(&final_model_name)
|
||||
.arg("--file")
|
||||
.arg(&dest_file)
|
||||
.output();
|
||||
|
||||
match alt_output {
|
||||
Ok(alt_out) => {
|
||||
if alt_out.status.success() {
|
||||
log::info!("Modelo {} registrado com sucesso (método alternativo)", final_model_name);
|
||||
Ok(final_model_name)
|
||||
} else {
|
||||
let alt_stderr = String::from_utf8_lossy(&alt_out.stderr);
|
||||
// Se falhar, ainda retornamos sucesso pois o arquivo foi copiado
|
||||
log::warn!("Não foi possível registrar modelo automaticamente: {}. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", alt_stderr, dest_file.display(), final_model_name, modelfile_path.display());
|
||||
Ok(final_model_name)
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Se ambos falharem, ainda retornamos sucesso pois o arquivo foi copiado
|
||||
log::warn!("Não foi possível registrar modelo automaticamente. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", dest_file.display(), final_model_name, modelfile_path.display());
|
||||
Ok(final_model_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Se ollama create falhar, ainda retornamos sucesso pois o arquivo foi copiado
|
||||
// O usuário pode registrar manualmente depois
|
||||
log::warn!("Não foi possível registrar modelo automaticamente: {}. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", e, dest_file.display(), final_model_name, modelfile_path.display());
|
||||
Ok(final_model_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Função auxiliar para ler linha até encontrar \r ou \n (mantida para fallback)
|
||||
#[allow(dead_code)]
|
||||
fn read_line_until_delimiter<R: Read>(reader: &mut BufReader<R>, buffer: &mut Vec<u8>) -> Result<usize, std::io::Error> {
|
||||
@@ -1599,6 +1763,7 @@ async fn search_web_metadata(
|
||||
query: String,
|
||||
limit: Option<usize>,
|
||||
search_config: Option<SearchConfig>,
|
||||
engine_order: Option<Vec<String>>,
|
||||
) -> Result<Vec<SearchResultMetadata>, String> {
|
||||
if query.trim().is_empty() {
|
||||
return Err("Query não pode estar vazia".to_string());
|
||||
@@ -1606,23 +1771,63 @@ async fn search_web_metadata(
|
||||
|
||||
let lim = limit.unwrap_or(5);
|
||||
|
||||
if let Some(config) = search_config {
|
||||
// Usar smart_search para obter URLs e convertê-las em metadados simples
|
||||
match smart_search(&query, &config).await {
|
||||
Ok(mut urls) => {
|
||||
urls.truncate(lim);
|
||||
let metas = urls
|
||||
.into_iter()
|
||||
.map(|u| SearchResultMetadata { title: u.clone(), url: u, snippet: String::new() })
|
||||
.collect::<Vec<_>>();
|
||||
Ok(metas)
|
||||
}
|
||||
Err(e) => Err(format!("Erro ao executar smart_search: {}", e)),
|
||||
}
|
||||
// Converter engine_order (strings) para Vec<SearchEngine>
|
||||
let engines: Vec<SearchEngine> = if let Some(order) = engine_order {
|
||||
order.iter()
|
||||
.filter_map(|s| SearchEngine::from_str(s))
|
||||
.collect()
|
||||
} else {
|
||||
search_duckduckgo_metadata(&query, lim)
|
||||
// Ordem padrão: Google primeiro, depois outros
|
||||
vec![
|
||||
SearchEngine::Google,
|
||||
SearchEngine::Bing,
|
||||
SearchEngine::Yahoo,
|
||||
SearchEngine::DuckDuckGo,
|
||||
SearchEngine::Startpage,
|
||||
]
|
||||
};
|
||||
|
||||
// Se não há engines configuradas, usar DuckDuckGo como fallback
|
||||
if engines.is_empty() {
|
||||
log::warn!("No valid engines in order, using DuckDuckGo as fallback");
|
||||
return search_duckduckgo_metadata(&query, lim)
|
||||
.await
|
||||
.map_err(|e| format!("Erro ao buscar metadados: {}", e))
|
||||
.map_err(|e| format!("Erro ao buscar metadados: {}", e));
|
||||
}
|
||||
|
||||
// Usar multi-engine search
|
||||
let min_results = 1; // Mínimo de 1 resultado para considerar sucesso
|
||||
match search_multi_engine_metadata(&query, lim, &engines, min_results).await {
|
||||
Ok(results) => {
|
||||
if results.is_empty() && search_config.is_some() {
|
||||
// Fallback para smart_search se multi-engine retornou vazio
|
||||
log::info!("Multi-engine returned empty, trying smart_search fallback");
|
||||
if let Some(config) = search_config {
|
||||
match smart_search(&query, &config).await {
|
||||
Ok(mut urls) => {
|
||||
urls.truncate(lim);
|
||||
let metas = urls
|
||||
.into_iter()
|
||||
.map(|u| SearchResultMetadata { title: u.clone(), url: u, snippet: String::new() })
|
||||
.collect::<Vec<_>>();
|
||||
Ok(metas)
|
||||
}
|
||||
Err(e) => Err(format!("Erro ao executar smart_search: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Ok(results)
|
||||
}
|
||||
} else {
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Se multi-engine falhou completamente, tentar DuckDuckGo como último recurso
|
||||
log::warn!("Multi-engine search failed: {}, trying DuckDuckGo fallback", e);
|
||||
search_duckduckgo_metadata(&query, lim)
|
||||
.await
|
||||
.map_err(|e| format!("Erro ao buscar metadados: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1849,6 +2054,45 @@ fn get_app_data_dir(app_handle: AppHandle) -> Result<String, String> {
|
||||
Ok(format!("{}", app_data_dir.display()))
|
||||
}
|
||||
|
||||
/// Salva um arquivo temporário e retorna o caminho
|
||||
#[command]
|
||||
fn save_temp_file(app_handle: AppHandle, data: Vec<u8>, extension: String) -> Result<String, String> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Obter diretório temporário
|
||||
let temp_dir = std::env::temp_dir();
|
||||
|
||||
// Criar nome de arquivo único
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
let filename = format!("ollama_model_{}.{}", timestamp, extension);
|
||||
let temp_path = temp_dir.join(&filename);
|
||||
|
||||
// Escrever arquivo
|
||||
fs::write(&temp_path, data)
|
||||
.map_err(|e| format!("Erro ao salvar arquivo temporário: {}", e))?;
|
||||
|
||||
Ok(temp_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Abre um dialog de seleção de arquivo GGUF usando dialog nativo do sistema
|
||||
#[command]
|
||||
async fn open_gguf_file_dialog() -> Result<Option<String>, String> {
|
||||
use rfd::FileDialog;
|
||||
|
||||
// No rfd, o filtro "*" não funciona corretamente no Windows.
|
||||
// Para garantir que todos os arquivos sejam mostrados, vamos criar
|
||||
// um dialog sem filtro algum. O dialog nativo do Windows mostrará
|
||||
// todos os arquivos por padrão quando não há filtro.
|
||||
let file = FileDialog::new()
|
||||
.set_title("Selecionar modelo GGUF")
|
||||
.pick_file();
|
||||
|
||||
Ok(file.map(|p| p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
// ========== Sources Config Commands ==========
|
||||
|
||||
/// Carrega a configuração de fontes de busca
|
||||
@@ -2264,6 +2508,12 @@ fn get_system_stats(
|
||||
Ok(monitor.get_stats())
|
||||
}
|
||||
|
||||
/// Obtém estatísticas detalhadas de uma GPU específica
|
||||
#[command]
|
||||
fn get_gpu_stats(gpu_id: Option<String>) -> Result<Option<GpuStats>, String> {
|
||||
Ok(system_monitor::get_gpu_stats(gpu_id.as_deref()))
|
||||
}
|
||||
|
||||
// ========== Task Scheduler Commands ==========
|
||||
|
||||
#[command]
|
||||
@@ -2434,8 +2684,12 @@ pub fn run() {
|
||||
get_operating_system,
|
||||
check_if_model_installed,
|
||||
pull_model,
|
||||
install_gguf_model,
|
||||
save_temp_file,
|
||||
open_gguf_file_dialog,
|
||||
start_ollama_server,
|
||||
start_system_monitor,
|
||||
get_gpu_stats,
|
||||
list_local_models,
|
||||
delete_model,
|
||||
save_chat_session,
|
||||
|
||||
@@ -10,6 +10,36 @@ pub struct GpuInfo {
|
||||
pub memory_mb: Option<u64>,
|
||||
}
|
||||
|
||||
/// Estatísticas detalhadas de uma GPU
|
||||
#[derive(Serialize, Clone, Debug, Default)]
|
||||
pub struct GpuStats {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub vendor: Option<String>,
|
||||
// Memória VRAM
|
||||
pub vram_used_mb: Option<u64>,
|
||||
pub vram_total_mb: Option<u64>,
|
||||
pub vram_percent: Option<f32>,
|
||||
// Uso de processamento
|
||||
pub compute_usage_percent: Option<f32>,
|
||||
pub graphics_usage_percent: Option<f32>,
|
||||
pub overall_usage_percent: Option<f32>,
|
||||
// Temperatura
|
||||
pub temperature_celsius: Option<f32>,
|
||||
pub temperature_max_celsius: Option<f32>,
|
||||
// Energia
|
||||
pub power_watts: Option<f32>,
|
||||
pub power_max_watts: Option<f32>,
|
||||
// Ventilador
|
||||
pub fan_speed_rpm: Option<u32>,
|
||||
pub fan_speed_percent: Option<f32>,
|
||||
// Processos
|
||||
pub processes_count: Option<usize>,
|
||||
// Driver/API
|
||||
pub driver_version: Option<String>,
|
||||
pub api: Option<String>, // CUDA, Vulkan, OpenCL, etc.
|
||||
}
|
||||
|
||||
/// Estatísticas do sistema em tempo real
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct SystemStats {
|
||||
@@ -615,3 +645,158 @@ fn parse_memory_string(s: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Obtém estatísticas detalhadas de uma GPU específica
|
||||
pub fn get_gpu_stats(gpu_id: Option<&str>) -> Option<GpuStats> {
|
||||
let gpus = detect_all_gpus();
|
||||
|
||||
// Se gpu_id fornecido, buscar GPU específica, senão usar primeira GPU
|
||||
let target_gpu = if let Some(id) = gpu_id {
|
||||
gpus.iter().find(|g| g.id == id)
|
||||
} else {
|
||||
gpus.first()
|
||||
}?;
|
||||
|
||||
// Tentar obter stats detalhados baseado no vendor
|
||||
if let Some(vendor) = &target_gpu.vendor {
|
||||
match vendor.as_str() {
|
||||
"NVIDIA" => get_nvidia_gpu_stats(target_gpu),
|
||||
"AMD" => get_amd_gpu_stats(target_gpu),
|
||||
"Intel" => get_intel_gpu_stats(target_gpu),
|
||||
_ => get_generic_gpu_stats(target_gpu),
|
||||
}
|
||||
} else {
|
||||
get_generic_gpu_stats(target_gpu)
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtém estatísticas detalhadas de GPU NVIDIA via nvidia-smi
|
||||
fn get_nvidia_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
|
||||
use std::process::Command;
|
||||
|
||||
log::info!("Coletando stats detalhados da GPU NVIDIA: {}", gpu.name);
|
||||
|
||||
// Query nvidia-smi para obter todas as métricas
|
||||
let query = "name,memory.used,memory.total,utilization.gpu,utilization.memory,temperature.gpu,temperature.max,power.draw,power.limit,fan.speed,driver_version";
|
||||
|
||||
let output = Command::new("nvidia-smi")
|
||||
.args(&[
|
||||
"--query-gpu", query,
|
||||
"--format=csv,noheader,nounits"
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
log::warn!("nvidia-smi falhou ao coletar stats");
|
||||
return get_generic_gpu_stats(gpu);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
||||
let line = stdout.lines().next()?;
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
|
||||
if parts.len() < 11 {
|
||||
log::warn!("nvidia-smi retornou formato inesperado");
|
||||
return get_generic_gpu_stats(gpu);
|
||||
}
|
||||
|
||||
// Parse dos valores
|
||||
let vram_used_mb = parts[1].parse::<u64>().ok();
|
||||
let vram_total_mb = parts[2].parse::<u64>().ok();
|
||||
let vram_percent = if let (Some(used), Some(total)) = (vram_used_mb, vram_total_mb) {
|
||||
if total > 0 {
|
||||
Some((used as f32 / total as f32) * 100.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let compute_usage_percent = parts[3].parse::<f32>().ok();
|
||||
let memory_usage_percent = parts[4].parse::<f32>().ok();
|
||||
let overall_usage_percent = compute_usage_percent;
|
||||
|
||||
let temperature_celsius = parts[5].parse::<f32>().ok();
|
||||
let temperature_max_celsius = parts[6].parse::<f32>().ok();
|
||||
|
||||
let power_watts = parts[7].parse::<f32>().ok();
|
||||
let power_max_watts = parts[8].parse::<f32>().ok();
|
||||
|
||||
let fan_speed_percent = parts[9].parse::<f32>().ok();
|
||||
let fan_speed_rpm = None; // nvidia-smi não retorna RPM diretamente
|
||||
|
||||
let driver_version = Some(parts[10].to_string());
|
||||
|
||||
// Contar processos usando GPU
|
||||
let processes_count = count_nvidia_gpu_processes().unwrap_or(0);
|
||||
|
||||
Some(GpuStats {
|
||||
id: gpu.id.clone(),
|
||||
name: gpu.name.clone(),
|
||||
vendor: gpu.vendor.clone(),
|
||||
vram_used_mb,
|
||||
vram_total_mb,
|
||||
vram_percent,
|
||||
compute_usage_percent,
|
||||
graphics_usage_percent: compute_usage_percent, // NVIDIA não diferencia
|
||||
overall_usage_percent,
|
||||
temperature_celsius,
|
||||
temperature_max_celsius,
|
||||
power_watts,
|
||||
power_max_watts,
|
||||
fan_speed_rpm,
|
||||
fan_speed_percent,
|
||||
processes_count: Some(processes_count),
|
||||
driver_version,
|
||||
api: Some("CUDA".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Conta processos usando GPU NVIDIA
|
||||
fn count_nvidia_gpu_processes() -> Result<usize, String> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("nvidia-smi")
|
||||
.args(&["--query-compute-apps=pid", "--format=csv,noheader"])
|
||||
.output()
|
||||
.map_err(|e| format!("nvidia-smi não encontrado: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.map_err(|e| format!("Erro ao parsear output: {}", e))?;
|
||||
|
||||
// Contar linhas não vazias
|
||||
let count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Obtém estatísticas de GPU AMD (implementação básica)
|
||||
fn get_amd_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
|
||||
log::info!("Coletando stats da GPU AMD: {} (suporte limitado)", gpu.name);
|
||||
// AMD requer rocm-smi ou outras ferramentas específicas
|
||||
// Por enquanto, retornar stats genéricos
|
||||
get_generic_gpu_stats(gpu)
|
||||
}
|
||||
|
||||
/// Obtém estatísticas de GPU Intel (implementação básica)
|
||||
fn get_intel_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
|
||||
log::info!("Coletando stats da GPU Intel: {} (suporte limitado)", gpu.name);
|
||||
// Intel requer intel_gpu_top ou outras ferramentas específicas
|
||||
get_generic_gpu_stats(gpu)
|
||||
}
|
||||
|
||||
/// Retorna stats genéricos quando não há suporte específico
|
||||
fn get_generic_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
|
||||
Some(GpuStats {
|
||||
id: gpu.id.clone(),
|
||||
name: gpu.name.clone(),
|
||||
vendor: gpu.vendor.clone(),
|
||||
vram_total_mb: gpu.memory_mb,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use url::Url;
|
||||
use rand::Rng;
|
||||
use tokio::sync::Semaphore;
|
||||
use regex::Regex;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Resultado da extração de conteúdo de uma URL
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
@@ -57,6 +58,178 @@ fn default_total_sources() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
/// Enum para identificar diferentes motores de busca
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SearchEngine {
|
||||
Google,
|
||||
Bing,
|
||||
Yahoo,
|
||||
DuckDuckGo,
|
||||
Startpage,
|
||||
}
|
||||
|
||||
impl SearchEngine {
|
||||
/// Converte string para SearchEngine
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"google" => Some(SearchEngine::Google),
|
||||
"bing" => Some(SearchEngine::Bing),
|
||||
"yahoo" => Some(SearchEngine::Yahoo),
|
||||
"duckduckgo" | "duck_duck_go" => Some(SearchEngine::DuckDuckGo),
|
||||
"startpage" => Some(SearchEngine::Startpage),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retorna nome do motor como string
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SearchEngine::Google => "Google",
|
||||
SearchEngine::Bing => "Bing",
|
||||
SearchEngine::Yahoo => "Yahoo",
|
||||
SearchEngine::DuckDuckGo => "DuckDuckGo",
|
||||
SearchEngine::Startpage => "Startpage",
|
||||
}
|
||||
}
|
||||
|
||||
/// Retorna URL base de busca
|
||||
fn base_url(&self) -> &'static str {
|
||||
match self {
|
||||
SearchEngine::Google => "https://www.google.com/search",
|
||||
SearchEngine::Bing => "https://www.bing.com/search",
|
||||
SearchEngine::Yahoo => "https://search.yahoo.com/search",
|
||||
SearchEngine::DuckDuckGo => "https://html.duckduckgo.com/html",
|
||||
SearchEngine::Startpage => "https://www.startpage.com/sp/search",
|
||||
}
|
||||
}
|
||||
|
||||
/// Retorna selectors CSS específicos para cada motor
|
||||
fn selectors(&self) -> SearchSelectors {
|
||||
match self {
|
||||
SearchEngine::Google => SearchSelectors {
|
||||
container: vec![
|
||||
"div.g",
|
||||
"div[data-ved]",
|
||||
".tF2Cxc",
|
||||
],
|
||||
title: vec![
|
||||
"h3",
|
||||
".LC20lb",
|
||||
".DKV0Md",
|
||||
],
|
||||
url: vec![
|
||||
"a[href]",
|
||||
"cite",
|
||||
],
|
||||
snippet: vec![
|
||||
".VwiC3b",
|
||||
".s",
|
||||
".st",
|
||||
],
|
||||
},
|
||||
SearchEngine::Bing => SearchSelectors {
|
||||
container: vec![
|
||||
".b_algo",
|
||||
"li.b_algo",
|
||||
],
|
||||
title: vec![
|
||||
"h2 a",
|
||||
".b_title a",
|
||||
],
|
||||
url: vec![
|
||||
"h2 a[href]",
|
||||
".b_title a[href]",
|
||||
],
|
||||
snippet: vec![
|
||||
".b_caption p",
|
||||
".b_caption",
|
||||
],
|
||||
},
|
||||
SearchEngine::Yahoo => SearchSelectors {
|
||||
container: vec![
|
||||
".dd.algo",
|
||||
".Sr",
|
||||
],
|
||||
title: vec![
|
||||
"h3 a",
|
||||
".ac-algo h3 a",
|
||||
],
|
||||
url: vec![
|
||||
"h3 a[href]",
|
||||
".ac-algo h3 a[href]",
|
||||
],
|
||||
snippet: vec![
|
||||
".ac-algo .ac-text",
|
||||
".compText",
|
||||
],
|
||||
},
|
||||
SearchEngine::DuckDuckGo => SearchSelectors {
|
||||
container: vec![
|
||||
".result",
|
||||
".web-result",
|
||||
".result__body",
|
||||
],
|
||||
title: vec![
|
||||
".result__a",
|
||||
".web-result__link",
|
||||
"a.result__a",
|
||||
],
|
||||
url: vec![
|
||||
".result__a[href]",
|
||||
".web-result__link[href]",
|
||||
],
|
||||
snippet: vec![
|
||||
".result__snippet",
|
||||
".result__snippet.js-result-snippet",
|
||||
".web-result__snippet",
|
||||
],
|
||||
},
|
||||
SearchEngine::Startpage => SearchSelectors {
|
||||
container: vec![
|
||||
".w-gl__result",
|
||||
".result",
|
||||
],
|
||||
title: vec![
|
||||
".w-gl__result-title a",
|
||||
"h3 a",
|
||||
],
|
||||
url: vec![
|
||||
".w-gl__result-title a[href]",
|
||||
"h3 a[href]",
|
||||
],
|
||||
snippet: vec![
|
||||
".w-gl__result-snippet",
|
||||
".snippet",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Normaliza query para o motor específico
|
||||
fn normalize_query(&self, query: &str) -> String {
|
||||
// Todos os motores usam encoding padrão, mas alguns podem ter requisitos específicos
|
||||
query.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Estrutura para selectors CSS de cada motor
|
||||
struct SearchSelectors {
|
||||
container: Vec<&'static str>,
|
||||
title: Vec<&'static str>,
|
||||
url: Vec<&'static str>,
|
||||
snippet: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// Log de tentativa de busca em um motor
|
||||
struct SearchAttemptLog {
|
||||
engine: SearchEngine,
|
||||
query: String,
|
||||
success: bool,
|
||||
results_count: usize,
|
||||
duration_ms: u64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Pool de User-Agents para rotação (evita bloqueios 429)
|
||||
const USER_AGENTS: &[&str] = &[
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
@@ -128,6 +301,389 @@ pub async fn search_duckduckgo(query: &str, limit: usize) -> Result<Vec<String>>
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Busca no Google retornando apenas metadados (título, URL, snippet)
|
||||
pub async fn search_google_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let url = format!("{}?q={}&num={}",
|
||||
SearchEngine::Google.base_url(),
|
||||
urlencoding::encode(query),
|
||||
limit.min(100)
|
||||
);
|
||||
|
||||
let user_agent = get_random_user_agent();
|
||||
let start_time = Instant::now();
|
||||
|
||||
log::info!("[SearchEngine:Google] Query: '{}', Attempting...", query);
|
||||
|
||||
let res = match client
|
||||
.get(&url)
|
||||
.header(USER_AGENT, user_agent)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.text().await?,
|
||||
Err(e) => {
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
log::warn!("[SearchEngine:Google] Failed: {} ({}ms)", e, duration);
|
||||
return Err(anyhow::anyhow!("Google search failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
||||
let selectors = SearchEngine::Google.selectors();
|
||||
let document = Html::parse_document(&res);
|
||||
|
||||
for cont_sel in &selectors.container {
|
||||
if results.len() >= limit { break; }
|
||||
if let Ok(container) = Selector::parse(cont_sel) {
|
||||
for node in document.select(&container) {
|
||||
if results.len() >= limit { break; }
|
||||
|
||||
let mut found_url: Option<String> = None;
|
||||
let mut found_title: Option<String> = None;
|
||||
|
||||
// Buscar título
|
||||
for tsel in &selectors.title {
|
||||
if let Ok(ts) = Selector::parse(tsel) {
|
||||
if let Some(a) = node.select(&ts).next() {
|
||||
// Extrair URL
|
||||
if let Some(href) = a.value().attr("href") {
|
||||
let cleaned = clean_url(href);
|
||||
if cleaned.is_some() {
|
||||
found_url = cleaned;
|
||||
}
|
||||
}
|
||||
// Extrair título
|
||||
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
||||
if !text.is_empty() { found_title = Some(text); }
|
||||
}
|
||||
if found_url.is_some() && found_title.is_some() { break; }
|
||||
}
|
||||
}
|
||||
|
||||
if found_url.is_none() { continue; }
|
||||
|
||||
// Buscar snippet
|
||||
let mut snippet_text = String::new();
|
||||
for ssel in &selectors.snippet {
|
||||
if let Ok(ss) = Selector::parse(ssel) {
|
||||
if let Some(s) = node.select(&ss).next() {
|
||||
let t = s.text().collect::<Vec<_>>().join(" ");
|
||||
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !norm.is_empty() { snippet_text = norm; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url_final = found_url.unwrap();
|
||||
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
||||
|
||||
results.push(SearchResultMetadata {
|
||||
title: found_title.unwrap_or_else(|| url_final.clone()),
|
||||
url: url_final,
|
||||
snippet: snippet_text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
if results.is_empty() {
|
||||
log::warn!("[SearchEngine:Google] No results found ({}ms)", duration);
|
||||
} else {
|
||||
log::info!("[SearchEngine:Google] Found {} results ({}ms)", results.len(), duration);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Busca no Bing retornando apenas metadados (título, URL, snippet)
|
||||
pub async fn search_bing_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let url = format!("{}?q={}&count={}",
|
||||
SearchEngine::Bing.base_url(),
|
||||
urlencoding::encode(query),
|
||||
limit.min(50)
|
||||
);
|
||||
|
||||
let user_agent = get_random_user_agent();
|
||||
let start_time = Instant::now();
|
||||
|
||||
log::info!("[SearchEngine:Bing] Query: '{}', Attempting...", query);
|
||||
|
||||
let res = match client
|
||||
.get(&url)
|
||||
.header(USER_AGENT, user_agent)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.text().await?,
|
||||
Err(e) => {
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
log::warn!("[SearchEngine:Bing] Failed: {} ({}ms)", e, duration);
|
||||
return Err(anyhow::anyhow!("Bing search failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
||||
let selectors = SearchEngine::Bing.selectors();
|
||||
let document = Html::parse_document(&res);
|
||||
|
||||
for cont_sel in &selectors.container {
|
||||
if results.len() >= limit { break; }
|
||||
if let Ok(container) = Selector::parse(cont_sel) {
|
||||
for node in document.select(&container) {
|
||||
if results.len() >= limit { break; }
|
||||
|
||||
let mut found_url: Option<String> = None;
|
||||
let mut found_title: Option<String> = None;
|
||||
|
||||
for tsel in &selectors.title {
|
||||
if let Ok(ts) = Selector::parse(tsel) {
|
||||
if let Some(a) = node.select(&ts).next() {
|
||||
if let Some(href) = a.value().attr("href") {
|
||||
let cleaned = clean_url(href);
|
||||
if cleaned.is_some() {
|
||||
found_url = cleaned;
|
||||
}
|
||||
}
|
||||
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
||||
if !text.is_empty() { found_title = Some(text); }
|
||||
}
|
||||
if found_url.is_some() && found_title.is_some() { break; }
|
||||
}
|
||||
}
|
||||
|
||||
if found_url.is_none() { continue; }
|
||||
|
||||
let mut snippet_text = String::new();
|
||||
for ssel in &selectors.snippet {
|
||||
if let Ok(ss) = Selector::parse(ssel) {
|
||||
if let Some(s) = node.select(&ss).next() {
|
||||
let t = s.text().collect::<Vec<_>>().join(" ");
|
||||
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !norm.is_empty() { snippet_text = norm; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url_final = found_url.unwrap();
|
||||
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
||||
|
||||
results.push(SearchResultMetadata {
|
||||
title: found_title.unwrap_or_else(|| url_final.clone()),
|
||||
url: url_final,
|
||||
snippet: snippet_text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
if results.is_empty() {
|
||||
log::warn!("[SearchEngine:Bing] No results found ({}ms)", duration);
|
||||
} else {
|
||||
log::info!("[SearchEngine:Bing] Found {} results ({}ms)", results.len(), duration);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Busca no Yahoo retornando apenas metadados (título, URL, snippet)
|
||||
pub async fn search_yahoo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let url = format!("{}?p={}&n={}",
|
||||
SearchEngine::Yahoo.base_url(),
|
||||
urlencoding::encode(query),
|
||||
limit.min(40)
|
||||
);
|
||||
|
||||
let user_agent = get_random_user_agent();
|
||||
let start_time = Instant::now();
|
||||
|
||||
log::info!("[SearchEngine:Yahoo] Query: '{}', Attempting...", query);
|
||||
|
||||
let res = match client
|
||||
.get(&url)
|
||||
.header(USER_AGENT, user_agent)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.text().await?,
|
||||
Err(e) => {
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
log::warn!("[SearchEngine:Yahoo] Failed: {} ({}ms)", e, duration);
|
||||
return Err(anyhow::anyhow!("Yahoo search failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
||||
let selectors = SearchEngine::Yahoo.selectors();
|
||||
let document = Html::parse_document(&res);
|
||||
|
||||
for cont_sel in &selectors.container {
|
||||
if results.len() >= limit { break; }
|
||||
if let Ok(container) = Selector::parse(cont_sel) {
|
||||
for node in document.select(&container) {
|
||||
if results.len() >= limit { break; }
|
||||
|
||||
let mut found_url: Option<String> = None;
|
||||
let mut found_title: Option<String> = None;
|
||||
|
||||
for tsel in &selectors.title {
|
||||
if let Ok(ts) = Selector::parse(tsel) {
|
||||
if let Some(a) = node.select(&ts).next() {
|
||||
if let Some(href) = a.value().attr("href") {
|
||||
let cleaned = clean_url(href);
|
||||
if cleaned.is_some() {
|
||||
found_url = cleaned;
|
||||
}
|
||||
}
|
||||
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
||||
if !text.is_empty() { found_title = Some(text); }
|
||||
}
|
||||
if found_url.is_some() && found_title.is_some() { break; }
|
||||
}
|
||||
}
|
||||
|
||||
if found_url.is_none() { continue; }
|
||||
|
||||
let mut snippet_text = String::new();
|
||||
for ssel in &selectors.snippet {
|
||||
if let Ok(ss) = Selector::parse(ssel) {
|
||||
if let Some(s) = node.select(&ss).next() {
|
||||
let t = s.text().collect::<Vec<_>>().join(" ");
|
||||
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !norm.is_empty() { snippet_text = norm; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url_final = found_url.unwrap();
|
||||
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
||||
|
||||
results.push(SearchResultMetadata {
|
||||
title: found_title.unwrap_or_else(|| url_final.clone()),
|
||||
url: url_final,
|
||||
snippet: snippet_text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
if results.is_empty() {
|
||||
log::warn!("[SearchEngine:Yahoo] No results found ({}ms)", duration);
|
||||
} else {
|
||||
log::info!("[SearchEngine:Yahoo] Found {} results ({}ms)", results.len(), duration);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Busca no Startpage retornando apenas metadados (título, URL, snippet)
|
||||
pub async fn search_startpage_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let url = format!("{}?query={}&page=1",
|
||||
SearchEngine::Startpage.base_url(),
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
|
||||
let user_agent = get_random_user_agent();
|
||||
let start_time = Instant::now();
|
||||
|
||||
log::info!("[SearchEngine:Startpage] Query: '{}', Attempting...", query);
|
||||
|
||||
let res = match client
|
||||
.get(&url)
|
||||
.header(USER_AGENT, user_agent)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.text().await?,
|
||||
Err(e) => {
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
log::warn!("[SearchEngine:Startpage] Failed: {} ({}ms)", e, duration);
|
||||
return Err(anyhow::anyhow!("Startpage search failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
||||
let selectors = SearchEngine::Startpage.selectors();
|
||||
let document = Html::parse_document(&res);
|
||||
|
||||
for cont_sel in &selectors.container {
|
||||
if results.len() >= limit { break; }
|
||||
if let Ok(container) = Selector::parse(cont_sel) {
|
||||
for node in document.select(&container) {
|
||||
if results.len() >= limit { break; }
|
||||
|
||||
let mut found_url: Option<String> = None;
|
||||
let mut found_title: Option<String> = None;
|
||||
|
||||
for tsel in &selectors.title {
|
||||
if let Ok(ts) = Selector::parse(tsel) {
|
||||
if let Some(a) = node.select(&ts).next() {
|
||||
if let Some(href) = a.value().attr("href") {
|
||||
let cleaned = clean_url(href);
|
||||
if cleaned.is_some() {
|
||||
found_url = cleaned;
|
||||
}
|
||||
}
|
||||
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
||||
if !text.is_empty() { found_title = Some(text); }
|
||||
}
|
||||
if found_url.is_some() && found_title.is_some() { break; }
|
||||
}
|
||||
}
|
||||
|
||||
if found_url.is_none() { continue; }
|
||||
|
||||
let mut snippet_text = String::new();
|
||||
for ssel in &selectors.snippet {
|
||||
if let Ok(ss) = Selector::parse(ssel) {
|
||||
if let Some(s) = node.select(&ss).next() {
|
||||
let t = s.text().collect::<Vec<_>>().join(" ");
|
||||
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !norm.is_empty() { snippet_text = norm; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url_final = found_url.unwrap();
|
||||
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
||||
|
||||
results.push(SearchResultMetadata {
|
||||
title: found_title.unwrap_or_else(|| url_final.clone()),
|
||||
url: url_final,
|
||||
snippet: snippet_text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start_time.elapsed().as_millis() as u64;
|
||||
if results.is_empty() {
|
||||
log::warn!("[SearchEngine:Startpage] No results found ({}ms)", duration);
|
||||
} else {
|
||||
log::info!("[SearchEngine:Startpage] Found {} results ({}ms)", results.len(), duration);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Busca no DuckDuckGo retornando apenas metadados (título, URL, snippet)
|
||||
pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -237,6 +793,216 @@ pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Calcula score de relevância baseado em matches de palavras-chave
|
||||
fn calculate_relevance_score(result: &SearchResultMetadata, query: &str) -> f32 {
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_words: Vec<&str> = query_lower.split_whitespace()
|
||||
.filter(|w| w.len() > 2)
|
||||
.collect();
|
||||
|
||||
if query_words.is_empty() {
|
||||
return 0.5; // Score neutro se não há palavras-chave
|
||||
}
|
||||
|
||||
let title_lower = result.title.to_lowercase();
|
||||
let snippet_lower = result.snippet.to_lowercase();
|
||||
let combined = format!("{} {}", title_lower, snippet_lower);
|
||||
|
||||
let mut matches = 0;
|
||||
for word in &query_words {
|
||||
if combined.contains(word) {
|
||||
matches += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let base_score = matches as f32 / query_words.len() as f32;
|
||||
|
||||
// Bônus se palavra está no título
|
||||
let title_matches = query_words.iter()
|
||||
.filter(|w| title_lower.contains(*w))
|
||||
.count();
|
||||
let title_bonus = (title_matches as f32 / query_words.len() as f32) * 0.3;
|
||||
|
||||
// Bônus se snippet não está vazio
|
||||
let snippet_bonus = if !result.snippet.is_empty() { 0.1 } else { 0.0 };
|
||||
|
||||
(base_score + title_bonus + snippet_bonus).min(1.0)
|
||||
}
|
||||
|
||||
/// Busca multi-engine com fallback automático
|
||||
pub async fn search_multi_engine_metadata(
|
||||
query: &str,
|
||||
limit: usize,
|
||||
engine_order: &[SearchEngine],
|
||||
min_results: usize,
|
||||
) -> Result<Vec<SearchResultMetadata>> {
|
||||
let mut all_results: Vec<SearchResultMetadata> = Vec::new();
|
||||
let mut seen_urls = std::collections::HashSet::new();
|
||||
let mut attempt_logs: Vec<SearchAttemptLog> = Vec::new();
|
||||
|
||||
log::info!("[MultiEngine] Starting search for: '{}'", query);
|
||||
log::info!("[MultiEngine] Engine order: {:?}", engine_order.iter().map(|e| e.as_str()).collect::<Vec<_>>());
|
||||
log::info!("[MultiEngine] Min results required: {}", min_results);
|
||||
|
||||
for engine in engine_order {
|
||||
let start_time = Instant::now();
|
||||
let mut attempt_log = SearchAttemptLog {
|
||||
engine: *engine,
|
||||
query: query.to_string(),
|
||||
success: false,
|
||||
results_count: 0,
|
||||
duration_ms: 0,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let result = match *engine {
|
||||
SearchEngine::Google => search_google_metadata(query, limit).await,
|
||||
SearchEngine::Bing => search_bing_metadata(query, limit).await,
|
||||
SearchEngine::Yahoo => search_yahoo_metadata(query, limit).await,
|
||||
SearchEngine::DuckDuckGo => search_duckduckgo_metadata(query, limit).await,
|
||||
SearchEngine::Startpage => search_startpage_metadata(query, limit).await,
|
||||
};
|
||||
|
||||
attempt_log.duration_ms = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
match result {
|
||||
Ok(mut engine_results) => {
|
||||
// Filtrar duplicatas
|
||||
engine_results.retain(|r| {
|
||||
if seen_urls.contains(&r.url) {
|
||||
false
|
||||
} else {
|
||||
seen_urls.insert(r.url.clone());
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
attempt_log.results_count = engine_results.len();
|
||||
attempt_log.success = true;
|
||||
|
||||
if !engine_results.is_empty() {
|
||||
log::info!("[MultiEngine:{}] Found {} unique results ({}ms)",
|
||||
engine.as_str(), engine_results.len(), attempt_log.duration_ms);
|
||||
all_results.extend(engine_results);
|
||||
|
||||
// Se atingiu mínimo necessário, pode parar
|
||||
if all_results.len() >= min_results {
|
||||
log::info!("[MultiEngine] Minimum results ({}) reached, stopping early", min_results);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
log::warn!("[MultiEngine:{}] No results found ({}ms), trying next engine...",
|
||||
engine.as_str(), attempt_log.duration_ms);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("{}", e);
|
||||
attempt_log.error = Some(error_msg.clone());
|
||||
log::warn!("[MultiEngine:{}] Failed: {} ({}ms), trying next engine...",
|
||||
engine.as_str(), error_msg, attempt_log.duration_ms);
|
||||
}
|
||||
}
|
||||
|
||||
attempt_logs.push(attempt_log);
|
||||
}
|
||||
|
||||
// Ranquear resultados por relevância
|
||||
let mut scored_results: Vec<(SearchResultMetadata, f32)> = all_results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let score = calculate_relevance_score(&r, query);
|
||||
(r, score)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Ordenar por score (maior primeiro)
|
||||
scored_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Retornar top limit resultados
|
||||
let final_results: Vec<SearchResultMetadata> = scored_results
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(r, _)| r)
|
||||
.collect();
|
||||
|
||||
// Log resumo
|
||||
log::info!("[MultiEngine] Final results: {} (from {} engines)",
|
||||
final_results.len(), attempt_logs.len());
|
||||
for log_entry in &attempt_logs {
|
||||
if log_entry.success {
|
||||
log::info!(" ✓ {}: {} results ({}ms)",
|
||||
log_entry.engine.as_str(), log_entry.results_count, log_entry.duration_ms);
|
||||
} else {
|
||||
log::warn!(" ✗ {}: Failed - {} ({}ms)",
|
||||
log_entry.engine.as_str(),
|
||||
log_entry.error.as_ref().unwrap_or(&"Unknown error".to_string()),
|
||||
log_entry.duration_ms);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(final_results)
|
||||
}
|
||||
|
||||
/// Expande query semanticamente (adiciona sinônimos, remove stopwords)
|
||||
pub fn expand_query_semantic(query: &str, language: &str) -> Vec<String> {
|
||||
let mut variants = Vec::new();
|
||||
|
||||
// Query original sempre incluída
|
||||
variants.push(query.trim().to_string());
|
||||
|
||||
// Stopwords por idioma
|
||||
let stopwords: Vec<&str> = match language {
|
||||
"pt-BR" | "pt" => vec!["o", "a", "os", "as", "de", "do", "da", "dos", "das", "em", "no", "na", "nos", "nas", "para", "por", "com", "sem", "que", "qual", "quais"],
|
||||
"en" => vec!["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by"],
|
||||
"es" => vec!["el", "la", "los", "las", "de", "del", "en", "un", "una", "para", "por", "con", "sin"],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
// Remover stopwords
|
||||
let words: Vec<&str> = query.split_whitespace()
|
||||
.filter(|w| !stopwords.contains(&w.to_lowercase().as_str()))
|
||||
.collect();
|
||||
|
||||
if words.len() > 1 {
|
||||
let without_stopwords = words.join(" ");
|
||||
if without_stopwords != query.trim() {
|
||||
variants.push(without_stopwords);
|
||||
}
|
||||
}
|
||||
|
||||
// Sinônimos comuns (básico - pode ser expandido)
|
||||
let synonyms: Vec<(&str, &str)> = match language {
|
||||
"pt-BR" | "pt" => vec![
|
||||
("pesquisa", "estudo investigação"),
|
||||
("resultado", "achado descoberta"),
|
||||
("acadêmico", "científico universitário"),
|
||||
],
|
||||
"en" => vec![
|
||||
("research", "study investigation"),
|
||||
("result", "finding discovery"),
|
||||
("academic", "scientific scholarly"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
// Adicionar variantes com sinônimos
|
||||
for (original, replacements) in synonyms {
|
||||
if query.to_lowercase().contains(original) {
|
||||
for replacement in replacements.split_whitespace() {
|
||||
let variant = query.to_lowercase().replace(original, replacement);
|
||||
if variant != query.to_lowercase() {
|
||||
variants.push(variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remover duplicatas e retornar
|
||||
variants.sort();
|
||||
variants.dedup();
|
||||
variants
|
||||
}
|
||||
|
||||
/// Extrai a URL real do redirecionamento do DuckDuckGo
|
||||
fn extract_real_url(ddg_redirect: &str) -> Option<String> {
|
||||
// DuckDuckGo usa formato: /l/?kh=-1&uddg=<URL_ENCODED>
|
||||
|
||||
+29
-1
@@ -27,6 +27,10 @@ export interface SettingsState {
|
||||
totalSourcesLimit: number;
|
||||
categories: SearchCategory[];
|
||||
userCustomSites: string[];
|
||||
engineOrder: string[]; // Ordem dos motores de busca: ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage']
|
||||
minResultsPerEngine: number; // Mínimo de resultados para considerar sucesso
|
||||
enableSemanticExpansion: boolean; // Habilitar expansão semântica de queries
|
||||
semanticExpansionLanguage: string; // Idioma para expansão: 'pt-BR', 'en', 'es'
|
||||
};
|
||||
|
||||
// Content Processing
|
||||
@@ -72,6 +76,10 @@ export interface SettingsState {
|
||||
setWebSearchTimeout: (timeout: number) => void;
|
||||
setWebSearchMaxConcurrentTabs: (max: number) => void;
|
||||
setWebSearchTotalSourcesLimit: (limit: number) => void;
|
||||
setWebSearchEngineOrder: (order: string[]) => void;
|
||||
setWebSearchMinResultsPerEngine: (min: number) => void;
|
||||
setWebSearchSemanticExpansion: (enabled: boolean) => void;
|
||||
setWebSearchSemanticExpansionLanguage: (language: string) => void;
|
||||
addExcludedDomain: (domain: string) => void;
|
||||
removeExcludedDomain: (domain: string) => void;
|
||||
toggleCategory: (categoryId: string) => void;
|
||||
@@ -196,12 +204,16 @@ const initialState = {
|
||||
webSearch: {
|
||||
enabled: true,
|
||||
maxResults: 10,
|
||||
timeout: 10,
|
||||
timeout: 15000, // Timeout em milissegundos (15 segundos)
|
||||
excludedDomains: ['youtube.com', 'linkedin.com'],
|
||||
maxConcurrentTabs: 5,
|
||||
totalSourcesLimit: 100,
|
||||
categories: defaultCategories,
|
||||
userCustomSites: [],
|
||||
engineOrder: ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'], // Ordem padrão
|
||||
minResultsPerEngine: 1, // Mínimo de 1 resultado para considerar sucesso
|
||||
enableSemanticExpansion: true, // Habilitado por padrão
|
||||
semanticExpansionLanguage: 'pt-BR', // Português brasileiro por padrão
|
||||
},
|
||||
sourcesConfig: null,
|
||||
autoStart: false,
|
||||
@@ -266,6 +278,22 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
set((state) => ({
|
||||
webSearch: { ...state.webSearch, totalSourcesLimit: limit },
|
||||
})),
|
||||
setWebSearchEngineOrder: (order) =>
|
||||
set((state) => ({
|
||||
webSearch: { ...state.webSearch, engineOrder: order },
|
||||
})),
|
||||
setWebSearchMinResultsPerEngine: (min) =>
|
||||
set((state) => ({
|
||||
webSearch: { ...state.webSearch, minResultsPerEngine: min },
|
||||
})),
|
||||
setWebSearchSemanticExpansion: (enabled) =>
|
||||
set((state) => ({
|
||||
webSearch: { ...state.webSearch, enableSemanticExpansion: enabled },
|
||||
})),
|
||||
setWebSearchSemanticExpansionLanguage: (language) =>
|
||||
set((state) => ({
|
||||
webSearch: { ...state.webSearch, semanticExpansionLanguage: language },
|
||||
})),
|
||||
addExcludedDomain: (domain) =>
|
||||
set((state) => {
|
||||
const normalized = domain.toLowerCase().trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
|
||||
Reference in new issue
Block a user