feat: add emoji-regex and remove-accents dependencies; enhance chat and setup pages

- Added `emoji-regex` and `remove-accents` packages to improve text processing capabilities.
- Implemented automatic Ollama startup in the chat page for better user experience.
- Enhanced setup page to include GPU selection based on hardware specifications.
- Updated chat input to support real-time validation and improved user feedback.
- Refactored message handling in chat to incorporate new processing features.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-26 16:12:16 -03:00
1 parent 4275295e4f
commit 84ea2dec19
51 files changed
+7857 -573

No files matched your search

+762 -236
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -4,13 +4,36 @@ import { useOllamaCheck } from "@/hooks/use-ollama-check";
import { Hero } from "@/components/landing/hero";
import { InstallModal } from "@/components/modals/install-modal";
import { StoppedCard } from "@/components/landing/stopped-card";
import { WelcomeNotification } from "@/components/notifications/welcome-notification";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function Home() {
const { status, check } = useOllamaCheck();
const router = useRouter();
// Tentar iniciar Ollama automaticamente quando o componente montar
useEffect(() => {
const tryAutoStart = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke('auto_start_ollama');
// Aguardar um pouco e verificar novamente
setTimeout(() => {
check();
}, 2000);
} catch (error) {
console.error('Erro ao tentar iniciar Ollama automaticamente:', error);
}
};
// Só tentar se estiver instalado mas não rodando
if (status === 'installed_stopped') {
tryAutoStart();
}
}, [status, check]);
const handleStart = () => {
const setupComplete = localStorage.getItem("ollahub_setup_complete");
if (setupComplete) {
@@ -31,6 +54,8 @@ export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
{/* Notificação de boas-vindas */}
<WelcomeNotification />
{/* Layout for Running State */}
{status === 'running' && (
+8 -1
View File
@@ -2,12 +2,13 @@
import { useHardware } from "@/hooks/use-hardware";
import { HardwareScan } from "@/components/setup/hardware-scan";
import { GpuSelector } from "@/components/setup/gpu-selector";
import { ModelSelector } from "@/components/setup/model-selector";
import { getRecommendation } from "@/lib/recommendation";
import { useRouter } from "next/navigation";
export default function SetupPage() {
const { specs, loading } = useHardware();
const { specs, loading, gpus } = useHardware();
const router = useRouter();
const handleComplete = () => {
@@ -26,6 +27,12 @@ export default function SetupPage() {
<HardwareScan specs={specs} loading={loading} />
{!loading && specs && gpus.length > 0 && (
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-200 fill-mode-backwards">
<GpuSelector gpus={gpus} />
</div>
)}
{!loading && specs && (
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
<ModelSelector
+105 -51
View File
@@ -1,13 +1,31 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Square, Globe, WifiOff, ChevronUp } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle } from "lucide-react";
import { useState, useRef, useEffect, useMemo } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { quickValidate, validateQuery } from "@/lib/query-validation";
import { useSettingsStore, type SearchCategory } from "@/store/settings-store";
interface ChatInputProps {
onSend: (message: string) => void;
@@ -31,6 +49,35 @@ export function ChatInput({
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [webDialogOpen, setWebDialogOpen] = useState(false);
const settings = useSettingsStore();
// Validação em tempo real
const validation = useMemo(() => {
if (!input.trim()) {
return { isValid: true, errors: [], warnings: [] };
}
const preprocessingConfig = settings.queryPreprocessing || {
enabled: true,
minLength: 3,
maxLength: 2000,
autoSplitQuestions: true,
irrelevantPatterns: [],
};
if (!preprocessingConfig.enabled) {
return { isValid: true, errors: [], warnings: [] };
}
return validateQuery(input, {
minLength: preprocessingConfig.minLength,
maxLength: preprocessingConfig.maxLength,
irrelevantPatterns: preprocessingConfig.irrelevantPatterns.length > 0
? preprocessingConfig.irrelevantPatterns
: undefined,
});
}, [input, settings.queryPreprocessing]);
const isValid = validation.isValid;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -40,7 +87,7 @@ export function ChatInput({
};
const handleSend = () => {
if (input.trim() && !isLoading) {
if (input.trim() && !isLoading && isValid) {
onSend(input);
setInput("");
}
@@ -49,31 +96,37 @@ export function ChatInput({
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const scrollHeight = textareaRef.current.scrollHeight;
// Limitar altura máxima a uns 200px
textareaRef.current.style.height = `${Math.min(scrollHeight, 200)}px`;
}
}, [input]);
return (
<div className="p-6 bg-transparent">
<div className="relative max-w-3xl mx-auto w-full">
<div className="flex-1 relative">
<div className="w-full bg-transparent">
<div className="relative w-full">
<div className="flex-1 relative bg-background dark:bg-zinc-900/50 rounded-[2rem] border border-border/50 shadow-lg backdrop-blur-xl transition-all duration-200 focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/40 hover:border-primary/30">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="min-h-[48px] resize-none overflow-hidden py-4 pr-12 rounded-xl border shadow-lg"
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 ${
input.trim() && !isValid ? 'text-destructive' : ''
}`}
rows={1}
/>
<div className="absolute right-2 bottom-2 flex items-center gap-2">
{/* Right Actions */}
<div className="absolute right-3 bottom-2.5 flex items-center gap-1.5">
{onWebSearchToggle && (
<TooltipProvider>
<Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
size="icon"
variant={webSearchEnabled ? "default" : "ghost"}
variant={webSearchEnabled ? "ghost" : "ghost"}
onClick={() => {
if (!webSearchEnabled) {
onWebSearchToggle(true);
@@ -81,7 +134,11 @@ export function ChatInput({
setWebDialogOpen(true);
}
}}
className="h-8 w-8"
className={`h-9 w-9 rounded-full transition-all ${
webSearchEnabled
? "text-primary bg-primary/10 hover:bg-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
disabled={isLoading}
>
{webSearchEnabled ? (
@@ -101,14 +158,15 @@ export function ChatInput({
</Tooltip>
</TooltipProvider>
)}
{onToggleCategory && categories && categories.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" className="h-8 w-8" title="Gerenciar fontes">
<Button size="icon" variant="ghost" className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted" title="Gerenciar fontes">
<ChevronUp className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuContent align="end" sideOffset={12} className="w-56 rounded-xl">
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
<DropdownMenuSeparator />
{categories.map((cat) => (
@@ -123,68 +181,64 @@ export function ChatInput({
</DropdownMenuContent>
</DropdownMenu>
)}
{isLoading ? (
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
<Square className="h-4 w-4 fill-current" />
<Button
size="icon"
variant="destructive"
onClick={onStop}
className="h-9 w-9 rounded-full shadow-sm animate-in fade-in zoom-in duration-200"
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
) : (
<Button
size="icon"
onClick={handleSend}
disabled={!input.trim()}
className="h-8 w-8"
disabled={!input.trim() || !isValid}
className={`h-9 w-9 rounded-full transition-all shadow-sm ${
input.trim() && isValid
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
>
<Send className="h-4 w-4" />
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
{/* Validation Errors */}
{input.trim() && !isValid && validation.errors.length > 0 && (
<div className="absolute -top-12 left-0 right-0 mx-auto w-max max-w-[90%] animate-in fade-in slide-in-from-bottom-2">
<div className="bg-destructive/90 text-destructive-foreground text-xs px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 backdrop-blur-sm">
<AlertCircle className="h-3.5 w-3.5" />
<span>{validation.errors[0]}</span>
</div>
</div>
)}
</div>
<Dialog open={webDialogOpen} onOpenChange={setWebDialogOpen}>
<DialogContent>
<DialogContent className="sm:max-w-[425px] rounded-2xl">
<DialogHeader>
<DialogTitle>Desativar pesquisa web?</DialogTitle>
<DialogDescription>
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes, preços, notícias e conteúdo em rápida mudança.
Use com cautela — modelos locais sem contexto web tendem a alucinar mais nesses cenários.
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setWebDialogOpen(false)}>Manter ativado</Button>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setWebDialogOpen(false)} className="rounded-full">Cancelar</Button>
<Button
variant="destructive"
onClick={() => { onWebSearchToggle?.(false); setWebDialogOpen(false); }}
className="rounded-full"
>
Desativar pesquisa web
Desativar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="text-xs text-center text-muted-foreground mt-2">
OllaHub pode cometer erros. Verifique informações importantes.
</div>
</div>
);
}
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { SearchCategory } from "@/store/settings-store";
+95 -41
View File
@@ -1,71 +1,125 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { cn } from "@/lib/utils";
import { Message } from "@/hooks/use-chat";
import { Bot, User } from "lucide-react";
import { markdownRenderers } from "./markdown-renderers";
import { MessageActions } from "./message-actions";
import { MessageBackground } from "./message-background";
import { MessageStepThinking } from "./message-step-thinking";
import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
}
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) {
// Verificar se é mensagem de processo de pensamento
const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' &&
'type' in message.metadata &&
message.metadata.type === 'thinking';
if (isThinkingMessage) {
return <MessageStepThinking metadata={message.metadata as ThinkingMessageMetadata} />;
}
export function ChatMessage({ message }: { message: Message }) {
const isUser = message.role === 'user';
const [selected, setSelected] = useState(false);
const isEmpty = !message.content || message.content.trim() === '';
// Memoizar processamento de conteúdo
const processedContent = useMemo(() => {
let content = message.content || '';
// Remove metadata tags
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Also remove common prefixes
const prefixes = [
'Bloco oculto de metadados:', 'Metadata:', 'Metadados:',
'Hidden metadata block:', 'JSON metadata:', '---'
];
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(content)) {
content = content.replace(regex, '').trim();
}
}
return content || message.content;
}, [message.content]);
return (
<MessageBackground
role={isUser ? 'user' : 'assistant'}
state={selected ? 'selected' : 'normal'}
className={cn("cursor-pointer")}
className={cn(
"cursor-default transition-colors rounded-xl",
selected && "bg-muted/40",
isUser ? "mb-2" : "mb-8"
)}
onClick={() => setSelected((v) => !v)}
tabIndex={0}
>
<div className="flex-shrink-0 mt-1">
<div className="flex-shrink-0 mt-0.5">
<div className={cn(
"w-8 h-8 rounded-md flex items-center justify-center",
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
isUser
? "bg-background text-muted-foreground"
: "bg-primary/10 text-primary ring-primary/20"
)}>
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
</div>
</div>
<div className="flex-1 overflow-hidden min-w-0">
<div className="prose dark:prose-invert max-w-none break-words leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{(() => {
let content = message.content || '';
// Remove metadata tags
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Also remove common prefixes if they appear at the end
const prefixes = [
'Bloco oculto de metadados:',
'Metadata:',
'Metadados:',
'Hidden metadata block:',
'JSON metadata:',
'---'
];
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(content)) {
content = content.replace(regex, '').trim();
}
}
return content || message.content;
})()}
</ReactMarkdown>
</div>
<div className="flex-1 overflow-hidden min-w-0 pt-1">
{isUser ? (
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
{processedContent}
</div>
) : (
// Estilo Assistant
isEmpty && isStreaming ? (
<div className="flex items-center gap-2 text-muted-foreground py-1">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-sm font-medium">Gerando resposta...</span>
</div>
) : (
<div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
"prose-p:leading-7 prose-li:leading-7",
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
isStreaming && "relative"
)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{processedContent}
</ReactMarkdown>
{isStreaming && !isEmpty && (
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
)}
</div>
)
)}
</div>
{!isUser && (
<div className="absolute top-4 right-4">
{!isUser && !isStreaming && (
<div className={cn(
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
"group-hover:opacity-100",
selected && "opacity-100"
)}>
<MessageActions content={message.content} role={message.role} />
</div>
)}
</MessageBackground>
);
}
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { AlertCircle, RefreshCw, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface ErrorDisplayProps {
error: string | Error;
onRetry?: () => void;
onDismiss?: () => void;
className?: string;
}
export function ErrorDisplay({ error, onRetry, onDismiss, className }: ErrorDisplayProps) {
const errorMessage = error instanceof Error ? error.message : String(error);
return (
<div
className={cn(
"mx-4 mb-3 px-4 py-3 rounded-lg border border-red-500/50 bg-red-500/10",
className
)}
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">
Erro ao processar
</div>
<p className="text-sm text-red-700 dark:text-red-300 break-words">
{errorMessage}
</p>
{(onRetry || onDismiss) && (
<div className="flex items-center gap-2 mt-3">
{onRetry && (
<Button
size="sm"
variant="outline"
onClick={onRetry}
className="h-7 text-xs border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-500/20"
>
<RefreshCw className="w-3 h-3 mr-1.5" />
Tentar novamente
</Button>
)}
{onDismiss && (
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
>
<X className="w-3 h-3 mr-1.5" />
Fechar
</Button>
)}
</div>
)}
</div>
</div>
</div>
);
}
+13 -5
View File
@@ -18,11 +18,19 @@ export function MessageBackground({
children,
...props
}: MessageBackgroundProps) {
const base = "group flex w-full gap-3 md:gap-4 relative transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
const base = "group flex w-full gap-4 md:gap-6 relative transition-all duration-200";
// User: Fundo sutil, alinhado à direita (se quiséssemos, mas o layout atual é lista).
// Vamos manter layout lista mas com estilo clean.
// Perplexity usa: User message apenas texto bold/destacado, Assistant texto normal.
const roleCl = role === "user"
? "rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-3"
: "bg-transparent px-0 py-0";
const stateCl = state === "selected" ? "ring-2 ring-primary/40" : state === "highlighted" ? "ring-1 ring-accent/30" : "";
? "bg-transparent text-foreground px-0 py-2" // Minimalista
: "bg-transparent text-foreground/90 px-0 py-2";
// Estado de seleção sutil
const stateCl = state === "selected" ? "bg-muted/30 -mx-4 px-4 rounded-lg" : "";
const hc = highContrast ? "hc:bg-message-high-contrast hc:text-foreground" : "";
return (
@@ -34,4 +42,4 @@ export function MessageBackground({
{children}
</div>
);
}
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import {
Search,
Brain,
Globe,
Package,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
ChevronDown,
ChevronUp,
ExternalLink,
Copy,
Check
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
interface MessageStepThinkingProps {
metadata: ThinkingMessageMetadata;
}
export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const getStepConfig = () => {
switch (metadata.stepType) {
case 'preprocessing':
return {
icon: Brain,
textColor: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-900/20",
};
case 'web-research':
return {
icon: Globe,
textColor: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-900/20",
};
case 'sources-found':
return {
icon: Package,
textColor: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-900/20",
};
case 'processing':
return {
icon: Brain,
textColor: "text-indigo-600 dark:text-indigo-400",
bgColor: "bg-indigo-50 dark:bg-indigo-900/20",
};
case 'response-generation':
return {
icon: Loader2,
textColor: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-50 dark:bg-orange-900/20",
};
case 'fallback':
return {
icon: AlertCircle,
textColor: "text-yellow-600 dark:text-yellow-400",
bgColor: "bg-yellow-50 dark:bg-yellow-900/20",
};
case 'error':
return {
icon: XCircle,
textColor: "text-red-600 dark:text-red-400",
bgColor: "bg-red-50 dark:bg-red-900/20",
};
default:
return {
icon: Search,
textColor: "text-muted-foreground",
bgColor: "bg-muted/30",
};
}
};
const getStepIcon = () => {
const config = getStepConfig();
const Icon = config.icon;
const isSpinning = metadata.stepType === 'response-generation' && metadata.status === 'running';
return <Icon className={cn("w-4 h-4", config.textColor, isSpinning && "animate-spin")} />;
};
const copyToClipboard = async (url: string, index: number) => {
try {
await navigator.clipboard.writeText(url);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (error) {
console.error('Failed to copy URL:', error);
}
};
const config = getStepConfig();
return (
<div className="mb-4 ml-10 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className={cn(
"flex items-center gap-3 p-2 rounded-lg transition-all w-fit max-w-full",
config.bgColor
)}>
<div className="flex items-center gap-2">
{getStepIcon()}
<span className={cn("text-sm font-medium", config.textColor)}>
{metadata.label}
</span>
</div>
{metadata.status === 'running' && (
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground/50" />
)}
{(metadata.details || metadata.sources || metadata.error) && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-3.5 h-3.5 opacity-50" />
) : (
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
)}
</button>
)}
</div>
{/* Progress Bar Compacta */}
{metadata.progress !== undefined && metadata.progress < 100 && (
<div className="ml-1 mt-1 w-32 h-1 bg-muted/50 rounded-full overflow-hidden">
<div
className={cn("h-full transition-all duration-300", config.textColor.replace('text-', 'bg-'))}
style={{ width: `${metadata.progress}%` }}
/>
</div>
)}
{/* Conteúdo Expandido */}
{isExpanded && (
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
{metadata.details && (
<div className="whitespace-pre-wrap">{metadata.details}</div>
)}
{metadata.sources && metadata.sources.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{metadata.sources.map((source, idx) => (
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
<ExternalLink className="w-3 h-3 opacity-50" />
<a href={source.url} target="_blank" className="truncate hover:underline flex-1">{source.title || source.url}</a>
<button onClick={() => copyToClipboard(source.url, idx)} className="ml-1 opacity-50 hover:opacity-100">
{copiedIndex === idx ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
</button>
</div>
))}
</div>
)}
{metadata.error && (
<div className="text-red-500 font-medium flex items-center gap-2">
<XCircle className="w-4 h-4" />
{metadata.error}
</div>
)}
</div>
)}
</div>
);
}
+367
View File
@@ -0,0 +1,367 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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 { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface ModelDownloadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (modelName: string) => void;
}
interface DownloadInfo {
status: string;
percent?: number;
downloaded?: string;
total?: string;
speed?: string;
raw: string;
}
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
const [modelName, setModelName] = useState("");
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState("");
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const isDownloadingRef = useRef(false);
const modelNameRef = useRef("");
// Atualizar refs quando estado muda
useEffect(() => {
isDownloadingRef.current = isDownloading;
modelNameRef.current = modelName;
}, [isDownloading, modelName]);
// Resetar estado quando dialog abre/fecha
useEffect(() => {
if (open) {
setModelName("");
setIsDownloading(false);
setDownloadProgress(0);
setDownloadStatus("");
setDownloadInfo(null);
setError(null);
setIsInstalled(false);
isDownloadingRef.current = false;
modelNameRef.current = "";
}
}, [open]);
// Verificar se modelo já está instalado quando nome muda
useEffect(() => {
if (modelName.trim() && !isDownloading) {
checkInstallation(modelName.trim());
}
}, [modelName, isDownloading]);
// Listener para progresso do download - sempre ativo quando dialog está aberto
useEffect(() => {
if (!open) return;
let unlistenFn: (() => void) | null = null;
const setupListener = async () => {
const unlisten = await listen<string>('download-progress', (event) => {
// Só processar se estiver fazendo download (usar ref para evitar stale closure)
if (!isDownloadingRef.current) return;
const payload = event.payload;
// Tentar parsear como JSON estruturado
let info: DownloadInfo | null = null;
try {
info = JSON.parse(payload) as DownloadInfo;
} catch {
// Fallback: tratar como string raw
info = {
status: "downloading",
raw: payload,
};
}
if (info) {
setDownloadInfo(info);
// Atualizar status text
const statusText = info.status === "pulling" ? "Baixando modelo..." :
info.status === "verifying" ? "Verificando..." :
info.status === "writing" ? "Salvando..." :
info.status === "success" ? "Concluído!" :
"Baixando...";
setDownloadStatus(statusText);
// Atualizar progresso
if (info.percent !== undefined) {
setDownloadProgress(info.percent);
} else {
// Fallback: estimar progresso baseado no status
if (info.status === "pulling") {
setDownloadProgress(prev => prev < 10 ? 10 : prev);
} else if (info.status === "verifying") {
setDownloadProgress(90);
} else if (info.status === "writing") {
setDownloadProgress(95);
}
}
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
if (info.percent === 100 || info.status === "success") {
setTimeout(async () => {
setIsInstalled(true);
setIsDownloading(false);
isDownloadingRef.current = false;
const currentModelName = modelNameRef.current.trim();
await checkInstallation(currentModelName);
// Após verificar instalação, chamar onSuccess e fechar
if (onSuccess) {
onSuccess(currentModelName);
}
onOpenChange(false);
}, 500);
}
}
});
unlistenFn = unlisten;
};
setupListener();
return () => {
if (unlistenFn) {
unlistenFn();
}
};
}, [open, onSuccess, onOpenChange]);
const checkInstallation = async (name: string) => {
try {
const installed = await invoke<boolean>('check_if_model_installed', { name });
setIsInstalled(installed);
if (installed) {
setError(null);
}
} catch (e) {
console.error("Check failed", e);
}
};
const validateModelName = (name: string): string | null => {
const trimmed = name.trim();
if (!trimmed) {
return "Nome do modelo não pode estar vazio";
}
// Validar formato: aceita letras, números, pontos, underscores, hífens, dois pontos e barras
// Exemplos válidos: "llama3.2:1b", "huihui_ai/qwen3-abliterated:1.7b", "mistral:latest"
if (!/^[a-zA-Z0-9._:\/-]+$/.test(trimmed)) {
return "Nome do modelo contém caracteres inválidos";
}
// Não pode começar ou terminar com barra, dois pontos ou hífen
if (/^[\/:\-]|[\/:\-]$/.test(trimmed)) {
return "Nome do modelo não pode começar ou terminar com /, : ou -";
}
return null;
};
const handleDownload = async () => {
const trimmedName = modelName.trim();
const validationError = validateModelName(trimmedName);
if (validationError) {
setError(validationError);
return;
}
if (isInstalled) {
// Se já está instalado, apenas fechar e notificar sucesso
handleSuccess(trimmedName);
return;
}
setIsDownloading(true);
isDownloadingRef.current = true;
setDownloadProgress(0);
setDownloadStatus("Iniciando download...");
setError(null);
modelNameRef.current = trimmedName;
try {
await invoke('pull_model', { name: trimmedName });
// O status será atualizado pelo listener quando o download completar
} catch (error: any) {
console.error("Download failed", error);
setError(error?.message || "Falha no download. Tente novamente.");
setDownloadStatus("");
setDownloadProgress(0);
setIsDownloading(false);
isDownloadingRef.current = false;
}
};
const handleSuccess = (name: string) => {
// Só fechar e chamar onSuccess se realmente completou o download
// Se já estava instalado, também chamar onSuccess
if (onSuccess) {
onSuccess(name);
}
// Não fechar automaticamente se foi apenas verificação de instalação
// Deixar o usuário decidir quando fechar
};
const handleClose = () => {
// Permitir fechar sempre, mas avisar se estiver baixando
if (isDownloading) {
const confirmed = window.confirm("O download está em andamento. Deseja realmente fechar? O download continuará em segundo plano.");
if (!confirmed) {
return;
}
}
onOpenChange(false);
};
const handleCancel = () => {
// Cancelar download (por enquanto apenas fecha o dialog e para o estado)
// TODO: Implementar cancelamento real do processo ollama pull
if (isDownloading) {
const confirmed = window.confirm("Deseja cancelar o download? O processo continuará em segundo plano.");
if (confirmed) {
setIsDownloading(false);
isDownloadingRef.current = false;
setDownloadProgress(0);
setDownloadStatus("");
onOpenChange(false);
}
} else {
onOpenChange(false);
}
};
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
return (
<Dialog open={open} onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Adicionar Modelo</DialogTitle>
<DialogDescription>
Digite o nome do modelo do Ollama que deseja baixar (ex: llama3.2:1b, mistral:latest)
</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 && (
<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>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
>
{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
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { ChevronDown, ChevronUp, Clock, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
export interface ProcessStep {
id: string;
label: string;
status: 'pending' | 'running' | 'completed' | 'error';
timestamp?: number;
duration?: number;
details?: string;
error?: string;
}
interface PostProcessingPanelProps {
steps: ProcessStep[];
className?: string;
}
export function PostProcessingPanel({ steps, className }: PostProcessingPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!steps || steps.length === 0) {
return null;
}
const completedSteps = steps.filter(s => s.status === 'completed').length;
const hasErrors = steps.some(s => s.status === 'error');
const isRunning = steps.some(s => s.status === 'running');
const getStepIcon = (step: ProcessStep) => {
switch (step.status) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />;
case 'error':
return <XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />;
case 'running':
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
default:
return <Clock className="w-4 h-4 text-muted-foreground" />;
}
};
const formatDuration = (ms?: number) => {
if (!ms) return '';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
return (
<div className={cn("border-t border-muted bg-muted/20", className)}>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-6 py-3 flex items-center justify-between hover:bg-muted/40 transition-colors"
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{hasErrors ? (
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
) : isRunning ? (
<Loader2 className="w-4 h-4 text-primary animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
)}
<span className="text-sm font-medium">
Processo de Pensamento
</span>
</div>
<span className="text-xs text-muted-foreground">
({completedSteps}/{steps.length} etapas)
</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="px-6 pb-4 space-y-3">
{steps.map((step, idx) => (
<div
key={step.id}
className={cn(
"p-3 rounded-lg border transition-all",
step.status === 'completed' && "bg-green-500/5 border-green-500/20",
step.status === 'error' && "bg-red-500/5 border-red-500/20",
step.status === 'running' && "bg-primary/5 border-primary/20",
step.status === 'pending' && "bg-muted/50 border-muted"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 flex-1 min-w-0">
{getStepIcon(step)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{step.label}</span>
{step.duration && (
<span className="text-xs text-muted-foreground">
({formatDuration(step.duration)})
</span>
)}
</div>
{step.details && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
{step.details}
</p>
)}
{step.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{step.error}
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
'use client';
import { Search, Brain, Package, Loader2, CheckCircle2, Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState, useEffect } from 'react';
export type ProcessingStep =
| 'collecting'
| 'processing'
| 'searching'
| 'preparing'
| 'streaming';
interface ProcessingIndicatorProps {
currentStep: ProcessingStep;
progress?: number; // 0-100
sourcesCount?: number;
}
export function ProcessingIndicator({
currentStep,
progress = 0,
sourcesCount = 0,
}: ProcessingIndicatorProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Fade-in animation
setIsVisible(true);
}, []);
const steps: Array<{
key: ProcessingStep;
label: string;
icon: React.ReactNode;
emoji: string;
}> = [
{
key: 'collecting',
label: 'Coletando Dados',
icon: <Search className="w-4 h-4" />,
emoji: '🔍',
},
{
key: 'processing',
label: 'Processando...',
icon: <Brain className="w-4 h-4" />,
emoji: '🤖',
},
{
key: 'searching',
label: sourcesCount > 0 ? `Buscando na Web (${sourcesCount} fontes)...` : 'Buscando na Web...',
icon: <Globe className="w-4 h-4" />,
emoji: '🌐',
},
{
key: 'preparing',
label: 'Preparando Contexto',
icon: <Package className="w-4 h-4" />,
emoji: '📦',
},
{
key: 'streaming',
label: 'Gerando Resposta...',
icon: <Loader2 className="w-4 h-4 animate-spin" />,
emoji: '💬',
},
];
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
const currentStepData = steps[currentStepIndex] || steps[0];
return (
<div
className={cn(
"mx-4 mb-3 px-4 py-3 rounded-lg border border-muted/50 bg-muted/20 transition-all duration-300",
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
)}
>
{/* Card de Status Atual */}
<div className="flex items-center gap-3 mb-3">
<div className="flex-shrink-0">
<div className="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
{currentStep === 'collecting' && (
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-ping" />
)}
{currentStep === 'processing' && (
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-pulse" />
)}
{currentStep === 'searching' && (
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-spin" style={{ animationDuration: '2s' }} />
)}
<span className="text-xl relative z-10">{currentStepData.emoji}</span>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">
{currentStepData.label}
</div>
{progress > 0 && progress < 100 && (
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
</div>
{/* Timeline Visual */}
<div className="hidden md:flex items-center gap-2 text-xs text-muted-foreground">
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
const isActive = idx === currentStepIndex;
const isPast = idx < currentStepIndex;
return (
<div key={step.key} className="flex items-center gap-1.5">
<div
className={cn(
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-300",
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-primary/20 text-primary animate-pulse",
!isActive && !isPast && "bg-muted text-muted-foreground"
)}
>
{isPast ? (
<CheckCircle2 className="w-3 h-3" />
) : isActive ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
step.icon
)}
</div>
<span
className={cn(
"transition-colors duration-300",
isActive && "text-foreground font-medium",
isPast && "text-muted-foreground opacity-60"
)}
>
{step.label.split(' ')[0]} {/* Mostrar apenas primeira palavra na timeline */}
</span>
{idx < currentStepIndex && (
<div className="w-6 h-px bg-muted mx-1" />
)}
</div>
);
})}
</div>
{/* Timeline Vertical para Mobile */}
<div className="md:hidden space-y-2">
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
const isActive = idx === currentStepIndex;
const isPast = idx < currentStepIndex;
return (
<div key={step.key} className="flex items-center gap-2">
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full transition-all",
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-primary/20 text-primary animate-pulse",
!isActive && !isPast && "bg-muted text-muted-foreground"
)}
>
{isPast ? (
<CheckCircle2 className="w-3 h-3" />
) : isActive ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
step.icon
)}
</div>
<span
className={cn(
"text-xs transition-colors",
isActive && "text-foreground font-medium",
isPast && "text-muted-foreground opacity-60"
)}
>
{step.label}
</span>
</div>
);
})}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { ExternalLink, Copy, Check, Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState, useMemo } from 'react';
import type { ScrapedContent } from '@/services/webSearch';
interface SourcesDisplayProps {
sources: ScrapedContent[];
className?: string;
}
export function SourcesDisplay({ sources, className }: SourcesDisplayProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
if (!sources || sources.length === 0) {
return null;
}
const copyToClipboard = async (url: string, index: number) => {
try {
await navigator.clipboard.writeText(url);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (error) {
console.error('Failed to copy URL:', error);
}
};
// Memoizar URLs de favicon para evitar recálculos
const faviconUrls = useMemo(() => {
return sources.map(source => {
try {
const domain = new URL(source.url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
} catch {
return null;
}
});
}, [sources]);
return (
<div className={cn("px-6 py-4 border-t border-muted bg-muted/20", className)}>
<div className="flex items-center gap-2 mb-3">
<Globe className="w-4 h-4 text-muted-foreground" />
<p className="text-xs font-medium text-muted-foreground">
Fontes consultadas ({sources.length}):
</p>
</div>
<div className="flex flex-wrap gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{sources.map((source, idx) => {
const faviconUrl = faviconUrls[idx];
const isCopied = copiedIndex === idx;
return (
<div
key={idx}
className="group flex-shrink-0 flex items-center gap-2 px-3 py-2 rounded-md bg-background hover:bg-muted/80 transition-all duration-200 border border-muted hover:border-primary/50 shadow-sm hover:shadow-md"
>
{/* Favicon */}
{faviconUrl ? (
<img
src={faviconUrl}
alt=""
className="w-4 h-4 flex-shrink-0 rounded-sm"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Globe className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
)}
{/* Title and URL */}
<div className="flex-1 min-w-0 max-w-[200px] md:max-w-[300px]">
<div className="text-xs font-medium text-foreground truncate" title={source.title || source.url}>
{source.title || `Fonte ${idx + 1}`}
</div>
<div className="text-[10px] text-muted-foreground truncate" title={source.url}>
{source.url.replace(/^https?:\/\//, '').replace(/^www\./, '')}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{/* Copy Button */}
<button
onClick={() => copyToClipboard(source.url, idx)}
className="p-1 rounded hover:bg-muted transition-colors"
title="Copiar URL"
aria-label="Copiar URL"
>
{isCopied ? (
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
) : (
<Copy className="w-3 h-3 text-muted-foreground" />
)}
</button>
{/* External Link Button */}
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded hover:bg-muted transition-colors"
title="Abrir em nova aba"
aria-label="Abrir em nova aba"
>
<ExternalLink className="w-3 h-3 text-muted-foreground" />
</a>
</div>
</div>
);
})}
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { Loader2, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
interface StreamingIndicatorProps {
isStreaming: boolean;
className?: string;
}
export function StreamingIndicator({ isStreaming, className }: StreamingIndicatorProps) {
const [dots, setDots] = useState('');
useEffect(() => {
if (!isStreaming) {
setDots('');
return;
}
const interval = setInterval(() => {
setDots(prev => {
if (prev === '...') return '';
return prev + '.';
});
}, 500);
return () => clearInterval(interval);
}, [isStreaming]);
if (!isStreaming) return null;
return (
<div className={cn("flex items-center gap-2 text-xs text-muted-foreground px-6 py-2", className)}>
<div className="relative">
<Sparkles className="w-3 h-3 text-primary animate-pulse" />
<div className="absolute inset-0 bg-primary/20 rounded-full animate-ping" />
</div>
<span>Streaming{dots}</span>
</div>
);
}
+17 -5
View File
@@ -3,8 +3,10 @@ import { cn } from '@/lib/utils';
export type ThinkingStep =
| 'analyzing'
| 'planning'
| 'searching'
| 'reading'
| 'validating'
| 'formulating'
| 'complete';
@@ -13,6 +15,7 @@ interface ThinkingIndicatorProps {
searchQuery?: string;
sourcesRead?: number;
totalSources?: number;
planSize?: number;
onComplete?: () => void;
}
@@ -21,6 +24,7 @@ export function ThinkingIndicator({
searchQuery,
sourcesRead = 0,
totalSources = 0,
planSize = 0,
}: ThinkingIndicatorProps) {
const steps: Array<{
key: ThinkingStep;
@@ -29,21 +33,29 @@ export function ThinkingIndicator({
}> = [
{
key: 'analyzing',
label: 'Analisando intenção...',
label: 'Analisando...',
icon: <Brain className="w-3 h-3" />,
},
{
key: 'planning',
label: 'Planejando pesquisa...',
icon: <Brain className="w-3 h-3" />,
},
{
key: 'searching',
label: searchQuery ? `Pesquisando: "${searchQuery}"` : 'Pesquisando na web...',
label: planSize > 0 ? `Pesquisando (${planSize} tópicos)...` : 'Pesquisando na web...',
icon: <Search className="w-3 h-3" />,
},
{
key: 'reading',
label: totalSources > 0
? `Lendo ${sourcesRead}/${totalSources} fontes...`
: 'Lendo conteúdo...',
label: 'Lendo conteúdo...',
icon: <BookOpen className="w-3 h-3" />,
},
{
key: 'validating',
label: 'Validando dados...',
icon: <CheckCircle2 className="w-3 h-3" />,
},
{
key: 'formulating',
label: 'Formulando resposta...',
+254 -25
View File
@@ -1,3 +1,6 @@
'use client';
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -7,7 +10,12 @@ import {
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Download } from "lucide-react"
import { Progress } from "@/components/ui/progress"
import { Download, Loader2, AlertCircle, CheckCircle2, Play } from "lucide-react"
import { useOllamaDownload } from "@/hooks/use-ollama-download"
import { useToast } from "@/hooks/use-toast"
import { useOperatingSystem } from "@/hooks/use-operating-system"
import type { OS } from "@/lib/download-utils"
interface InstallModalProps {
open: boolean;
@@ -15,6 +23,162 @@ interface InstallModalProps {
}
export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
const { os: detectedOS } = useOperatingSystem();
const [activeTab, setActiveTab] = useState<OS>('mac');
const downloadState = useOllamaDownload();
const { toast } = useToast();
// Detectar OS e definir aba ativa quando modal abrir
useEffect(() => {
if (open && detectedOS) {
setActiveTab(detectedOS);
}
}, [open, detectedOS]);
// Resetar estado quando o modal abrir
useEffect(() => {
if (open) {
downloadState.reset();
// Verificar se já existe download para a tab ativa
downloadState.checkExistingDownload(activeTab);
} else {
// Parar verificação automática quando modal fechar
downloadState.stopAutoCheck();
}
}, [open, activeTab]);
// Verificar download existente ao mudar de tab
useEffect(() => {
if (open) {
downloadState.checkExistingDownload(activeTab);
}
}, [activeTab, open]);
// Escutar evento de Ollama instalado
useEffect(() => {
if (!open) return;
const handleOllamaInstalled = () => {
// Ollama foi encontrado, verificar novamente para atualizar o estado
onCheckAgain();
};
window.addEventListener('ollama-installed', handleOllamaInstalled);
return () => {
window.removeEventListener('ollama-installed', handleOllamaInstalled);
};
}, [open, onCheckAgain]);
// Mostrar toast quando houver erro
useEffect(() => {
if (downloadState.downloadError) {
toast({
title: 'Erro no download',
description: downloadState.downloadError,
variant: 'destructive',
});
}
}, [downloadState.downloadError, toast]);
const handleDownload = async (os: OS) => {
await downloadState.handleDownload(os);
};
const handleInstall = async () => {
try {
await downloadState.handleInstall();
toast({
title: 'Instalador iniciado',
description: 'O instalador foi aberto. Siga as instruções na tela.',
});
} catch (error) {
toast({
title: 'Erro ao executar instalador',
description: error instanceof Error ? error.message : 'Erro desconhecido',
variant: 'destructive',
});
}
};
const DownloadButton = ({ os, label }: { os: OS; label: string }) => {
const isActive = activeTab === os;
const isDownloading = downloadState.isDownloading && isActive;
const hasDownloaded = downloadState.filePath !== null && isActive;
if (hasDownloaded && !isDownloading) {
return (
<Button
variant="default"
className="w-full"
onClick={handleInstall}
>
<Play className="mr-2 h-4 w-4" />
Instalar Ollama
</Button>
);
}
return (
<Button
variant="outline"
className="w-full"
onClick={() => handleDownload(os)}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{downloadState.downloadStatus || 'Baixando...'}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{label}
</>
)}
</Button>
);
};
const DownloadProgress = ({ os }: { os: OS }) => {
const isActive = activeTab === os;
const isDownloading = downloadState.isDownloading && isActive;
const hasDownloaded = downloadState.filePath !== null && isActive;
if (!isDownloading && !hasDownloaded) {
return null;
}
return (
<div className="space-y-2">
{isDownloading && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{downloadState.downloadStatus || 'Baixando...'}
</span>
<span className="font-medium">{downloadState.downloadProgress}%</span>
</div>
<Progress value={downloadState.downloadProgress} className="h-2" />
</>
)}
{hasDownloaded && !isDownloading && (
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
<div className="text-sm flex-1">
<p className="font-medium text-green-900 dark:text-green-100">
Download concluído
</p>
<p className="text-green-700 dark:text-green-300">
Clique em "Instalar Ollama" para iniciar a instalação.
</p>
</div>
</div>
)}
</div>
);
};
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-[600px]">
@@ -22,29 +186,41 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
<DialogTitle>Ollama não encontrado</DialogTitle>
<DialogDescription>
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
Siga os passos abaixo para o seu sistema operacional.
Baixe e instale o Ollama usando os passos abaixo.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="mac" className="w-full">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as OS)} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="mac">macOS</TabsTrigger>
<TabsTrigger value="linux">Linux</TabsTrigger>
<TabsTrigger value="windows">Windows</TabsTrigger>
</TabsList>
<TabsContent value="mac" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">1. Download e Instalação</h3>
<Button variant="outline" className="w-full" asChild>
<a href="https://ollama.com/download/Ollama-darwin.zip" target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Baixar para macOS
</a>
</Button>
<DownloadButton os="mac" label="Baixar para macOS" />
<DownloadProgress os="mac" />
</div>
<div className="text-sm text-muted-foreground">
Após instalar, abra o terminal e digite <code>ollama serve</code> se necessário.
{downloadState.downloadError && activeTab === 'mac' && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
<div className="text-sm">
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-red-700 dark:text-red-300">
{downloadState.downloadError}
</p>
</div>
</div>
)}
<div className="space-y-2 text-sm text-muted-foreground">
<p>Após instalar, o Ollama será iniciado automaticamente. Se necessário, abra o terminal e digite <code className="bg-muted px-1 rounded">ollama serve</code>.</p>
<p className="font-medium text-foreground">
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
</p>
</div>
</TabsContent>
<TabsContent value="linux" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">Instalação via Terminal</h3>
@@ -52,35 +228,88 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
<code className="text-sm break-all">curl -fsSL https://ollama.com/install.sh | sh</code>
</div>
</div>
<div className="space-y-2">
<div className="space-y-2">
<h3 className="font-medium">Ou baixar e instalar via interface</h3>
<DownloadButton os="linux" label="Baixar Script de Instalação" />
<DownloadProgress os="linux" />
</div>
{downloadState.downloadError && activeTab === 'linux' && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
<div className="text-sm">
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-red-700 dark:text-red-300">
{downloadState.downloadError}
</p>
</div>
</div>
)}
<div className="space-y-2">
<h3 className="font-medium">Comando Manual</h3>
<div className="text-sm text-muted-foreground">
<div className="text-sm text-muted-foreground">
Ou consulte <a href="https://ollama.com/download/linux" className="underline text-primary" target="_blank" rel="noopener noreferrer">ollama.com/download/linux</a> para instruções manuais.
</div>
</div>
<p className="text-sm font-medium text-foreground">
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
</p>
</div>
</TabsContent>
<TabsContent value="windows" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">1. Download do Instalador</h3>
<Button variant="outline" className="w-full" asChild>
<a href="https://ollama.com/download/OllamaSetup.exe" target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Baixar para Windows
</a>
</Button>
<DownloadButton os="windows" label="Baixar para Windows" />
<DownloadProgress os="windows" />
</div>
<div className="text-sm text-muted-foreground">
Execute o instalador baixado e siga as instruções na tela.
{downloadState.downloadError && activeTab === 'windows' && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
<div className="text-sm">
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-red-700 dark:text-red-300">
{downloadState.downloadError}
</p>
</div>
</div>
)}
<div className="space-y-2 text-sm text-muted-foreground">
<p>Após clicar em "Instalar Ollama", o instalador será aberto. Siga as instruções na tela para completar a instalação.</p>
<p className="font-medium text-foreground">
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
</p>
</div>
</TabsContent>
</Tabs>
{/* Status de verificação automática */}
{downloadState.isChecking && (
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md mt-4">
<Loader2 className="h-4 w-4 mt-0.5 text-blue-600 dark:text-blue-400 animate-spin" />
<div className="text-sm flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100">
Verificando instalação do Ollama...
</p>
<p className="text-blue-700 dark:text-blue-300">
{downloadState.checkStatus || 'Aguardando instalação ser concluída...'}
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
Esta verificação continuará em segundo plano até encontrar o Ollama.
</p>
</div>
</div>
)}
<div className="flex justify-end gap-2 mt-4">
<Button onClick={onCheckAgain}>
Verificar Novamente
<Button
onClick={() => {
// Chamar verificação completa (instalação + execução)
onCheckAgain();
}}
>
Verificar Novamente
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,67 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Heart } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function WelcomeNotification() {
const [show, setShow] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Verificar se já foi mostrado
const welcomeShown = localStorage.getItem('ollahub_welcome_shown');
if (!welcomeShown) {
setShow(true);
// Marcar como mostrado
localStorage.setItem('ollahub_welcome_shown', 'true');
// Auto-fechar após 5 segundos
const timer = setTimeout(() => {
setShow(false);
}, 5000);
return () => clearTimeout(timer);
}
}, []);
if (!mounted || !show) {
return null;
}
return (
<div
className={cn(
"fixed top-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md",
"animate-in slide-in-from-top-5 fade-in duration-500"
)}
>
<div className="bg-primary text-primary-foreground rounded-lg shadow-lg p-4 mx-4">
<div className="flex items-start gap-3">
<Heart className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-sm mb-1">
Obrigado por usar o OllaHub! 🎉
</h3>
<p className="text-xs text-primary-foreground/90">
Estamos felizes em tê-lo conosco. Comece a explorar e aproveite sua experiência!
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-primary-foreground hover:bg-primary-foreground/20 flex-shrink-0"
onClick={() => setShow(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Check, Cpu, Monitor, Zap, Laptop } from "lucide-react";
import { GpuInfo } from "@/lib/recommendation";
import { useSettingsStore } from "@/store/settings-store";
import { cn } from "@/lib/utils";
interface GpuSelectorProps {
gpus: GpuInfo[];
}
export function GpuSelector({ gpus }: GpuSelectorProps) {
const selectedGpu = useSettingsStore((state) => state.selectedGpu);
const setSelectedGpu = useSettingsStore((state) => state.setSelectedGpu);
// Se não há GPUs ou apenas uma, não mostrar seletor
if (gpus.length === 0) {
return null;
}
// Se há apenas uma GPU, selecionar automaticamente
if (gpus.length === 1 && !selectedGpu) {
setSelectedGpu(gpus[0].id);
}
// Selecionar primeira GPU por padrão se nenhuma estiver selecionada
useEffect(() => {
if (!selectedGpu && gpus.length > 0) {
setSelectedGpu(gpus[0].id);
}
}, [selectedGpu, gpus, setSelectedGpu]);
const getVendorIcon = (vendor: string | null) => {
const iconClass = "h-5 w-5";
switch (vendor?.toUpperCase()) {
case 'NVIDIA':
return <Zap className={cn(iconClass, "text-green-500")} />;
case 'AMD':
return <Zap className={cn(iconClass, "text-red-500")} />;
case 'INTEL':
return <Cpu className={cn(iconClass, "text-blue-500")} />;
case 'APPLE':
return <Monitor className={cn(iconClass, "text-gray-500")} />;
default:
return <Monitor className={cn(iconClass, "text-muted-foreground")} />;
}
};
const formatMemory = (memoryMb: number | null) => {
if (!memoryMb) return null;
if (memoryMb >= 1024) {
return `${(memoryMb / 1024).toFixed(1)} GB`;
}
return `${memoryMb} MB`;
};
return (
<Card className="w-full max-w-2xl mx-auto bg-muted/30 animate-in fade-in slide-in-from-bottom-4">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Monitor className="text-primary" />
Placas de Vídeo Detectadas
</CardTitle>
<CardDescription>
Selecione qual GPU você deseja usar para processamento de IA
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{gpus.map((gpu) => {
const isSelected = selectedGpu === gpu.id;
const memoryStr = formatMemory(gpu.memory_mb);
return (
<button
key={gpu.id}
onClick={() => setSelectedGpu(gpu.id)}
className={cn(
"relative p-4 rounded-lg border-2 transition-all text-left",
"hover:border-primary/50 hover:bg-accent/50",
isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border bg-background"
)}
>
{isSelected && (
<div className="absolute top-2 right-2">
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-start gap-2">
<div className="mt-0.5">
{getVendorIcon(gpu.vendor)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate">{gpu.name}</h3>
{gpu.vendor && (
<Badge variant="outline" className="mt-1 text-xs">
{gpu.vendor}
</Badge>
)}
</div>
</div>
{memoryStr && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Cpu className="h-3 w-3" />
<span>{memoryStr} VRAM</span>
</div>
)}
{!memoryStr && gpu.vendor && (
<div className="text-xs text-muted-foreground">
{gpu.vendor === 'Intel' ? 'GPU Integrada' : 'GPU Dedicada'}
</div>
)}
</div>
</button>
);
})}
</div>
{gpus.length === 1 && (
<p className="text-sm text-muted-foreground mt-4 text-center">
Apenas uma GPU foi detectada e será usada automaticamente.
</p>
)}
</CardContent>
</Card>
);
}
+72 -13
View File
@@ -14,12 +14,22 @@ interface ModelSelectorProps {
onComplete: () => void;
}
interface DownloadInfo {
status: string;
percent?: number;
downloaded?: string;
total?: string;
speed?: string;
raw: string;
}
export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState(recommendation.modelId);
const [isInstalled, setIsInstalled] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState("");
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
@@ -29,25 +39,60 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
useEffect(() => {
const unlisten = listen<string>('download-progress', (event) => {
const line = event.payload;
setDownloadStatus(line);
const payload = event.payload;
// Simple heuristic parsing for progress
// Ollama output example: "pulling manifest", "downloading 10%", "verifying sha256 digest"
if (line.includes('%')) {
const match = line.match(/(\d+)%/);
if (match) {
setDownloadProgress(parseInt(match[1], 10));
// Tentar parsear como JSON estruturado
let info: DownloadInfo | null = null;
try {
info = JSON.parse(payload) as DownloadInfo;
} catch {
// Fallback: tratar como string raw
info = {
status: "downloading",
raw: payload,
};
}
if (info) {
setDownloadInfo(info);
// Atualizar status text
const statusText = info.status === "pulling" ? "Baixando modelo..." :
info.status === "verifying" ? "Verificando..." :
info.status === "writing" ? "Salvando..." :
info.status === "success" ? "Concluído!" :
"Baixando...";
setDownloadStatus(statusText);
// Atualizar progresso
if (info.percent !== undefined) {
setDownloadProgress(info.percent);
} else {
// Fallback: estimar progresso baseado no status
if (info.status === "pulling") {
setDownloadProgress(prev => prev < 10 ? 10 : prev);
} else if (info.status === "verifying") {
setDownloadProgress(90);
} else if (info.status === "writing") {
setDownloadProgress(95);
}
}
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
if (info.percent === 100 || info.status === "success") {
setTimeout(() => {
setIsInstalled(true);
setIsDownloading(false);
checkInstallation(selectedModelId);
}, 500);
}
} else if (line.includes('success')) {
setDownloadProgress(100);
}
});
return () => {
unlisten.then(f => f());
};
}, []);
}, [selectedModelId]);
const checkInstallation = async (modelId: string) => {
try {
@@ -61,13 +106,15 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
const handleDownload = async () => {
setIsDownloading(true);
setDownloadProgress(0);
setDownloadStatus("Iniciando download...");
try {
await invoke('pull_model', { name: selectedModelId });
setIsInstalled(true);
setIsDownloading(false);
// O status será atualizado pelo listener quando o download completar
// Não precisamos setar aqui pois o listener já faz isso
} catch (error) {
console.error("Download failed", error);
setDownloadStatus("Falha no download. Tente novamente.");
setDownloadProgress(0);
setIsDownloading(false);
}
};
@@ -128,6 +175,18 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
<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>
)}
</CardContent>
+46
View File
@@ -0,0 +1,46 @@
export const BATCH_VERIFY_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.
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.
Example Output for "Onde fica Garuva?":
["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"]`,
VALIDATION: `Role: Fact Checker.
Task: Analyze the following search results from multiple sources. Identify consensus and discrepancies.
Search Results:
{{searchResults}}
Output structure:
1. Consensus: Facts agreed upon by multiple sources.
2. Discrepancies: Conflicts between sources (cite sources).
3. Reliability: Which sources seem most authoritative?
4. Verdict: Can we answer the user's query with high confidence? (YES/NO/PARTIAL)
Keep it concise and factual.`,
SYNTHESIS: `Role: Senior Analyst.
Task: Generate a final response based on the validation report and search results.
Validation Report:
{{validationReport}}
Original Query: "{{userQuery}}"
Protocol:
1. Veredito Direto: The consolidated answer.
2. Evidências Cruzadas: A comparison table or list showing consensus.
3. Fontes: List key URLs used.
4. If data is insufficient/conflicting, state it clearly.
Language: Portuguese (Brazil).`
};
+67
View File
@@ -0,0 +1,67 @@
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.
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.
Example Output for "Onde fica Garuva?":
["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"]`,
CONTEXT_CHECK: `Role: Fact Checker.
Task: Analyze the Knowledge Base below and determine if it contains sufficient information to answer the user's query.
## KNOWLEDGE BASE
{{knowledgeBase}}
## USER QUERY
"{{userQuery}}"
## OUTPUT
1. **Sufficiency**: Does the Knowledge Base contain enough information to answer the query? (YES/NO/PARTIAL)
2. **Key Facts Found**: List the main facts relevant to the query (bullet points).
3. **Missing Information**: What information is NOT in the Knowledge Base but would be needed?
4. **Reliability Assessment**: Rate the overall reliability of sources (HIGH/MEDIUM/LOW).
Be concise and factual.`,
STRICT_GENERATION: `Role: Rigorous Fact-Based Reporter.
## CRITICAL INSTRUCTIONS
You are a reporter who can ONLY write what is explicitly stated in the KNOWLEDGE BASE below.
- EVERY claim MUST be supported by the Knowledge Base.
- Use citations in the format [ID: X] after each fact.
- If a fact is NOT in the Knowledge Base, DO NOT WRITE IT.
- If you cannot answer the question from the Knowledge Base, say: "Não encontrei informações suficientes nas fontes consultadas para responder completamente."
- DO NOT use your internal knowledge. Pretend you know NOTHING except what is in the Knowledge Base.
- Language: Portuguese (Brazil).
## KNOWLEDGE BASE (Your ONLY source of truth)
{{knowledgeBase}}
## VALIDATION REPORT
{{validationReport}}
## USER QUERY
"{{userQuery}}"
## YOUR RESPONSE
Write a comprehensive answer using ONLY the Knowledge Base above. Cite sources using [ID: X].`,
CITATION_FORMAT: `
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.
`
};
+190
View File
@@ -0,0 +1,190 @@
import { useState, useCallback } from 'react';
import { BATCH_VERIFY_PROMPTS } from '@/data/prompts/batch-verify';
import type { ScrapedContent } from '@/services/webSearch';
export type BatchVerifyStep = 'idle' | 'planning' | 'searching' | 'validating' | 'formulating' | 'complete' | 'error';
export interface BatchVerifyLog {
stage: 'decomposition' | 'validation' | 'fallback';
timestamp: number;
input: string;
rawOutput?: string;
parsedOutput?: any;
error?: string;
}
export interface BatchVerifyState {
step: BatchVerifyStep;
plan: string[];
results: Map<string, ScrapedContent[]>;
validationReport: string;
error?: string;
logs: BatchVerifyLog[];
}
export function useBatchVerify() {
const [state, setState] = useState<BatchVerifyState>({
step: 'idle',
plan: [],
results: new Map(),
validationReport: '',
logs: [],
});
const addLog = (log: BatchVerifyLog) => {
setState(s => ({ ...s, logs: [...s.logs, log] }));
};
const callLLM = async (model: string, prompt: string, json = false) => {
try {
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
format: json ? 'json' : undefined,
options: {
temperature: 0.3,
num_predict: 1024,
},
}),
});
if (!response.ok) throw new Error('Failed to call LLM');
const data = await response.json();
return data.response;
} catch (e) {
console.error('LLM Call Error:', e);
throw e;
}
};
const decompose = useCallback(async (query: string, model: string) => {
setState(s => ({ ...s, step: 'planning', error: undefined, logs: [] }));
const startTime = Date.now();
try {
const prompt = BATCH_VERIFY_PROMPTS.DECOMPOSITION.replace('{{userQuery}}', query);
const response = await callLLM(model, prompt, true);
// Parse JSON response
let plan: string[] = [];
let parseError = undefined;
try {
plan = JSON.parse(response);
if (!Array.isArray(plan)) throw new Error('Not an array');
} catch (e) {
console.warn('Failed to parse JSON plan, falling back to raw split', e);
parseError = 'JSON Parse Failed';
plan = response.split('\n').filter((l: string) => l.trim().length > 0).slice(0, 3);
}
// --- Safety Check ---
// Ensure at least one generated query contains keywords from the original query
// This prevents hallucinations like "Garuva" -> "brackets"
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3); // filter small words
const isRelevant = plan.some(q =>
keywords.some(k => q.toLowerCase().includes(k))
);
if (!isRelevant && keywords.length > 0) {
console.warn('Decomposition Safety Check Failed: Generated queries are irrelevant.', { plan, query });
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: 'Safety Check Failed: Plan irrelevant to query'
});
// Fallback to original query
plan = [query];
} else {
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: parseError
});
}
setState(s => ({ ...s, plan }));
return plan;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState(s => ({ ...s, step: 'error', error: 'Failed to create research plan' }));
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: query,
error: errorMessage
});
return [];
}
}, []);
const validate = useCallback(async (results: Map<string, ScrapedContent[]>, model: string) => {
setState(s => ({ ...s, step: 'validating', results }));
try {
let resultsText = '';
results.forEach((docs, query) => {
resultsText += `\nQuery: "${query}"\n`;
docs.forEach(doc => {
resultsText += `- [${doc.title}](${doc.url}): ${doc.markdown.substring(0, 300)}...\n`;
});
});
const prompt = BATCH_VERIFY_PROMPTS.VALIDATION.replace('{{searchResults}}', resultsText);
const report = await callLLM(model, prompt);
addLog({
stage: 'validation',
timestamp: Date.now(),
input: prompt,
rawOutput: report,
parsedOutput: report
});
setState(s => ({ ...s, validationReport: report }));
return report;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState(s => ({ ...s, step: 'error', error: 'Validation failed' }));
addLog({
stage: 'validation',
timestamp: Date.now(),
input: 'Validation phase',
error: errorMessage
});
return '';
}
}, []);
const reset = useCallback(() => {
setState({
step: 'idle',
plan: [],
results: new Map(),
validationReport: '',
logs: [],
});
}, []);
return {
state,
decompose,
validate,
reset,
setStep: (step: BatchVerifyStep) => setState(s => ({ ...s, step }))
};
}
+275 -118
View File
@@ -3,10 +3,34 @@ import { invoke } from '@tauri-apps/api/core';
import { removeMetadataNoise } from '@/lib/metadata';
import { useSettingsStore } from '@/store/settings-store';
export type ThinkingStepType =
| 'preprocessing'
| 'web-research'
| 'sources-found'
| 'processing'
| 'response-generation'
| 'fallback'
| 'error';
export type ThinkingStepStatus = 'running' | 'completed' | 'error';
export interface ThinkingMessageMetadata {
type: 'thinking';
stepType: ThinkingStepType;
status: ThinkingStepStatus;
label: string;
progress?: number; // 0-100
details?: string;
sources?: Array<{ url: string; title: string }>;
error?: string;
timestamp: number;
duration?: number;
}
export interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
metadata?: any;
metadata?: any | ThinkingMessageMetadata;
}
interface ToolCall {
@@ -209,10 +233,13 @@ export function useChat() {
) => {
setIsLoading(true);
const newMessages: Message[] = [
...messages,
{ role: 'user', content }
];
// Verificar se a última mensagem já é do usuário (para evitar duplicação)
// Se for, usar as mensagens existentes; caso contrário, adicionar nova mensagem do usuário
const lastMessage = messages[messages.length - 1];
const newMessages: Message[] = lastMessage?.role === 'user' && lastMessage?.content === content
? [...messages] // Já existe, não adicionar novamente
: [...messages, { role: 'user', content }]; // Adicionar nova mensagem do usuário
setMessages(newMessages);
// Prepare messages for API (include system prompt if present)
@@ -301,130 +328,260 @@ export function useChat() {
abortControllerRef.current = new AbortController();
try {
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: apiMessages,
stream: true,
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) throw new Error('Failed to send message');
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Reset raw content buffer for this message
rawContentRef.current = '';
// Add empty assistant message to start streaming into
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
let streamFinished = false;
let metadataProcessed = false;
while (true) {
const { done, value } = await reader.read();
if (done) {
streamFinished = true;
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
try {
const json = JSON.parse(line);
if (json.message?.content) {
// Accumulate raw content (including metadata) in ref
rawContentRef.current += json.message.content;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// For display: strip incomplete metadata tag if present (opening tag but not closed)
// This prevents showing "<metadata>..." in the UI while streaming
const displayContent = stripMetadataTag(rawContentRef.current);
return [
...prev.slice(0, -1),
{ ...last, content: displayContent }
];
}
return prev;
});
}
if (json.done) {
streamFinished = true;
metadataProcessed = true;
// Process metadata FIRST before setting isLoading to false
// This prevents race condition with saveSession useEffect
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// Parse metadata from raw accumulated content
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
// Fallback: if content is empty after parse but we had content before,
// keep the stripped version (without metadata tag) to preserve user-visible text
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
// Clear ref for next message
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
}
return prev;
});
// Set loading to false AFTER processing metadata
setIsLoading(false);
break; // Exit inner loop when done
}
} catch (e) {
console.error('Error parsing chunk', e);
// Função auxiliar para tentar enviar mensagem (usada no retry)
const attemptSend = async (): Promise<void> => {
// Verificar se Ollama está rodando antes de fazer requisição
try {
const isRunning = await invoke<boolean>('check_ollama_running');
if (!isRunning) {
throw new Error('OllamaNotRunning');
}
} catch (checkError: any) {
if (checkError.message === 'OllamaNotRunning') {
throw checkError;
}
console.warn('Erro ao verificar status do Ollama:', checkError);
// Continuar mesmo se a verificação falhar
}
}
// If stream finished without explicit json.done, process metadata anyway
if (streamFinished && !metadataProcessed && rawContentRef.current) {
// Timeout configurável (padrão 5 minutos)
const timeoutMs = 5 * 60 * 1000; // 5 minutos
const timeoutId = setTimeout(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, timeoutMs);
let response: Response;
try {
response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: apiMessages,
stream: true,
}),
signal: abortControllerRef.current.signal,
});
} catch (fetchError: any) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error('RequestTimeout');
}
if (fetchError.message?.includes('Failed to fetch') || fetchError.message?.includes('NetworkError')) {
throw new Error('ConnectionError');
}
throw fetchError;
}
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 404) {
throw new Error('ModelNotFound');
}
if (response.status === 500) {
throw new Error('ServerError');
}
throw new Error(`HTTPError: ${response.status}`);
}
if (!response.body) throw new Error('NoResponseBody');
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Reset raw content buffer for this message
rawContentRef.current = '';
// Add empty assistant message to start streaming into
// Verificar se já existe uma mensagem vazia do assistente (adicionada pelo handleSend)
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
if (last && last.role === 'assistant' && last.content === '') {
// Já existe mensagem vazia, não adicionar outra
return prev;
}
return prev;
// Adicionar mensagem vazia do assistente
return [...prev, { role: 'assistant', content: '' }];
});
setIsLoading(false);
} else if (streamFinished && !metadataProcessed) {
// Stream finished but no content to process, just set loading to false
setIsLoading(false);
let streamFinished = false;
let metadataProcessed = false;
while (true) {
const { done, value } = await reader.read();
if (done) {
streamFinished = true;
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
// Validação básica: verificar se a linha parece ser JSON válido
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.length === 0) {
continue; // Linha vazia, ignorar
}
// Verificar se começa com { e termina com } (formato básico de JSON)
if (!trimmedLine.startsWith('{') || !trimmedLine.endsWith('}')) {
// Linha parcial ou inválida, ignorar silenciosamente
// Isso pode acontecer quando o streaming divide JSON em múltiplas chunks
continue;
}
try {
const json = JSON.parse(trimmedLine);
if (json.message?.content) {
// Accumulate raw content (including metadata) in ref
rawContentRef.current += json.message.content;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// For display: strip incomplete metadata tag if present (opening tag but not closed)
// This prevents showing "<metadata>..." in the UI while streaming
const displayContent = stripMetadataTag(rawContentRef.current);
return [
...prev.slice(0, -1),
{ ...last, content: displayContent }
];
}
return prev;
});
}
if (json.done) {
streamFinished = true;
metadataProcessed = true;
// Process metadata FIRST before setting isLoading to false
// This prevents race condition with saveSession useEffect
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// Parse metadata from raw accumulated content
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
// Fallback: if content is empty after parse but we had content before,
// keep the stripped version (without metadata tag) to preserve user-visible text
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
// Clear ref for next message
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
}
return prev;
});
// Set loading to false AFTER processing metadata
setIsLoading(false);
break; // Exit inner loop when done
}
} catch (parseError) {
// Log apenas em desenvolvimento para debug, não quebrar o fluxo
if (process.env.NODE_ENV === 'development') {
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
if (!errorMessage.includes('Unexpected end') && !errorMessage.includes('Unexpected token')) {
console.warn('Failed to parse JSON chunk:', errorMessage);
}
}
// Continuar processamento mesmo se uma linha falhar
continue;
}
}
}
// If stream finished without explicit json.done, process metadata anyway
if (streamFinished && !metadataProcessed && rawContentRef.current) {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
}
return prev;
});
setIsLoading(false);
} else if (streamFinished && !metadataProcessed) {
// Stream finished but no content to process, just set loading to false
setIsLoading(false);
}
};
// Tentar enviar com retry logic
let lastError: any = null;
const maxRetries = 2; // Máximo de 2 retries (3 tentativas no total)
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await attemptSend();
return; // Sucesso, sair do loop
} catch (error: any) {
lastError = error;
// Não fazer retry para certos erros
if (error.message === 'ModelNotFound' ||
error.message === 'OllamaNotRunning' ||
error.message === 'ConnectionError' ||
error.name === 'AbortError' ||
attempt === maxRetries) { // Última tentativa
break;
}
// Aguardar antes de tentar novamente (backoff exponencial)
if (attempt < maxRetries) {
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
console.log(`Tentativa ${attempt + 1} falhou, tentando novamente em ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Se chegou aqui, todas as tentativas falharam
throw lastError;
} catch (error: any) {
if (error.name === 'AbortError') {
console.log('Request aborted');
console.error('Chat error:', error);
let errorMessage = 'Erro ao obter resposta do Ollama.';
if (error.name === 'AbortError' || error.message === 'RequestTimeout') {
errorMessage = '**Timeout:** A requisição demorou muito para responder. Tente novamente ou use um modelo menor.';
} else if (error.message === 'OllamaNotRunning') {
errorMessage = '**Erro:** Ollama não está rodando. Por favor, inicie o Ollama e tente novamente.\n\nVocê pode verificar o status do Ollama na página inicial do aplicativo.';
} else if (error.message === 'ConnectionError' || error.message?.includes('Failed to fetch')) {
errorMessage = '**Erro de Conexão:** Não foi possível conectar ao Ollama. Verifique se o Ollama está rodando em `http://localhost:11434`.';
} else if (error.message === 'ModelNotFound') {
errorMessage = `**Modelo Não Encontrado:** O modelo "${model}" não está instalado. Por favor, baixe o modelo primeiro na página de configuração.`;
} else if (error.message === 'ServerError') {
errorMessage = '**Erro do Servidor:** O Ollama retornou um erro interno. Verifique os logs do Ollama para mais detalhes.';
} else if (error.message === 'NoResponseBody') {
errorMessage = '**Erro:** O Ollama não retornou uma resposta válida. Tente novamente.';
} else if (error.message?.includes('HTTPError')) {
const statusMatch = error.message.match(/HTTPError: (\d+)/);
const status = statusMatch ? statusMatch[1] : 'desconhecido';
errorMessage = `**Erro HTTP ${status}:** O Ollama retornou um erro. Verifique se o modelo está instalado e tente novamente.`;
} else {
console.error('Chat error:', error);
setMessages(prev => [...prev, { role: 'assistant', content: 'Error: Failed to get response from Ollama.' }]);
// Erro genérico com mais contexto
const errorDetails = error.message || error.toString();
errorMessage = `**Erro:** Falha ao obter resposta do Ollama.\n\nDetalhes: ${errorDetails}`;
}
setMessages(prev => [...prev, { role: 'assistant', content: errorMessage }]);
} finally {
setIsLoading(false);
abortControllerRef.current = null;
+524
View File
@@ -0,0 +1,524 @@
import { useState, useCallback } from 'react';
import { DEEP_RESEARCH_PROMPTS } from '@/data/prompts/deep-research';
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';
export type DeepResearchStep = 'idle' | 'planning' | 'searching' | 'aggregating' | 'validating' | 'formulating' | 'complete' | 'error';
export interface ResearchEntry {
id: string;
query: string;
sourceUrl: string;
title: string;
content: string;
timestamp: number;
}
export interface DeepResearchLog {
stage: 'decomposition' | 'search' | 'aggregation' | 'validation' | 'generation' | 'fallback';
timestamp: number;
input: string;
rawOutput?: string;
parsedOutput?: any;
error?: string;
}
export interface DeepResearchState {
step: DeepResearchStep;
plan: string[];
knowledgeBase: ResearchEntry[];
validationReport: string;
error?: string;
logs: DeepResearchLog[];
}
// ========== LOGGING UTILITIES ==========
function logSection(title: string) {
log.info(`\n========== ${title} ==========`);
}
function logStep(message: string) {
log.info(message);
}
function logData(label: string, data: any) {
if (typeof data === 'string' && data.length > 500) {
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
} else if (typeof data === 'object') {
log.info(`${label}: ${JSON.stringify(data, null, 2)}`);
} else {
log.info(`${label}: ${data}`);
}
}
function logError(message: string, error?: any) {
log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`);
}
function logSuccess(message: string) {
log.info(`✅ ${message}`);
}
/**
* Extracts an array of queries from various LLM response formats.
* Handles: plain arrays, objects with "queries"/"plan"/"searches" keys,
* markdown code blocks, and other common formats.
*/
function extractQueriesFromResponse(raw: string): string[] {
logStep('--- Parsing LLM Response ---');
logData('Raw response', raw);
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
let cleaned = raw
.replace(/```json\s*/gi, '')
.replace(/```\s*/g, '')
.trim();
// 2. Try to parse as JSON
let parsed: any;
try {
parsed = JSON.parse(cleaned);
logStep('Direct JSON parse successful');
} catch (e) {
logStep('Direct JSON parse failed, trying to extract JSON from string...');
// If JSON parse fails, try to find JSON-like content in the string
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
if (jsonMatch) {
try {
parsed = JSON.parse(jsonMatch[0]);
logStep('Extracted JSON from string successfully');
} catch (e2) {
logError('Could not extract JSON from response');
throw new Error('Could not extract JSON from response');
}
} else {
logError('No JSON found in response');
throw new Error('No JSON found in response');
}
}
// 3. Handle different formats
if (Array.isArray(parsed)) {
const result = parsed.filter(item => typeof item === 'string' && item.trim().length > 0);
logSuccess(`Parsed as plain array: ${result.length} queries`);
return result;
}
// 4. Check common object keys used by different models
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
for (const key of commonKeys) {
if (parsed[key] && Array.isArray(parsed[key])) {
const result = parsed[key].filter((item: any) => typeof item === 'string' && item.trim().length > 0);
logSuccess(`Found queries under key "${key}": ${result.length} queries`);
return result;
}
}
// 5. Fallback: find first array value in the object
for (const val of Object.values(parsed)) {
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
const result = (val as string[]).filter((item: any) => typeof item === 'string' && item.trim().length > 0);
logSuccess(`Found queries in object value: ${result.length} queries`);
return result;
}
}
logError('No array of queries found in response');
throw new Error('No array of queries found in response');
}
export function useDeepResearch() {
const [state, setState] = useState<DeepResearchState>({
step: 'idle',
plan: [],
knowledgeBase: [],
validationReport: '',
logs: [],
});
const addLog = (log: DeepResearchLog) => {
setState(s => ({ ...s, logs: [...s.logs, log] }));
};
const callLLM = async (model: string, prompt: string, json = false) => {
logStep(`Calling LLM (model: ${model}, json: ${json})`);
logData('Prompt length', `${prompt.length} chars`);
try {
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
format: json ? 'json' : undefined,
options: {
temperature: 0.2,
num_predict: 2048,
},
}),
});
if (!response.ok) {
logError(`LLM API returned status ${response.status}`);
throw new Error('Failed to call LLM');
}
const data = await response.json();
logSuccess(`LLM response received (${data.response?.length || 0} chars)`);
return data.response;
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
logError('LLM Call Error', errorMsg);
throw e;
}
};
// --- DECOMPOSITION ---
const decompose = useCallback(async (query: string, model: string) => {
logSection('DECOMPOSITION');
logData('User Query', query);
logData('Model', model);
setState(s => ({ ...s, step: 'planning', error: undefined, logs: [], knowledgeBase: [] }));
try {
const prompt = DEEP_RESEARCH_PROMPTS.DECOMPOSITION.replace('{{userQuery}}', query);
logStep('Sending decomposition prompt to LLM...');
const response = await callLLM(model, prompt, true);
let plan: string[] = [];
let parseError = undefined;
try {
plan = extractQueriesFromResponse(response);
logData('Parsed Plan', plan);
} catch (e) {
logError('Failed to extract queries, falling back to original query', e);
parseError = `JSON Parse Failed: ${e instanceof Error ? e.message : 'Unknown'}`;
plan = [query];
}
// Safety Check
logStep('--- Safety Check ---');
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
logData('Keywords from query', keywords);
const isRelevant = plan.some(q =>
keywords.some(k => q.toLowerCase().includes(k))
);
logData('Plan is relevant', isRelevant);
if (!isRelevant && keywords.length > 0) {
logError('Safety Check Failed: Plan is irrelevant to query');
logData('Generated plan', plan);
logStep('Falling back to original query');
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: 'Safety Check Failed: Plan irrelevant to query'
});
plan = [query];
} else {
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: parseError
});
}
setState(s => ({ ...s, plan }));
return plan;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logError('Decomposition failed', errorMessage);
setState(s => ({ ...s, step: 'error', error: 'Failed to create research plan' }));
addLog({ stage: 'decomposition', timestamp: Date.now(), input: query, error: errorMessage });
return [];
}
}, []);
// --- ADD TO KNOWLEDGE BASE ---
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
logStep(`--- Adding to Knowledge Base ---`);
logData('Query', query);
logData('Sources count', sources.length);
const entries: ResearchEntry[] = sources.map((src, idx) => ({
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
query,
sourceUrl: src.url,
title: src.title,
content: src.markdown,
timestamp: Date.now(),
}));
sources.forEach((src, idx) => {
logStep(` → Source ${idx + 1}: ${src.title} (${src.url})`);
const contentLength = src.markdown?.length || 0;
logData(` Content length`, `${contentLength} chars`);
if (src.markdown && contentLength > 0) {
const preview = src.markdown.substring(0, 300);
logData(` Preview`, preview + (contentLength > 300 ? '...' : ''));
} else {
logStep(` ⚠️ No content extracted`);
}
});
setState(s => ({
...s,
knowledgeBase: [...s.knowledgeBase, ...entries]
}));
addLog({
stage: 'search',
timestamp: Date.now(),
input: query,
parsedOutput: entries.map(e => ({
id: e.id,
title: e.title,
url: e.sourceUrl,
contentLength: e.content.length,
contentPreview: e.content.substring(0, 200) + (e.content.length > 200 ? '...' : '')
}))
});
logSuccess(`Added ${entries.length} entries to Knowledge Base`);
return entries;
}, []);
// --- DEDUPLICATE & FORMAT CONTEXT (LEGACY - mantido para compatibilidade) ---
const getCuratedContext = useCallback((maxTokens: number = 12000) => {
logSection('AGGREGATION');
const kb = state.knowledgeBase;
logData('Raw KB entries', kb.length);
if (kb.length === 0) {
logStep('Knowledge Base is empty, skipping aggregation');
return '';
}
// Deduplicate by URL
const seen = new Set<string>();
const unique = kb.filter(entry => {
if (seen.has(entry.sourceUrl)) return false;
seen.add(entry.sourceUrl);
return true;
});
logData('Unique entries (after dedup)', unique.length);
// Format as Knowledge Base
let context = '## KNOWLEDGE BASE (Verified Sources)\n\n';
let currentLength = context.length;
const avgCharsPerToken = 4;
const maxChars = maxTokens * avgCharsPerToken;
logData('Max chars allowed', maxChars);
unique.forEach((entry, idx) => {
const snippet = entry.content.length > 1500
? entry.content.substring(0, 1500) + '...'
: entry.content;
const block = `[ID: ${idx + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n${snippet}\n\n---\n\n`;
if (currentLength + block.length < maxChars) {
context += block;
currentLength += block.length;
} else {
logStep(` → Skipping entry ${idx + 1} (would exceed max chars)`);
}
});
addLog({
stage: 'aggregation',
timestamp: Date.now(),
input: `${kb.length} raw entries`,
parsedOutput: `${unique.length} unique entries, ${currentLength} chars`
});
logSuccess(`Knowledge Base formatted: ${unique.length} entries, ${currentLength} chars`);
return context;
}, [state.knowledgeBase]);
// --- OPTIMIZED CONTEXT WITH CONDENSATION ---
const getCuratedContextOptimized = useCallback((
query: string,
maxTokens: number = 12000,
options?: {
minRelevanceScore?: number;
autoSummarize?: boolean;
fallbackToSummarization?: boolean;
}
): { context: string; result: CondensationResult } => {
logSection('AGGREGATION (OPTIMIZED)');
const kb = state.knowledgeBase;
logData('Raw KB entries', kb.length);
logData('User Query', query);
logData('Max Tokens', maxTokens);
if (kb.length === 0) {
logStep('Knowledge Base is empty, skipping aggregation');
return {
context: '',
result: {
context: '',
totalTokens: 0,
originalTokens: 0,
compressionRatio: 0,
chunksUsed: 0,
chunksTotal: 0,
method: 'fallback',
sources: [],
},
};
}
// Deduplicate by URL
const seen = new Set<string>();
const unique = kb.filter(entry => {
if (seen.has(entry.sourceUrl)) return false;
seen.add(entry.sourceUrl);
return true;
});
logData('Unique entries (after dedup)', unique.length);
// Condensar Knowledge Base
const condensationResult = condenseKnowledgeBase(unique, query, {
maxTokens,
minRelevanceScore: options?.minRelevanceScore ?? 0.1,
autoSummarize: options?.autoSummarize ?? true,
fallbackToSummarization: options?.fallbackToSummarization ?? true,
});
// Log detalhado do resultado
logData('Condensation Method', condensationResult.method);
logData('Original Tokens', condensationResult.originalTokens);
logData('Final Tokens', condensationResult.totalTokens);
logData('Compression Ratio', `${(condensationResult.compressionRatio * 100).toFixed(1)}%`);
logData('Chunks Used', `${condensationResult.chunksUsed} / ${condensationResult.chunksTotal}`);
// Log preview dos chunks selecionados
if (condensationResult.chunksUsed > 0) {
logStep('--- Selected Chunks Preview ---');
// O contexto já contém os chunks, vamos logar um preview
const preview = condensationResult.context.substring(0, 500);
logData('Context Preview', preview + (condensationResult.context.length > 500 ? '...' : ''));
}
// Log estatísticas por fonte
logStep('--- Source Statistics ---');
condensationResult.sources.forEach((source, idx) => {
logData(`Source ${idx + 1}`, `${source.title}: ${source.chunksUsed} chunks used`);
});
// Validar contexto
const validation = validateCondensedContext(condensationResult, query);
if (!validation.isValid) {
logError('Context validation failed', validation.warnings.join(', '));
} else if (validation.warnings.length > 0) {
validation.warnings.forEach(warning => {
logStep(`⚠️ Warning: ${warning}`);
});
}
addLog({
stage: 'aggregation',
timestamp: Date.now(),
input: `${kb.length} raw entries, query: "${query}"`,
parsedOutput: {
method: condensationResult.method,
chunksUsed: condensationResult.chunksUsed,
chunksTotal: condensationResult.chunksTotal,
compressionRatio: condensationResult.compressionRatio,
totalTokens: condensationResult.totalTokens,
},
});
logSuccess(`Knowledge Base optimized: ${condensationResult.chunksUsed} chunks selected, ${condensationResult.totalTokens} tokens`);
return {
context: condensationResult.context,
result: condensationResult,
};
}, [state.knowledgeBase]);
// --- VALIDATION ---
const validate = useCallback(async (model: string, userQuery: string) => {
logSection('VALIDATION');
logData('User Query', userQuery);
setState(s => ({ ...s, step: 'validating' }));
const context = getCuratedContext();
if (!context || context.length < 100) {
logError('Insufficient context for validation');
addLog({ stage: 'validation', timestamp: Date.now(), input: 'No context', error: 'Insufficient data' });
return '';
}
try {
const prompt = DEEP_RESEARCH_PROMPTS.CONTEXT_CHECK
.replace('{{knowledgeBase}}', context)
.replace('{{userQuery}}', userQuery);
logStep('Sending validation prompt to LLM...');
logData('Prompt length', `${prompt.length} chars`);
const report = await callLLM(model, prompt);
logSuccess('Validation complete');
logData('Validation Report', report);
addLog({
stage: 'validation',
timestamp: Date.now(),
input: prompt.substring(0, 200) + '...',
rawOutput: report,
parsedOutput: report
});
setState(s => ({ ...s, validationReport: report }));
return report;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logError('Validation failed', errorMessage);
setState(s => ({ ...s, step: 'error', error: 'Validation failed' }));
addLog({ stage: 'validation', timestamp: Date.now(), input: 'Validation', error: errorMessage });
return '';
}
}, [getCuratedContext]);
// --- RESET ---
const reset = useCallback(() => {
logStep('Resetting Deep Research state');
setState({
step: 'idle',
plan: [],
knowledgeBase: [],
validationReport: '',
logs: [],
});
}, []);
return {
state,
decompose,
addToKnowledgeBase,
getCuratedContext,
getCuratedContextOptimized,
validate,
reset,
setStep: (step: DeepResearchStep) => setState(s => ({ ...s, step }))
};
}
+6 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { SystemSpecs } from '@/lib/recommendation';
import { SystemSpecs, GpuInfo } from '@/lib/recommendation';
export function useHardware() {
const [specs, setSpecs] = useState<SystemSpecs | null>(null);
@@ -23,6 +23,10 @@ export function useHardware() {
fetchSpecs();
}, []);
return { specs, loading };
return {
specs,
loading,
gpus: specs?.gpus || []
};
}
+28 -19
View File
@@ -7,28 +7,37 @@ export function useOllamaCheck() {
const [status, setStatus] = useState<OllamaStatus>('checking');
const check = async () => {
// Don't set checking here if we want to silently revalidate,
// but for initial load or explicit re-check it is fine.
// Only set checking if it's not already in a known state to avoid flicker?
// For now simple is better.
setStatus('checking');
try {
const installed = await invoke<boolean>('check_ollama_installed');
if (!installed) {
setStatus('not_installed');
return;
}
const running = await invoke<boolean>('check_ollama_running');
if (!running) {
setStatus('installed_stopped');
return;
}
setStatus('running');
// Usar verificação completa que checa instalação E execução
const result = await invoke<{
installed: boolean;
running: boolean;
status: string;
}>('check_ollama_full');
setStatus(result.status as OllamaStatus);
} catch (error) {
console.error('Failed to check ollama:', error);
// If invoke fails (e.g. backend not ready), we might want to handle gracefully
setStatus('not_installed');
// Se falhar, tentar verificação individual como fallback
try {
const installed = await invoke<boolean>('check_ollama_installed');
if (!installed) {
setStatus('not_installed');
return;
}
const running = await invoke<boolean>('check_ollama_running');
if (!running) {
setStatus('installed_stopped');
return;
}
setStatus('running');
} catch (fallbackError) {
console.error('Fallback check also failed:', fallbackError);
setStatus('not_installed');
}
}
};
+257
View File
@@ -0,0 +1,257 @@
import { useState, useEffect, useRef } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { downloadInstaller, getDownloadedInstallerPath, runInstaller, type OS } from '@/lib/download-utils';
interface DownloadState {
isDownloading: boolean;
downloadProgress: number;
downloadStatus: string;
downloadError: string | null;
filePath: string | null;
isInstalled: boolean;
isChecking: boolean;
checkStatus: string;
}
export function useOllamaDownload() {
const [state, setState] = useState<DownloadState>({
isDownloading: false,
downloadProgress: 0,
downloadStatus: '',
downloadError: null,
filePath: null,
isInstalled: false,
isChecking: false,
checkStatus: '',
});
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isCheckingRef = useRef(false);
// Escutar eventos de progresso do download
useEffect(() => {
const unlistenPromise = listen('installer-download-progress', (event: any) => {
const data = event.payload;
setState(prev => ({
...prev,
downloadProgress: data.progress || 0,
downloadStatus: data.status || '',
}));
});
return () => {
unlistenPromise.then(unlisten => unlisten());
};
}, []);
const handleDownload = async (os: OS) => {
setState({
isDownloading: true,
downloadProgress: 0,
downloadStatus: 'Iniciando download...',
downloadError: null,
filePath: null,
isInstalled: false,
});
try {
// Verificar se já foi baixado
const existingPath = await getDownloadedInstallerPath(os);
if (existingPath) {
setState({
isDownloading: false,
downloadProgress: 100,
downloadStatus: 'Download já concluído',
downloadError: null,
filePath: existingPath,
isInstalled: false,
});
return;
}
// Fazer download
const filePath = await downloadInstaller(os);
setState({
isDownloading: false,
downloadProgress: 100,
downloadStatus: 'Download concluído',
downloadError: null,
filePath,
isInstalled: false,
});
} catch (error) {
setState({
isDownloading: false,
downloadProgress: 0,
downloadStatus: '',
downloadError: error instanceof Error ? error.message : 'Erro desconhecido durante download',
filePath: null,
isInstalled: false,
});
}
};
const handleInstall = async () => {
if (!state.filePath) {
setState(prev => ({
...prev,
downloadError: 'Nenhum instalador disponível',
}));
return;
}
try {
await runInstaller(state.filePath);
setState(prev => ({
...prev,
isInstalled: true,
}));
// Iniciar verificação automática em segundo plano
startAutoCheck();
} catch (error) {
setState(prev => ({
...prev,
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
}));
}
};
const checkOllama = async (): Promise<boolean> => {
try {
const installed = await invoke<boolean>('check_ollama_installed');
if (!installed) {
return false;
}
const running = await invoke<boolean>('check_ollama_running');
return running;
} catch (error) {
console.error('Erro ao verificar Ollama:', error);
return false;
}
};
const startAutoCheck = () => {
// Limpar intervalo anterior se existir
if (checkingIntervalRef.current) {
clearInterval(checkingIntervalRef.current);
}
if (isCheckingRef.current) {
return; // Já está verificando
}
isCheckingRef.current = true;
setState(prev => ({
...prev,
isChecking: true,
checkStatus: 'Verificando instalação do Ollama...',
}));
let attempts = 0;
const maxAttempts = 300; // 5 minutos (300 * 1 segundo)
checkingIntervalRef.current = setInterval(async () => {
attempts++;
const isRunning = await checkOllama();
if (isRunning) {
// Ollama encontrado e rodando!
if (checkingIntervalRef.current) {
clearInterval(checkingIntervalRef.current);
checkingIntervalRef.current = null;
}
isCheckingRef.current = false;
setState(prev => ({
...prev,
isChecking: false,
checkStatus: 'Ollama encontrado e rodando!',
}));
// Emitir evento customizado para notificar o componente pai
window.dispatchEvent(new CustomEvent('ollama-installed'));
} else if (attempts >= maxAttempts) {
// Limite de tentativas atingido
if (checkingIntervalRef.current) {
clearInterval(checkingIntervalRef.current);
checkingIntervalRef.current = null;
}
isCheckingRef.current = false;
setState(prev => ({
...prev,
isChecking: false,
checkStatus: 'Verificação automática pausada. Use "Verificar Novamente" para continuar.',
}));
} else {
setState(prev => ({
...prev,
checkStatus: `Verificando... (${attempts}/${maxAttempts})`,
}));
}
}, 1000); // Verificar a cada 1 segundo
};
const stopAutoCheck = () => {
if (checkingIntervalRef.current) {
clearInterval(checkingIntervalRef.current);
checkingIntervalRef.current = null;
}
isCheckingRef.current = false;
setState(prev => ({
...prev,
isChecking: false,
checkStatus: '',
}));
};
// Limpar intervalo ao desmontar
useEffect(() => {
return () => {
if (checkingIntervalRef.current) {
clearInterval(checkingIntervalRef.current);
}
};
}, []);
const checkExistingDownload = async (os: OS) => {
const existingPath = await getDownloadedInstallerPath(os);
if (existingPath) {
setState(prev => ({
...prev,
filePath: existingPath,
downloadProgress: 100,
downloadStatus: 'Download já concluído',
}));
}
};
const reset = () => {
stopAutoCheck();
setState({
isDownloading: false,
downloadProgress: 0,
downloadStatus: '',
downloadError: null,
filePath: null,
isInstalled: false,
isChecking: false,
checkStatus: '',
});
};
return {
...state,
handleDownload,
handleInstall,
checkExistingDownload,
startAutoCheck,
stopAutoCheck,
reset,
};
}
+56
View File
@@ -0,0 +1,56 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { OS } from '@/lib/download-utils';
export function useOperatingSystem() {
const [os, setOs] = useState<OS | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const detectOS = async () => {
try {
const detectedOS = await invoke<string>('get_operating_system');
// Mapear para o tipo OS
if (detectedOS === 'windows' || detectedOS === 'mac' || detectedOS === 'linux') {
setOs(detectedOS as OS);
} else {
// Fallback: tentar detectar via user agent
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('win')) {
setOs('windows');
} else if (userAgent.includes('mac')) {
setOs('mac');
} else if (userAgent.includes('linux')) {
setOs('linux');
} else {
setOs('windows'); // Default fallback
}
}
} catch (error) {
console.error('Failed to detect OS:', error);
// Fallback: detectar via user agent
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('win')) {
setOs('windows');
} else if (userAgent.includes('mac')) {
setOs('mac');
} else if (userAgent.includes('linux')) {
setOs('linux');
} else {
setOs('windows'); // Default fallback
}
} finally {
setLoading(false);
}
};
detectOS();
}, []);
return { os, loading };
}
+182
View File
@@ -0,0 +1,182 @@
import { useState, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { normalizeText } from '@/lib/text-normalization';
import { validateQuery, ValidationResult } from '@/lib/query-validation';
import { splitQuestions, SplitResult } from '@/lib/question-splitter';
import { useSettingsStore } from '@/store/settings-store';
export type QueryIntent =
| 'factual'
| 'conversational'
| 'technical'
| 'opinion'
| 'calculation'
| 'unknown';
export interface PreprocessedQuery {
original: string;
normalized: string;
questions: string[];
intent: QueryIntent;
validation: ValidationResult;
shouldSearch: boolean;
splitResult: SplitResult;
}
export interface PreprocessOptions {
skipValidation?: boolean;
skipSplit?: boolean;
skipIntentClassification?: boolean;
}
/**
* Hook para pré-processamento de queries do usuário
* Orquestra normalização, validação, split e classificação de intenção
*/
export function useQueryPreprocessor() {
const [isProcessing, setIsProcessing] = useState(false);
const settings = useSettingsStore();
/**
* Pré-processa uma query do usuário
*
* @param text - Texto original da query
* @param options - Opções de pré-processamento
* @returns Query pré-processada
*/
const preprocess = useCallback(
async (
text: string,
options: PreprocessOptions = {}
): Promise<PreprocessedQuery> => {
setIsProcessing(true);
try {
const original = text;
const normalized = normalizeText(text);
// 1. Validação
let validation: ValidationResult;
if (options.skipValidation) {
validation = {
isValid: true,
errors: [],
warnings: [],
normalizedLength: normalized.length,
};
} else {
const preprocessingConfig = settings.queryPreprocessing || {
enabled: true,
minLength: 3,
maxLength: 2000,
autoSplitQuestions: true,
irrelevantPatterns: [],
};
validation = validateQuery(text, {
minLength: preprocessingConfig.minLength,
maxLength: preprocessingConfig.maxLength,
irrelevantPatterns: preprocessingConfig.irrelevantPatterns.length > 0
? preprocessingConfig.irrelevantPatterns
: undefined,
});
}
// Se inválida, retornar com erro
if (!validation.isValid) {
return {
original,
normalized,
questions: [],
intent: 'unknown',
validation,
shouldSearch: false,
splitResult: {
questions: [],
originalText: original,
splitCount: 0,
},
};
}
// 2. Split de perguntas
let splitResult: SplitResult;
if (options.skipSplit) {
splitResult = {
questions: [original],
originalText: original,
splitCount: 1,
};
} else {
splitResult = splitQuestions(original);
}
// 3. Classificação de intenção
let intent: QueryIntent = 'unknown';
if (!options.skipIntentClassification) {
try {
const intentStr = await invoke<string>('classify_intent', {
query: normalized,
});
intent = intentStr as QueryIntent;
} catch (error) {
console.error('Erro ao classificar intent:', error);
// Fallback para unknown
intent = 'unknown';
}
}
// 4. Decidir se deve buscar
// Busca é necessária se:
// - Intent é factual ou technical (e webSearch está habilitado)
// - Intent é unknown (comportamento padrão: permitir busca)
const shouldSearch =
settings.webSearch.enabled &&
(intent === 'factual' ||
intent === 'technical' ||
intent === 'unknown');
return {
original,
normalized,
questions: splitResult.questions,
intent,
validation,
shouldSearch,
splitResult,
};
} catch (error) {
console.error('Erro no pré-processamento:', error);
// Retornar resultado com erro
return {
original: text,
normalized: normalizeText(text),
questions: [],
intent: 'unknown',
validation: {
isValid: false,
errors: ['Erro ao pré-processar query'],
warnings: [],
normalizedLength: 0,
},
shouldSearch: false,
splitResult: {
questions: [],
originalText: text,
splitCount: 0,
},
};
} finally {
setIsProcessing(false);
}
},
[settings]
);
return {
preprocess,
isProcessing,
};
}
+387
View File
@@ -0,0 +1,387 @@
import type { ResearchEntry } from '@/hooks/use-deep-research';
export interface Chunk {
content: string;
score: number;
sourceIndex: number;
startIndex: number;
endIndex: number;
sourceUrl?: string;
sourceTitle?: string;
}
export interface ProcessedContent {
original: string;
summarized?: string;
chunks: Chunk[];
keyFacts: string[];
totalTokens: number;
}
export interface ProcessingOptions {
maxChunkSize?: number;
summarizeThreshold?: number;
maxSummaryLength?: number;
}
const DEFAULT_OPTIONS: Required<ProcessingOptions> = {
maxChunkSize: 1024,
summarizeThreshold: 5000,
maxSummaryLength: 2000,
};
/**
* Sumariza conteúdo markdown mantendo estrutura e informações-chave
*
* @param content - Conteúdo markdown a ser sumarizado
* @param maxLen - Comprimento máximo do sumário
* @returns Conteúdo sumarizado
*/
export function summarizeMarkdown(content: string, maxLen: number = 2000): string {
if (!content || content.length <= maxLen) {
return content;
}
// Dividir por headings (###, ##, #)
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
const sections: Array<{ level: number; title: string; content: string; startIndex: number }> = [];
let lastIndex = 0;
let match;
const contentLines = content.split('\n');
// Encontrar todas as seções
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const title = match[2].trim();
const startIndex = content.substring(0, match.index).split('\n').length - 1;
// Encontrar conteúdo da seção (até próximo heading ou fim)
let endIndex = contentLines.length;
const nextHeadingRegex = /^(#{1,3})\s+/gm;
nextHeadingRegex.lastIndex = match.index + match[0].length;
const nextMatch = nextHeadingRegex.exec(content);
if (nextMatch) {
endIndex = content.substring(0, nextMatch.index).split('\n').length - 1;
}
const sectionContent = contentLines.slice(startIndex + 1, endIndex).join('\n').trim();
sections.push({
level,
title,
content: sectionContent,
startIndex,
});
lastIndex = endIndex;
}
// Se não encontrou headings, dividir por parágrafos
if (sections.length === 0) {
const paragraphs = content.split(/\n\s*\n/).filter(p => p.trim().length > 0);
let summary = '';
for (const para of paragraphs) {
if (summary.length + para.length + 2 <= maxLen) {
summary += para.trim() + '\n\n';
} else {
// Adicionar parte do parágrafo se ainda houver espaço
const remaining = maxLen - summary.length - 2;
if (remaining > 100) {
summary += para.substring(0, remaining).trim() + '...';
}
break;
}
}
return summary.trim() || content.substring(0, maxLen);
}
// Construir sumário priorizando seções de nível superior
let summary = '';
const sortedSections = sections.sort((a, b) => a.level - b.level);
for (const section of sortedSections) {
const sectionText = `## ${section.title}\n\n${section.content}\n\n`;
if (summary.length + sectionText.length <= maxLen) {
summary += sectionText;
} else {
// Adicionar parte da seção se ainda houver espaço
const remaining = maxLen - summary.length - 50; // 50 para heading
if (remaining > 100) {
const truncatedContent = section.content.substring(0, remaining).trim();
summary += `## ${section.title}\n\n${truncatedContent}...\n\n`;
}
break;
}
}
return summary.trim() || content.substring(0, maxLen);
}
/**
* Divide conteúdo markdown em chunks por seções
*
* @param markdown - Conteúdo markdown
* @param maxChunkSize - Tamanho máximo de cada chunk
* @returns Array de chunks
*/
export function chunkBySections(
markdown: string,
maxChunkSize: number = 1024
): Chunk[] {
if (!markdown || markdown.length === 0) {
return [];
}
const chunks: Chunk[] = [];
const lines = markdown.split('\n');
// Dividir por headings primeiro
const headingRegex = /^(#{1,3})\s+(.+)$/;
let currentChunk: string[] = [];
let currentHeading = '';
let startIndex = 0;
let chunkIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headingMatch = line.match(headingRegex);
if (headingMatch) {
// Se já temos um chunk, salvá-lo
if (currentChunk.length > 0) {
const chunkContent = currentChunk.join('\n');
if (chunkContent.trim().length > 0) {
chunks.push({
content: chunkContent,
score: 0, // Será calculado depois
sourceIndex: 0, // Será atribuído depois
startIndex: startIndex,
endIndex: i - 1,
});
chunkIndex++;
}
}
// Iniciar novo chunk
currentHeading = line;
currentChunk = [line];
startIndex = i;
} else {
currentChunk.push(line);
// Se chunk ficou muito grande, dividir por parágrafos
const currentContent = currentChunk.join('\n');
if (currentContent.length > maxChunkSize) {
// Salvar chunk atual (sem a linha atual)
const chunkWithoutLast = currentChunk.slice(0, -1).join('\n');
if (chunkWithoutLast.trim().length > 0) {
chunks.push({
content: chunkWithoutLast,
score: 0,
sourceIndex: 0,
startIndex: startIndex,
endIndex: i - 1,
});
chunkIndex++;
}
// Iniciar novo chunk com a linha atual
currentChunk = [currentHeading, line].filter(Boolean);
startIndex = i;
}
}
}
// Adicionar último chunk
if (currentChunk.length > 0) {
const chunkContent = currentChunk.join('\n');
if (chunkContent.trim().length > 0) {
chunks.push({
content: chunkContent,
score: 0,
sourceIndex: 0,
startIndex: startIndex,
endIndex: lines.length - 1,
});
}
}
// Se não encontrou headings, dividir por parágrafos
if (chunks.length === 0) {
const paragraphs = markdown.split(/\n\s*\n/).filter(p => p.trim().length > 0);
let currentChunkContent = '';
let paraStartIndex = 0;
for (let i = 0; i < paragraphs.length; i++) {
const para = paragraphs[i].trim();
if (currentChunkContent.length + para.length + 2 <= maxChunkSize) {
currentChunkContent += (currentChunkContent ? '\n\n' : '') + para;
} else {
// Salvar chunk atual
if (currentChunkContent.length > 0) {
chunks.push({
content: currentChunkContent,
score: 0,
sourceIndex: 0,
startIndex: paraStartIndex,
endIndex: i - 1,
});
}
// Iniciar novo chunk
currentChunkContent = para;
paraStartIndex = i;
}
}
// Adicionar último chunk
if (currentChunkContent.length > 0) {
chunks.push({
content: currentChunkContent,
score: 0,
sourceIndex: 0,
startIndex: paraStartIndex,
endIndex: paragraphs.length - 1,
});
}
}
return chunks;
}
/**
* Extrai fatos-chave do conteúdo (entidades, datas, números)
*
* @param markdown - Conteúdo markdown
* @returns Array de fatos extraídos
*/
export function extractKeyFacts(markdown: string): string[] {
if (!markdown) {
return [];
}
const facts: string[] = [];
const normalized = markdown.toLowerCase();
// Extrair datas (formato brasileiro: DD/MM/YYYY ou DD-MM-YYYY)
const dateRegex = /\b(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})\b/g;
const dates = [...markdown.matchAll(dateRegex)];
dates.forEach(match => {
if (!facts.includes(match[0])) {
facts.push(match[0]);
}
});
// Extrair anos (4 dígitos entre 1900-2100)
const yearRegex = /\b(19\d{2}|20[0-2]\d)\b/g;
const years = [...markdown.matchAll(yearRegex)];
years.forEach(match => {
if (!facts.includes(match[0])) {
facts.push(match[0]);
}
});
// Extrair números relevantes (valores, estatísticas)
// Padrão: número seguido de unidade ou contexto
const numberPatterns = [
/\b(\d{1,3}(?:\.\d{3})*(?:,\d+)?)\s*(?:milhões?|milhões?|bilhões?|bilhões?|mil|km|metros?|metros?|anos?|anos?|pessoas?|habitantes?)/gi,
/\bR\$\s*(\d{1,3}(?:\.\d{3})*(?:,\d+)?)/gi,
/\b(\d{1,3}(?:\.\d{3})*(?:,\d+)?)\s*%/g,
];
numberPatterns.forEach(pattern => {
const matches = [...markdown.matchAll(pattern)];
matches.forEach(match => {
const fact = match[0].trim();
if (fact.length > 0 && !facts.includes(fact)) {
facts.push(fact);
}
});
});
// Extrair locais (padrões comuns: "em [Local]", "de [Local]", "para [Local]")
const locationPatterns = [
/\b(?:em|de|para|no|na|nos|nas)\s+([A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+(?:\s+[A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+)*)/g,
/\b([A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+\s+(?:de\s+)?[A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+)\b/g,
];
locationPatterns.forEach(pattern => {
const matches = [...markdown.matchAll(pattern)];
matches.forEach(match => {
const location = match[1]?.trim();
if (location && location.length > 3 && location.length < 50 && !facts.includes(location)) {
// Filtrar palavras comuns que não são locais
const commonWords = ['o', 'a', 'os', 'as', 'um', 'uma', 'de', 'da', 'do', 'das', 'dos'];
if (!commonWords.includes(location.toLowerCase())) {
facts.push(location);
}
}
});
});
// Limitar a 20 fatos mais relevantes
return facts.slice(0, 20);
}
/**
* Processa conteúdo: sumariza, extrai fatos e faz chunking
*
* @param content - Conteúdo original
* @param query - Query do usuário (para contexto)
* @param options - Opções de processamento
* @returns Conteúdo processado
*/
export function processContent(
content: string,
query: string = '',
options: ProcessingOptions = {}
): ProcessedContent {
const opts = { ...DEFAULT_OPTIONS, ...options };
if (!content || content.length === 0) {
return {
original: content,
chunks: [],
keyFacts: [],
totalTokens: 0,
};
}
// Sumarizar se muito longo
let processedContent = content;
let summarized: string | undefined;
if (content.length > opts.summarizeThreshold) {
summarized = summarizeMarkdown(content, opts.maxSummaryLength);
processedContent = summarized;
}
// Extrair fatos-chave
const keyFacts = extractKeyFacts(content);
// Fazer chunking
const chunks = chunkBySections(processedContent, opts.maxChunkSize);
// Atribuir sourceIndex aos chunks (todos do mesmo source por enquanto)
chunks.forEach((chunk, idx) => {
chunk.sourceIndex = 0;
});
// Calcular tokens totais (aproximação: 1 token ≈ 4 caracteres)
const totalTokens = Math.ceil(processedContent.length / 4);
return {
original: content,
summarized,
chunks,
keyFacts,
totalTokens,
};
}
+97
View File
@@ -0,0 +1,97 @@
import { invoke } from '@tauri-apps/api/core';
/**
* URLs oficiais dos instaladores Ollama
*/
export const OLLAMA_DOWNLOAD_URLS = {
windows: 'https://ollama.com/download/OllamaSetup.exe',
mac: 'https://ollama.com/download/Ollama-darwin.zip',
linux: 'https://ollama.com/install.sh',
} as const;
/**
* Nomes dos arquivos de fallback locais
*/
export const LOCAL_INSTALLER_FILES = {
windows: 'OllamaSetup.exe',
mac: 'Ollama-darwin.zip',
linux: 'install.sh',
} as const;
export type OS = 'windows' | 'mac' | 'linux';
/**
* Verifica se a URL oficial está disponível
*/
export async function checkOfficialUrlAvailable(os: OS): Promise<boolean> {
try {
const url = OLLAMA_DOWNLOAD_URLS[os];
return await invoke<boolean>('check_download_url', { url });
} catch (error) {
console.error('Erro ao verificar URL oficial:', error);
return false;
}
}
/**
* Verifica se o instalador local existe
*/
export async function checkLocalInstallerExists(os: OS): Promise<boolean> {
try {
const filename = LOCAL_INSTALLER_FILES[os];
const path = await invoke<string | null>('get_local_installer_path', {
filename
});
return path !== null;
} catch (error) {
console.error('Erro ao verificar instalador local:', error);
return false;
}
}
/**
* Faz download do instalador (oficial ou fallback)
* Retorna o caminho do arquivo baixado
*/
export async function downloadInstaller(os: OS): Promise<string> {
const url = OLLAMA_DOWNLOAD_URLS[os];
const filename = LOCAL_INSTALLER_FILES[os];
try {
const filePath = await invoke<string>('download_installer', {
url,
filename
});
return filePath;
} catch (error) {
throw new Error(`Falha ao baixar instalador: ${error}`);
}
}
/**
* Verifica se o instalador já foi baixado
*/
export async function getDownloadedInstallerPath(os: OS): Promise<string | null> {
const filename = LOCAL_INSTALLER_FILES[os];
try {
const path = await invoke<string | null>('get_downloaded_installer_path', {
filename
});
return path;
} catch (error) {
console.error('Erro ao verificar instalador baixado:', error);
return null;
}
}
/**
* Executa o instalador baixado
*/
export async function runInstaller(filePath: string): Promise<void> {
try {
await invoke('run_installer', { filePath });
} catch (error) {
throw new Error(`Falha ao executar instalador: ${error}`);
}
}
+261
View File
@@ -0,0 +1,261 @@
import type { ResearchEntry } from '@/hooks/use-deep-research';
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
import { scoreAndSelectChunks } from './relevance-scorer';
import { summarizeMarkdown } from './content-condenser';
export interface CondensationResult {
context: string;
totalTokens: number;
originalTokens: number;
compressionRatio: number;
chunksUsed: number;
chunksTotal: number;
method: 'chunks' | 'summarized' | 'fallback';
sources: Array<{ title: string; url: string; chunksUsed: number }>;
}
export interface CondensationOptions {
maxTokens?: number;
minRelevanceScore?: number;
autoSummarize?: boolean;
summarizeThreshold?: number;
fallbackToSummarization?: boolean;
}
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
maxTokens: 12000,
minRelevanceScore: 0.1,
autoSummarize: true,
summarizeThreshold: 5000,
fallbackToSummarization: true,
};
/**
* Estima tokens de um texto (aproximação: 1 token ≈ 4 caracteres)
*/
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Sumariza todas as entradas e combina em um único contexto
*
* @param entries - Entradas da Knowledge Base
* @param query - Query do usuário
* @param maxSummaryLength - Comprimento máximo de cada sumário
* @returns Contexto sumarizado
*/
function fallbackSummarization(
entries: ResearchEntry[],
query: string,
maxSummaryLength: number = 1500
): string {
const summaries: string[] = [];
entries.forEach((entry, idx) => {
const summary = summarizeMarkdown(entry.content, maxSummaryLength);
summaries.push(
`[ID: ${idx + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n\n${summary}\n\n---\n\n`
);
});
const combined = summaries.join('');
return `## KNOWLEDGE BASE (Resumo Condensado)\n\n${combined}`;
}
/**
* Condensa Knowledge Base usando chunking inteligente e scoring
*
* @param entries - Entradas da Knowledge Base
* @param query - Query do usuário
* @param options - Opções de condensação
* @returns Resultado da condensação
*/
export function condenseKnowledgeBase(
entries: ResearchEntry[],
query: string,
options: CondensationOptions = {}
): CondensationResult {
const opts = { ...DEFAULT_OPTIONS, ...options };
if (entries.length === 0) {
return {
context: '',
totalTokens: 0,
originalTokens: 0,
compressionRatio: 0,
chunksUsed: 0,
chunksTotal: 0,
method: 'fallback',
sources: [],
};
}
// Calcular tokens originais
const originalContent = entries.map(e => e.content).join('\n\n');
const originalTokens = estimateTokens(originalContent);
// Processar cada entrada
const allChunks: Chunk[] = [];
const processedEntries: Array<{
entry: ResearchEntry;
processed: ProcessedContent;
chunks: Chunk[];
}> = [];
entries.forEach((entry, entryIndex) => {
const processed = processContent(entry.content, query, {
summarizeThreshold: opts.summarizeThreshold,
autoSummarize: opts.autoSummarize,
});
// Atribuir sourceIndex e metadados aos chunks
const chunksWithSource = processed.chunks.map((chunk, chunkIdx) => ({
...chunk,
sourceIndex: entryIndex,
sourceUrl: entry.sourceUrl,
sourceTitle: entry.title,
}));
processedEntries.push({
entry,
processed,
chunks: chunksWithSource,
});
allChunks.push(...chunksWithSource);
});
// Calcular tokens disponíveis (reservar 20% para margem)
const availableTokens = Math.floor(opts.maxTokens * 0.8);
// Selecionar chunks mais relevantes
let selectedChunks = scoreAndSelectChunks(
allChunks,
query,
availableTokens,
opts.minRelevanceScore
);
// Se não selecionou chunks suficientes ou método de fallback está ativo
let method: 'chunks' | 'summarized' | 'fallback' = 'chunks';
let context = '';
if (selectedChunks.length === 0 && opts.fallbackToSummarization) {
// Fallback: sumarizar tudo
method = 'fallback';
context = fallbackSummarization(entries, query);
} else {
// Construir contexto a partir dos chunks selecionados
const chunksBySource = new Map<number, Chunk[]>();
selectedChunks.forEach(chunk => {
if (!chunksBySource.has(chunk.sourceIndex)) {
chunksBySource.set(chunk.sourceIndex, []);
}
chunksBySource.get(chunk.sourceIndex)!.push(chunk);
});
// Agrupar chunks por fonte
const sourceBlocks: string[] = [];
const sourceStats: Array<{ title: string; url: string; chunksUsed: number }> = [];
chunksBySource.forEach((chunks, sourceIndex) => {
const entry = entries[sourceIndex];
if (!entry) return;
const sourceChunks = chunks
.sort((a, b) => a.startIndex - b.startIndex)
.map(c => c.content)
.join('\n\n');
sourceBlocks.push(
`[ID: ${sourceIndex + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n\n${sourceChunks}\n\n---\n\n`
);
sourceStats.push({
title: entry.title,
url: entry.sourceUrl,
chunksUsed: chunks.length,
});
});
context = `## KNOWLEDGE BASE (Verified Sources)\n\n${sourceBlocks.join('')}`;
// Verificar se contexto excede limite
const contextTokens = estimateTokens(context);
if (contextTokens > opts.maxTokens && opts.fallbackToSummarization) {
// Se ainda exceder, usar sumarização
method = 'summarized';
context = fallbackSummarization(entries, query);
}
}
const totalTokens = estimateTokens(context);
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
return {
context: context.trim(),
totalTokens,
originalTokens,
compressionRatio,
chunksUsed: selectedChunks.length,
chunksTotal: allChunks.length,
method,
sources: entries.map((entry, idx) => ({
title: entry.title,
url: entry.sourceUrl,
chunksUsed: selectedChunks.filter(c => c.sourceIndex === idx).length,
})),
};
}
/**
* Valida se o contexto condensado contém informações relevantes
*
* @param result - Resultado da condensação
* @param query - Query do usuário
* @returns true se válido
*/
export function validateCondensedContext(
result: CondensationResult,
query: string
): { isValid: boolean; warnings: string[] } {
const warnings: string[] = [];
if (!result.context || result.context.length < 100) {
return {
isValid: false,
warnings: ['Contexto condensado está vazio ou muito curto'],
};
}
if (result.chunksUsed === 0 && result.method !== 'fallback') {
warnings.push('Nenhum chunk relevante foi selecionado');
}
if (result.compressionRatio > 0.5) {
warnings.push(`Alta taxa de compressão (${(result.compressionRatio * 100).toFixed(1)}%) - informações podem ter sido perdidas`);
}
if (result.chunksUsed < result.chunksTotal * 0.3) {
warnings.push(`Apenas ${result.chunksUsed} de ${result.chunksTotal} chunks foram selecionados`);
}
// Verificar se query aparece no contexto (básico)
const normalizedQuery = query.toLowerCase();
const normalizedContext = result.context.toLowerCase();
const queryWords = normalizedQuery.split(/\s+/).filter(w => w.length > 3);
const matchingWords = queryWords.filter(word => normalizedContext.includes(word));
if (matchingWords.length < queryWords.length * 0.5) {
warnings.push('Poucas palavras-chave da query aparecem no contexto');
}
return {
isValid: result.context.length >= 100,
warnings,
};
}
+133
View File
@@ -0,0 +1,133 @@
import type { Message } from '@/hooks/use-chat';
import { useSettingsStore } from '@/store/settings-store';
export interface ModelContextInfo {
modelName: string;
maxContextWindow: number;
recommendedContextWindow: number;
}
/**
* Estima tokens de um texto (aproximação: 1 token ≈ 4 caracteres)
*/
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Detecta o context window máximo do modelo consultando Ollama API
*
* @param modelName - Nome do modelo
* @returns Limite máximo de tokens do modelo
*/
export async function detectModelContextWindow(modelName: string): Promise<number> {
if (!modelName) {
// Fallback para settings
const settings = useSettingsStore.getState();
return settings.contextWindow || 4096;
}
try {
// Tentar obter informações do modelo via Ollama API
const response = await fetch('http://localhost:11434/api/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: modelName,
}),
});
if (response.ok) {
const data = await response.json();
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
// Mapear tamanhos conhecidos de modelos
const modelInfo = data.modelfile || '';
const contextMatch = modelInfo.match(/context[_\s]*length[:\s]*(\d+)/i);
if (contextMatch) {
return parseInt(contextMatch[1], 10);
}
// Tentar inferir do tamanho do modelo
const size = data.size || '';
if (size.includes('7b') || size.includes('8b')) {
return 8192; // Modelos 7B-8B geralmente têm 8k
}
if (size.includes('13b') || size.includes('14b')) {
return 16384; // Modelos 13B-14B geralmente têm 16k
}
if (size.includes('32k') || size.includes('128k')) {
return 32768; // Modelos com contexto estendido
}
}
} catch (error) {
console.warn('Erro ao detectar context window do modelo:', error);
}
// Fallback: usar contextWindow das settings
const settings = useSettingsStore.getState();
return settings.contextWindow || 4096;
}
/**
* Calcula tokens disponíveis para Knowledge Base
*
* @param systemPrompt - System prompt
* @param messages - Mensagens do histórico
* @param modelMaxTokens - Limite máximo de tokens do modelo
* @returns Tokens disponíveis para Knowledge Base
*/
export function calculateAvailableTokens(
systemPrompt: string,
messages: Message[],
modelMaxTokens: number
): number {
// Calcular tokens usados
let usedTokens = 0;
// System prompt
if (systemPrompt) {
usedTokens += estimateTokens(systemPrompt);
}
// Mensagens do histórico
messages.forEach(msg => {
usedTokens += estimateTokens(msg.content);
});
// Reservar 10% para margem de segurança e resposta da IA
const reservedTokens = Math.ceil(modelMaxTokens * 0.1);
const availableTokens = modelMaxTokens - usedTokens - reservedTokens;
// Garantir mínimo de 1000 tokens disponíveis
return Math.max(availableTokens, 1000);
}
/**
* Obtém informações completas do contexto do modelo
*
* @param modelName - Nome do modelo
* @param systemPrompt - System prompt atual
* @param messages - Mensagens do histórico
* @returns Informações do contexto
*/
export async function getModelContextInfo(
modelName: string,
systemPrompt: string = '',
messages: Message[] = []
): Promise<ModelContextInfo> {
const maxContextWindow = await detectModelContextWindow(modelName);
const availableTokens = calculateAvailableTokens(systemPrompt, messages, maxContextWindow);
// Recommended é 80% do disponível para garantir margem
const recommendedContextWindow = Math.floor(availableTokens * 0.8);
return {
modelName,
maxContextWindow,
recommendedContextWindow,
};
}
+177
View File
@@ -0,0 +1,177 @@
import emojiRegex from 'emoji-regex';
import { normalizeText } from './text-normalization';
export interface ValidationOptions {
minLength?: number;
maxLength?: number;
irrelevantPatterns?: string[];
allowEmojis?: boolean;
}
export interface ValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
normalizedLength: number;
}
const DEFAULT_OPTIONS: Required<ValidationOptions> = {
minLength: 3,
maxLength: 2000,
irrelevantPatterns: [
'oi',
'olá',
'ola',
'teste',
'test',
'hello',
'hi',
'hey',
'ok',
'okay',
'sim',
'não',
'nao',
'yes',
'no',
],
allowEmojis: false,
};
/**
* Valida uma query do usuário
*
* @param text - Texto a ser validado
* @param options - Opções de validação
* @returns Resultado da validação
*/
export function validateQuery(
text: string,
options: ValidationOptions = {}
): ValidationResult {
const opts = { ...DEFAULT_OPTIONS, ...options };
const errors: string[] = [];
const warnings: string[] = [];
let normalizedLength = 0;
// Verificar se o texto existe e é string
if (!text || typeof text !== 'string') {
return {
isValid: false,
errors: ['Texto vazio ou inválido'],
warnings: [],
normalizedLength: 0,
};
}
// Normalizar texto para validação
const normalized = normalizeText(text);
normalizedLength = normalized.length;
// Verificar comprimento mínimo
if (normalizedLength < opts.minLength) {
errors.push(
`Texto muito curto. Mínimo de ${opts.minLength} caracteres necessário.`
);
}
// Verificar comprimento máximo
if (normalizedLength > opts.maxLength) {
errors.push(
`Texto muito longo. Máximo de ${opts.maxLength} caracteres permitido.`
);
}
// Verificar se é apenas espaços em branco após normalização
if (normalized.trim().length === 0) {
errors.push('Texto contém apenas espaços em branco ou caracteres especiais.');
}
// Verificar se contém apenas emojis
if (!opts.allowEmojis) {
const emojiPattern = emojiRegex();
const textWithoutEmojis = text.replace(emojiPattern, '').trim();
if (textWithoutEmojis.length === 0 && text.trim().length > 0) {
errors.push('Texto contém apenas emojis.');
}
}
// Verificar se contém apenas pontuação
const textWithoutPunctuation = normalized.replace(/[.,?!:;]/g, '').trim();
if (textWithoutPunctuation.length === 0 && normalized.length > 0) {
errors.push('Texto contém apenas pontuação.');
}
// Verificar padrões irrelevantes
if (isIrrelevantQuery(normalized, opts.irrelevantPatterns)) {
warnings.push(
'Pergunta pode ser irrelevante ou muito simples. Considere ser mais específico.'
);
}
return {
isValid: errors.length === 0,
errors,
warnings,
normalizedLength,
};
}
/**
* Verifica se uma query é irrelevante baseado em padrões
*
* @param text - Texto normalizado a ser verificado
* @param patterns - Lista de padrões irrelevantes
* @returns true se a query for irrelevante
*/
export function isIrrelevantQuery(
text: string,
patterns: string[] = DEFAULT_OPTIONS.irrelevantPatterns
): boolean {
if (!text || typeof text !== 'string') {
return true;
}
const normalized = normalizeText(text);
const normalizedPatterns = patterns.map((p) => normalizeText(p));
// Verificar se o texto normalizado está exatamente na lista de padrões
if (normalizedPatterns.includes(normalized)) {
return true;
}
// Verificar se o texto começa com um padrão irrelevante seguido apenas de pontuação/espaços
for (const pattern of normalizedPatterns) {
const regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s.,?!]*$`, 'i');
if (regex.test(normalized)) {
return true;
}
}
return false;
}
/**
* Validação rápida (apenas comprimento)
* Útil para validação em tempo real no input
*
* @param text - Texto a ser validado
* @param minLength - Comprimento mínimo
* @param maxLength - Comprimento máximo
* @returns true se válido
*/
export function quickValidate(
text: string,
minLength: number = DEFAULT_OPTIONS.minLength,
maxLength: number = DEFAULT_OPTIONS.maxLength
): boolean {
if (!text || typeof text !== 'string') {
return false;
}
const normalized = normalizeText(text);
return (
normalized.length >= minLength && normalized.length <= maxLength
);
}
+217
View File
@@ -0,0 +1,217 @@
import { normalizeText } from './text-normalization';
export interface SplitResult {
questions: string[];
originalText: string;
splitCount: number;
}
// Palavras interrogativas em português e inglês
const INTERROGATIVE_WORDS = [
'o que',
'que',
'qual',
'quais',
'quem',
'onde',
'quando',
'como',
'por que',
'porque',
'por quê',
'porquê',
'quantos',
'quantas',
'what',
'which',
'who',
'where',
'when',
'how',
'why',
'how many',
'how much',
];
// Conectores que podem separar perguntas
const QUESTION_SEPARATORS = [
/\s+e\s+/gi, // " e "
/\s+ou\s+/gi, // " ou "
/[.!?]\s+/g, // ". ", "! ", "? "
];
/**
* Verifica se um texto é uma pergunta
*
* @param text - Texto a ser verificado
* @returns true se for uma pergunta
*/
export function isQuestion(text: string): boolean {
if (!text || typeof text !== 'string') {
return false;
}
const trimmed = text.trim();
if (trimmed.length === 0) {
return false;
}
// Verifica se termina com ponto de interrogação
if (trimmed.endsWith('?')) {
return true;
}
// Verifica se começa com palavra interrogativa
const normalized = normalizeText(trimmed);
for (const word of INTERROGATIVE_WORDS) {
const normalizedWord = normalizeText(word);
if (normalized.startsWith(normalizedWord + ' ')) {
return true;
}
}
// Verifica padrões de pergunta
const questionPatterns = [
/^(o que|que|qual|quais|quem|onde|quando|como|por que|porque|quantos|quantas)/i,
/^(what|which|who|where|when|how|why|how many|how much)/i,
/\?$/,
];
for (const pattern of questionPatterns) {
if (pattern.test(trimmed)) {
return true;
}
}
return false;
}
/**
* Calcula similaridade básica entre duas strings (Jaccard similarity simplificada)
*
* @param str1 - Primeira string
* @param str2 - Segunda string
* @returns Similaridade entre 0 e 1
*/
function calculateSimilarity(str1: string, str2: string): number {
const words1 = new Set(normalizeText(str1).split(/\s+/).filter((w) => w.length > 2));
const words2 = new Set(normalizeText(str2).split(/\s+/).filter((w) => w.length > 2));
if (words1.size === 0 && words2.size === 0) {
return 1;
}
if (words1.size === 0 || words2.size === 0) {
return 0;
}
const intersection = new Set([...words1].filter((w) => words2.has(w)));
const union = new Set([...words1, ...words2]);
return intersection.size / union.size;
}
/**
* Remove perguntas duplicadas ou muito similares
*
* @param questions - Array de perguntas
* @param similarityThreshold - Limiar de similaridade (0-1)
* @returns Array de perguntas únicas
*/
function removeDuplicates(
questions: string[],
similarityThreshold: number = 0.7
): string[] {
const unique: string[] = [];
for (const question of questions) {
let isDuplicate = false;
for (const existing of unique) {
const similarity = calculateSimilarity(question, existing);
if (similarity >= similarityThreshold) {
isDuplicate = true;
// Manter a versão mais longa (geralmente mais completa)
if (question.length > existing.length) {
const index = unique.indexOf(existing);
unique[index] = question;
}
break;
}
}
if (!isDuplicate) {
unique.push(question);
}
}
return unique;
}
/**
* Divide um texto em múltiplas perguntas
*
* @param text - Texto a ser dividido
* @returns Resultado com array de perguntas
*/
export function splitQuestions(text: string): SplitResult {
if (!text || typeof text !== 'string') {
return {
questions: [],
originalText: text || '',
splitCount: 0,
};
}
const originalText = text.trim();
if (originalText.length === 0) {
return {
questions: [],
originalText: '',
splitCount: 0,
};
}
// Tentar dividir por separadores de sentenças primeiro
let parts: string[] = [originalText];
// Dividir por pontuação de fim de sentença
const sentenceSplit = originalText.split(/[.!?]\s+/);
if (sentenceSplit.length > 1) {
parts = sentenceSplit.map((p) => p.trim()).filter((p) => p.length > 0);
} else {
// Tentar dividir por conectores
for (const separator of QUESTION_SEPARATORS) {
const split = originalText.split(separator);
if (split.length > 1) {
parts = split.map((p) => p.trim()).filter((p) => p.length > 0);
break;
}
}
}
// Filtrar apenas partes que são perguntas
const questions = parts.filter((part) => {
// Adicionar "?" se não tiver mas for pergunta
const withQuestionMark = part.endsWith('?') ? part : part + '?';
return isQuestion(withQuestionMark);
});
// Se nenhuma parte foi identificada como pergunta, tratar o texto inteiro como uma pergunta
if (questions.length === 0) {
return {
questions: [originalText],
originalText,
splitCount: 1,
};
}
// Remover duplicatas
const uniqueQuestions = removeDuplicates(questions);
return {
questions: uniqueQuestions,
originalText,
splitCount: uniqueQuestions.length,
};
}
+8
View File
@@ -1,7 +1,15 @@
export interface GpuInfo {
id: string;
name: string;
vendor: string | null;
memory_mb: number | null;
}
export interface SystemSpecs {
total_memory: number; // Bytes
cpu_count: number;
os_name: string;
gpus: GpuInfo[];
}
export interface ModelRecommendation {
+193
View File
@@ -0,0 +1,193 @@
import type { Chunk } from './content-condenser';
import { normalizeText } from './text-normalization';
/**
* Calcula similaridade simples entre query e chunk baseado em palavras-chave
* Implementação simplificada de TF-IDF
*
* @param query - Query do usuário
* @param chunk - Chunk de conteúdo
* @returns Score de similaridade (0-1)
*/
export function calculateSimilarity(query: string, chunk: string): number {
if (!query || !chunk) {
return 0;
}
// Normalizar textos
const normalizedQuery = normalizeText(query);
const normalizedChunk = normalizeText(chunk);
// Extrair palavras-chave da query (remover stop words 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',
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
'of', 'with', 'by', 'from', 'as', 'is', 'are', 'was', 'were',
]);
const queryWords = normalizedQuery
.split(/\s+/)
.filter(word => word.length > 2 && !stopWords.has(word));
if (queryWords.length === 0) {
return 0.1; // Score mínimo se não houver palavras-chave
}
// Contar ocorrências de cada palavra-chave no chunk
let totalMatches = 0;
let uniqueMatches = 0;
const matchedWords = new Set<string>();
for (const word of queryWords) {
// Buscar palavra completa (word boundary)
const wordRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
const matches = normalizedChunk.match(wordRegex);
if (matches) {
totalMatches += matches.length;
if (!matchedWords.has(word)) {
matchedWords.add(word);
uniqueMatches++;
}
}
}
// Calcular score baseado em:
// 1. Proporção de palavras-chave encontradas (peso: 0.6)
// 2. Frequência de matches (peso: 0.3)
// 3. Normalização por tamanho do chunk (peso: 0.1)
const wordCoverage = uniqueMatches / queryWords.length;
const frequencyScore = Math.min(totalMatches / queryWords.length, 2) / 2; // Cap em 2x
const sizeNormalization = Math.min(1, 1000 / normalizedChunk.length); // Penalizar chunks muito longos
const score = (
wordCoverage * 0.6 +
frequencyScore * 0.3 +
sizeNormalization * 0.1
);
// Garantir que score esteja entre 0 e 1
return Math.min(Math.max(score, 0), 1);
}
/**
* Calcula scores de relevância para todos os chunks
*
* @param chunks - Array de chunks
* @param query - Query do usuário
* @returns Chunks com scores atribuídos, ordenados por relevância
*/
export function scoreChunks(chunks: Chunk[], query: string): Chunk[] {
if (!query || chunks.length === 0) {
return chunks;
}
// Calcular score para cada chunk
const scoredChunks = chunks.map(chunk => ({
...chunk,
score: calculateSimilarity(query, chunk.content),
}));
// Ordenar por score (maior primeiro)
return scoredChunks.sort((a, b) => b.score - a.score);
}
/**
* Seleciona os chunks mais relevantes que cabem no limite de tokens
*
* @param chunks - Chunks com scores
* @param maxTokens - Limite máximo de tokens
* @param query - Query do usuário (para contexto)
* @returns Chunks selecionados
*/
export function selectTopChunks(
chunks: Chunk[],
maxTokens: number,
query: string = ''
): Chunk[] {
if (chunks.length === 0) {
return [];
}
// Estimar tokens (aproximação: 1 token ≈ 4 caracteres)
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
// Garantir que pelo menos um chunk seja selecionado
const selected: Chunk[] = [];
let totalTokens = 0;
const maxChars = maxTokens * 4; // Converter tokens para chars
// Selecionar chunks por ordem de relevância até atingir limite
for (const chunk of chunks) {
const chunkTokens = estimateTokens(chunk.content);
if (totalTokens + chunkTokens <= maxTokens) {
selected.push(chunk);
totalTokens += chunkTokens;
} else {
// Se ainda não selecionou nenhum, forçar seleção do primeiro (mesmo que exceda)
if (selected.length === 0) {
selected.push(chunk);
break;
}
// Verificar se podemos adicionar parte do chunk
const remainingChars = maxChars - (totalTokens * 4);
if (remainingChars > 200) { // Mínimo de 200 chars para valer a pena
// Criar chunk truncado
const truncatedChunk: Chunk = {
...chunk,
content: chunk.content.substring(0, remainingChars) + '...',
};
selected.push(truncatedChunk);
}
break;
}
}
return selected;
}
/**
* Filtra chunks por score mínimo
*
* @param chunks - Chunks com scores
* @param minScore - Score mínimo (0-1)
* @returns Chunks que atendem ao score mínimo
*/
export function filterByMinScore(chunks: Chunk[], minScore: number = 0.1): Chunk[] {
return chunks.filter(chunk => chunk.score >= minScore);
}
/**
* Combina scoring e seleção em uma única função
*
* @param chunks - Chunks originais
* @param query - Query do usuário
* @param maxTokens - Limite máximo de tokens
* @param minScore - Score mínimo (opcional)
* @returns Chunks selecionados e ordenados por relevância
*/
export function scoreAndSelectChunks(
chunks: Chunk[],
query: string,
maxTokens: number,
minScore?: number
): Chunk[] {
// Calcular scores
let scoredChunks = scoreChunks(chunks, query);
// Filtrar por score mínimo se especificado
if (minScore !== undefined) {
scoredChunks = filterByMinScore(scoredChunks, minScore);
}
// Selecionar top chunks
return selectTopChunks(scoredChunks, maxTokens, query);
}
+38
View File
@@ -0,0 +1,38 @@
import { invoke } from '@tauri-apps/api/core';
/**
* Envia logs do frontend para o terminal via Tauri
* Use esta função em vez de console.log para ver logs no terminal
*/
export async function logToTerminal(level: 'info' | 'warn' | 'error' | 'debug', message: string) {
try {
await invoke('log_to_terminal', { level, message });
} catch (error) {
// Fallback para console se o comando falhar
console[level](message);
}
}
/**
* Helper para logs com prefixo [Chat]
*/
export const chatLog = {
info: (message: string) => logToTerminal('info', `[Chat] ${message}`),
warn: (message: string) => logToTerminal('warn', `[Chat] ${message}`),
error: (message: string) => logToTerminal('error', `[Chat] ${message}`),
debug: (message: string) => logToTerminal('debug', `[Chat] ${message}`),
};
/**
* Helper para logs com prefixo [DeepResearch]
*/
export const deepResearchLog = {
info: (message: string) => logToTerminal('info', `[DeepResearch] ${message}`),
warn: (message: string) => logToTerminal('warn', `[DeepResearch] ${message}`),
error: (message: string) => logToTerminal('error', `[DeepResearch] ${message}`),
debug: (message: string) => logToTerminal('debug', `[DeepResearch] ${message}`),
};
+81
View File
@@ -0,0 +1,81 @@
import removeAccents from 'remove-accents';
/**
* Normaliza texto removendo acentos, padronizando caixa e limpando caracteres especiais
*
* @param text - Texto a ser normalizado
* @returns Texto normalizado
*/
export function normalizeText(text: string): string {
if (!text || typeof text !== 'string') {
return '';
}
// Remove acentos
let normalized = removeAccents(text);
// Padroniza para lowercase
normalized = normalized.toLowerCase();
// Remove caracteres especiais não alfanuméricos (exceto espaços e pontuação básica)
// Mantém: letras, números, espaços, ponto, vírgula, interrogação, exclamação, dois pontos, ponto e vírgula
normalized = normalized.replace(/[^\w\s.,?!:;]/g, '');
// Normaliza espaços em branco múltiplos para um único espaço
normalized = normalized.replace(/\s+/g, ' ');
return normalized.trim();
}
/**
* Normaliza texto de forma mais agressiva para busca
* Remove pontuação e normaliza completamente
*
* @param text - Texto a ser normalizado para busca
* @returns Texto normalizado para busca
*/
export function normalizeForSearch(text: string): string {
if (!text || typeof text !== 'string') {
return '';
}
// Remove acentos
let normalized = removeAccents(text);
// Padroniza para lowercase
normalized = normalized.toLowerCase();
// Remove toda pontuação e caracteres especiais
normalized = normalized.replace(/[^\w\s]/g, '');
// Normaliza espaços em branco múltiplos para um único espaço
normalized = normalized.replace(/\s+/g, ' ');
return normalized.trim();
}
/**
* Preserva nomes próprios quando possível (versão menos agressiva)
* Mantém capitalização inicial de palavras que podem ser nomes próprios
*
* @param text - Texto a ser normalizado
* @returns Texto normalizado preservando possível capitalização de nomes
*/
export function normalizeTextPreserveCase(text: string): string {
if (!text || typeof text !== 'string') {
return '';
}
// Remove acentos
let normalized = removeAccents(text);
// Remove caracteres especiais não alfanuméricos (exceto espaços e pontuação básica)
normalized = normalized.replace(/[^\w\s.,?!:;]/g, '');
// Normaliza espaços em branco múltiplos para um único espaço
normalized = normalized.replace(/\s+/g, ' ');
return normalized.trim();
}
+298
View File
@@ -0,0 +1,298 @@
/**
* Sistema de Fallback Progressivo para Pesquisa Web
*
* Implementa múltiplas tentativas de busca com query expansion,
* validação de relevância e fallback para conhecimento interno.
*/
import type { ScrapedContent } from '@/services/webSearch';
import { chatLog } from '@/lib/terminal-logger';
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)
}
export interface FallbackAttempt {
round: number;
query: string;
results: ScrapedContent[];
relevanceScore: number;
timestamp: number;
}
export interface FallbackResult {
success: boolean;
knowledgeBaseContext: string;
scrapedSources: ScrapedContent[];
attempts: FallbackAttempt[];
totalResultsAnalyzed: number;
usedFallback: boolean; // true se usou conhecimento interno
finalQuery?: string;
}
const DEFAULT_CONFIG: FallbackConfig = {
maxRounds: 4,
maxResultsPerRound: 10,
maxTotalResults: 40,
minRelevanceScore: 0.3,
enableQueryExpansion: true,
};
/**
* Expande uma query usando técnicas de query expansion
*/
export async function expandQuery(
originalQuery: string,
model: string,
round: number
): Promise<string> {
if (round === 1) {
// Primeira rodada: usar query original
return originalQuery;
}
try {
const expansionPrompt = `Você é um especialista em busca de informações. Expanda e refine a query de busca abaixo para encontrar informações mais relevantes.
Query original: "${originalQuery}"
Instruções:
- Se a query for muito específica, generalize um pouco (ex: "bomba gás lacrimogêneo" → "gás lacrimogêneo composição química")
- Se a query for muito genérica, adicione termos específicos relacionados
- Use sinônimos e termos relacionados
- Mantenha o foco no tópico principal
- Retorne APENAS a query expandida, sem explicações
Query expandida:`;
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: expansionPrompt,
stream: false,
options: {
temperature: 0.5,
num_predict: 100,
},
}),
});
if (!response.ok) {
chatLog.warn('Failed to expand query, using original');
return originalQuery;
}
const data = await response.json();
const expanded = (data.response || '').trim()
.replace(/^["']|["']$/g, '')
.replace(/\.$/, '')
.trim();
if (expanded.length < 3) {
return originalQuery;
}
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
return expanded;
} catch (error) {
chatLog.warn('Error expanding query, using original:', error);
return originalQuery;
}
}
/**
* Calcula score de relevância de um resultado baseado na query
*/
export function calculateRelevanceScore(
result: ScrapedContent,
query: string
): number {
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
// Extrair texto para análise
const title = (result.title || '').toLowerCase();
const content = (result.markdown || '').toLowerCase();
const combinedText = `${title} ${content}`;
// Contar matches de palavras-chave
let matches = 0;
let totalWords = queryWords.length;
for (const word of queryWords) {
if (combinedText.includes(word)) {
matches++;
}
}
// Score base: proporção de palavras encontradas
let score = matches / totalWords;
// Bônus: título tem mais peso
const titleMatches = queryWords.filter(w => title.includes(w)).length;
if (titleMatches > 0) {
score += (titleMatches / totalWords) * 0.3; // 30% de bônus
}
// Bônus: conteúdo não vazio
if (content.length > 100) {
score += 0.1;
}
// Penalidade: conteúdo muito curto
if (content.length < 50) {
score *= 0.5;
}
// Normalizar para 0-1
return Math.min(1, Math.max(0, score));
}
/**
* Verifica se os resultados são relevantes o suficiente
*/
export function areResultsRelevant(
results: ScrapedContent[],
query: string,
minScore: number = 0.3
): boolean {
if (results.length === 0) {
return false;
}
// Calcular scores de relevância
const scores = results.map(r => calculateRelevanceScore(r, query));
// Verificar se pelo menos um resultado tem score suficiente
const maxScore = Math.max(...scores);
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
chatLog.info(`Relevance scores - Max: ${maxScore.toFixed(2)}, Avg: ${avgScore.toFixed(2)}, Min required: ${minScore}`);
// Considerar relevante se:
// 1. Pelo menos um resultado tem score alto, OU
// 2. Média dos scores é razoável
return maxScore >= minScore || avgScore >= (minScore * 0.7);
}
/**
* Executa busca progressiva com fallback
*/
export async function executeProgressiveSearch(
originalQuery: string,
searchFn: (query: string, limit: number) => Promise<ScrapedContent[]>,
model: string,
config: Partial<FallbackConfig> = {}
): Promise<FallbackResult> {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
const attempts: FallbackAttempt[] = [];
let totalResultsAnalyzed = 0;
let allResults: ScrapedContent[] = [];
const seenUrls = new Set<string>();
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
chatLog.info(`Original Query: "${originalQuery}"`);
chatLog.info(`Config: ${JSON.stringify(finalConfig)}`);
for (let round = 1; round <= finalConfig.maxRounds; round++) {
chatLog.info(`\n--- Round ${round}/${finalConfig.maxRounds} ---`);
// Expandir query se necessário
const query = finalConfig.enableQueryExpansion
? await expandQuery(originalQuery, model, round)
: originalQuery;
chatLog.info(`Searching with query: "${query}"`);
try {
// Executar busca
const results = await searchFn(query, finalConfig.maxResultsPerRound);
// Filtrar duplicatas
const uniqueResults = results.filter(r => {
if (seenUrls.has(r.url)) {
return false;
}
seenUrls.add(r.url);
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(),
});
}
}
// Se chegou aqui, não encontrou resultados relevantes
chatLog.warn(`\n⚠️ PROGRESSIVE SEARCH FAILED`);
chatLog.warn(`Total rounds: ${attempts.length}`);
chatLog.warn(`Total results analyzed: ${totalResultsAnalyzed}`);
chatLog.warn(`Using internal knowledge fallback`);
return {
success: false,
knowledgeBaseContext: '',
scrapedSources: allResults, // Retornar resultados encontrados mesmo que não relevantes
attempts,
totalResultsAnalyzed,
usedFallback: true,
};
}
+2
View File
@@ -26,6 +26,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-regex": "^10.6.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.554.0",
"next": "16.0.3",
@@ -36,6 +37,7 @@
"react-resizable-panels": "^3.0.6",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"remove-accents": "^0.5.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.8"
},
+16
View File
@@ -59,6 +59,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
emoji-regex:
specifier: ^10.6.0
version: 10.6.0
framer-motion:
specifier: ^12.23.24
version: 12.23.24([email protected]([email protected]))([email protected])
@@ -89,6 +92,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
remove-accents:
specifier: ^0.5.0
version: 0.5.0
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -1697,6 +1703,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-uqYq1IQhpXXLX+HgiXdyOZml7spy4xfy42yPxcCCRjswp0fYM2X+JwCON07lqnpLEGVCj739B7Yr+FngmHBMEQ==}
[email protected]:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
[email protected]:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -2831,6 +2840,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
[email protected]:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
[email protected]:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -4806,6 +4818,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -6330,6 +6344,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
[email protected]: {}
[email protected]: {}
[email protected]: {}
+3
View File
@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- sharp
- unrs-resolver
+9
View File
@@ -0,0 +1,9 @@
# Instaladores de fallback do Ollama
#
# Adicione os seguintes arquivos aqui:
# - OllamaSetup.exe (Windows)
# - Ollama-darwin.zip (macOS)
# - install.sh (Linux)
#
# Estes arquivos serão usados como fallback caso os links oficiais não estejam disponíveis.
+22
View File
@@ -105,6 +105,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"dirs 5.0.1",
"futures-util",
"headless_chrome",
"html2text",
@@ -1025,6 +1026,15 @@ dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -1045,6 +1055,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
+1
View File
@@ -44,3 +44,4 @@ futures-util = "0.3"
regex = "1.10"
walkdir = "2.5"
tauri-plugin-autostart = "2"
dirs = "5.0"
+208
View File
@@ -0,0 +1,208 @@
use regex::Regex;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum QueryIntent {
Factual, // Requer busca web (fatos, notícias, dados atualizados)
Conversational, // Não requer busca (saudações, conversa)
Technical, // Pode requerer busca (documentação, tutoriais)
Opinion, // Não requer busca (opiniões, subjetivo)
Calculation, // Não requer busca (cálculos, matemática)
Unknown, // Fallback
}
pub struct IntentClassifier;
impl IntentClassifier {
/// Classifica a intenção de uma query usando heurísticas baseadas em palavras-chave
pub fn classify(query: &str) -> QueryIntent {
if query.is_empty() {
return QueryIntent::Unknown;
}
let query_lower = query.to_lowercase();
let query_normalized = Self::normalize_query(&query_lower);
// Scoring para cada intent
let mut scores: std::collections::HashMap<QueryIntent, i32> = std::collections::HashMap::new();
scores.insert(QueryIntent::Factual, 0);
scores.insert(QueryIntent::Conversational, 0);
scores.insert(QueryIntent::Technical, 0);
scores.insert(QueryIntent::Opinion, 0);
scores.insert(QueryIntent::Calculation, 0);
// Padrões para Factual
let factual_patterns = vec![
r"\b(o que|que|qual|quais|quem|onde|quando)\b",
r"\b(what|which|who|where|when)\b",
r"\b(como funciona|how does|how works)\b",
r"\b(preço|price|preco|custo|cost)\b",
r"\b(notícia|noticia|news|notícias|noticias)\b",
r"\b(hoje|today|agora|now|atual|current)\b",
r"\b(último|ultimo|última|ultima|latest|recent)\b",
r"\b(história|historia|history|origem|origin)\b",
r"\b(é|e|is|are|was|were)\b.*\?",
];
// Padrões para Conversational
let conversational_patterns = vec![
r"^(oi|olá|ola|hello|hi|hey)\s*$",
r"\b(como você está|how are you|como vai|how is it going)\b",
r"\b(obrigado|obrigada|thanks|thank you|thank)\b",
r"\b(por favor|please|por favor)\b",
r"\b(tchau|bye|goodbye|até logo|see you)\b",
r"\b(bom dia|good morning|boa tarde|good afternoon|boa noite|good night)\b",
];
// Padrões para Technical
let technical_patterns = vec![
r"\b(como fazer|how to|how do|tutorial|tutoriais)\b",
r"\b(documentação|documentacao|documentation|docs)\b",
r"\b(exemplo|example|exemplos|examples)\b",
r"\b(código|codigo|code|implementação|implementacao|implementation)\b",
r"\b(api|sdk|framework|library|biblioteca)\b",
r"\b(erro|error|bug|problema|problem|issue)\b",
r"\b(guia|guide|manual|instalação|instalacao|installation)\b",
];
// Padrões para Opinion
let opinion_patterns = vec![
r"\b(você acha|you think|você pensa|you believe)\b",
r"\b(opinião|opiniao|opinion|pensar sobre|think about)\b",
r"\b(gostar|like|preferir|prefer|gosto|taste)\b",
r"\b(melhor|best|pior|worst|recomendar|recommend)\b",
r"\b(concordar|agree|discordar|disagree)\b",
];
// Padrões para Calculation
let calculation_patterns = vec![
r"\b(calcular|calculate|calcule|calculo)\b",
r"\b(quanto é|how much|quanto|how many)\b",
r"\b(\d+\s*[+\-*/]\s*\d+)", // Expressões matemáticas básicas
r"\b(soma|sum|subtração|subtraction|multiplicação|multiplication|divisão|division)\b",
r"\b(porcentagem|percentage|percent|por cento)\b",
];
// Calcular scores
Self::score_patterns(&query_normalized, &factual_patterns, &mut scores, QueryIntent::Factual);
Self::score_patterns(&query_normalized, &conversational_patterns, &mut scores, QueryIntent::Conversational);
Self::score_patterns(&query_normalized, &technical_patterns, &mut scores, QueryIntent::Technical);
Self::score_patterns(&query_normalized, &opinion_patterns, &mut scores, QueryIntent::Opinion);
Self::score_patterns(&query_normalized, &calculation_patterns, &mut scores, QueryIntent::Calculation);
// Encontrar intent com maior score
let mut max_score = 0;
let mut best_intent = QueryIntent::Unknown;
for (intent, score) in scores.iter() {
if *score > max_score {
max_score = *score;
best_intent = intent.clone();
}
}
// Se não houver score significativo, retornar Unknown
if max_score == 0 {
QueryIntent::Unknown
} else {
best_intent
}
}
/// Normaliza a query removendo acentos e caracteres especiais (simplificado)
fn normalize_query(query: &str) -> String {
query
.chars()
.map(|c| {
match c {
'á' | 'à' | 'ã' | 'â' | 'ä' => 'a',
'é' | 'è' | 'ê' | 'ë' => 'e',
'í' | 'ì' | 'î' | 'ï' => 'i',
'ó' | 'ò' | 'õ' | 'ô' | 'ö' => 'o',
'ú' | 'ù' | 'û' | 'ü' => 'u',
'ç' => 'c',
'ñ' => 'n',
'Á' | 'À' | 'Ã' | 'Â' | 'Ä' => 'a',
'É' | 'È' | 'Ê' | 'Ë' => 'e',
'Í' | 'Ì' | 'Î' | 'Ï' => 'i',
'Ó' | 'Ò' | 'Õ' | 'Ô' | 'Ö' => 'o',
'Ú' | 'Ù' | 'Û' | 'Ü' => 'u',
'Ç' => 'c',
'Ñ' => 'n',
_ => c,
}
})
.collect::<String>()
.to_lowercase()
}
/// Calcula score para um conjunto de padrões
fn score_patterns(
query: &str,
patterns: &[&str],
scores: &mut std::collections::HashMap<QueryIntent, i32>,
intent: QueryIntent,
) {
for pattern in patterns {
if let Ok(re) = Regex::new(pattern) {
if re.is_match(query) {
*scores.entry(intent.clone()).or_insert(0) += 1;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_factual_intent() {
assert_eq!(
IntentClassifier::classify("O que é Python?"),
QueryIntent::Factual
);
assert_eq!(
IntentClassifier::classify("Qual o preço do Bitcoin?"),
QueryIntent::Factual
);
}
#[test]
fn test_conversational_intent() {
assert_eq!(
IntentClassifier::classify("Oi, como você está?"),
QueryIntent::Conversational
);
assert_eq!(
IntentClassifier::classify("Obrigado!"),
QueryIntent::Conversational
);
}
#[test]
fn test_technical_intent() {
assert_eq!(
IntentClassifier::classify("Como fazer um loop em Python?"),
QueryIntent::Technical
);
assert_eq!(
IntentClassifier::classify("Documentação da API"),
QueryIntent::Technical
);
}
#[test]
fn test_calculation_intent() {
assert_eq!(
IntentClassifier::classify("Quanto é 2 + 2?"),
QueryIntent::Calculation
);
assert_eq!(
IntentClassifier::classify("Calcular porcentagem"),
QueryIntent::Calculation
);
}
}
+644 -29
View File
@@ -1,6 +1,7 @@
use std::process::{Command, Stdio, Child};
use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
use std::io::{BufRead, BufReader, Write, Read};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use std::fs;
use std::path::PathBuf;
use std::collections::HashMap;
@@ -18,6 +19,7 @@ mod task_executor;
mod scheduler_loop;
mod sources_config;
mod system_monitor;
mod intent_classifier;
use web_scraper::{
ScrapedContent,
@@ -34,10 +36,9 @@ use web_scraper::{
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};
use system_monitor::{SystemStats, SystemMonitorState, GpuInfo};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
// CommandExt é importado localmente onde necessário
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct Message {
@@ -47,6 +48,27 @@ struct Message {
metadata: Option<serde_json::Value>,
}
#[derive(serde::Serialize)]
struct DownloadProgress {
status: String, // "pulling", "verifying", "success"
percent: Option<u8>, // 0-100
downloaded: Option<String>, // "552 MB"
total: Option<String>, // "1.2 GB"
speed: Option<String>, // "25 MB/s"
raw: String, // linha original para fallback
}
#[derive(serde::Deserialize)]
struct PullProgress {
status: String,
#[serde(default)]
digest: String, // Mantido para compatibilidade com API, mas não usado atualmente
#[serde(default)]
total: u64,
#[serde(default)]
completed: u64,
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct ChatSession {
id: String,
@@ -75,6 +97,7 @@ struct SystemSpecs {
total_memory: u64,
cpu_count: usize,
os_name: String,
gpus: Vec<GpuInfo>,
}
// SystemStats movido para system_monitor.rs
@@ -440,13 +463,39 @@ fn get_system_specs() -> SystemSpecs {
let mut sys = System::new_all();
sys.refresh_all();
// Detectar todas as GPUs
let gpus = system_monitor::detect_all_gpus();
SystemSpecs {
total_memory: sys.total_memory(),
cpu_count: sys.cpus().len(),
os_name: System::name().unwrap_or("Unknown".to_string()),
gpus,
}
}
/// Retorna o sistema operacional atual: 'windows', 'mac', ou 'linux'
#[command]
fn get_operating_system() -> String {
#[cfg(target_os = "windows")]
{
return "windows".to_string();
}
#[cfg(target_os = "macos")]
{
return "mac".to_string();
}
#[cfg(target_os = "linux")]
{
return "linux".to_string();
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
return "unknown".to_string();
}
#[command]
fn start_system_monitor(window: Window) {
std::thread::spawn(move || {
@@ -542,34 +591,248 @@ fn check_if_model_installed(name: String) -> bool {
}
}
#[command]
async fn pull_model(window: Window, name: String) -> Result<(), String> {
let mut child = Command::new("ollama")
.arg("pull")
.arg(&name)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
let stdout = child.stdout.take().ok_or("Failed to open stdout")?;
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) => {
window.emit("download-progress", l).unwrap_or(());
// 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> {
buffer.clear();
let mut byte = [0u8; 1];
let mut count = 0;
loop {
match reader.read(&mut byte)? {
0 => break, // EOF
_ => {
if byte[0] == b'\r' {
// Se for \r, verificar se o próximo é \n e pular ambos
let mut peek = [0u8; 1];
if reader.read(&mut peek).unwrap_or(0) > 0 && peek[0] == b'\n' {
// É \r\n, já consumimos ambos
} else {
// É apenas \r, já consumimos
}
break;
} else if byte[0] == b'\n' {
break;
}
buffer.push(byte[0]);
count += 1;
}
Err(_) => break,
}
}
let status = child.wait().map_err(|e| e.to_string())?;
if status.success() {
Ok(())
Ok(count)
}
// Função auxiliar para formatar bytes em formato legível
fn format_bytes(bytes: u64) -> Option<String> {
if bytes == 0 {
return None;
}
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
Some(if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
Err("Failed to pull model".to_string())
format!("{} B", bytes)
})
}
// Função para parsear linha do Ollama e extrair informações (mantida para fallback)
#[allow(dead_code)]
fn parse_ollama_progress(line: &str) -> DownloadProgress {
let line_lower = line.to_lowercase();
let mut status = "downloading".to_string();
let mut percent: Option<u8> = None;
let mut downloaded: Option<String> = None;
let mut total: Option<String> = None;
let mut speed: Option<String> = None;
// Determinar status
if line_lower.contains("pulling manifest") || line_lower.contains("pulling") {
status = "pulling".to_string();
} else if line_lower.contains("verifying") {
status = "verifying".to_string();
} else if line_lower.contains("writing manifest") {
status = "writing".to_string();
} else if line_lower.contains("success") || line_lower.contains("complete") || line_lower.contains("pulled") {
status = "success".to_string();
}
// Extrair porcentagem: "45%" ou "45 %"
if let Some(caps) = regex::Regex::new(r"(\d+)\s*%").unwrap().captures(line) {
if let Ok(p) = caps[1].parse::<u8>() {
percent = Some(p);
}
}
// Extrair tamanho baixado/total: "552 MB/1.2 GB" ou "552MB / 1.2GB"
if let Some(caps) = regex::Regex::new(r"(\d+(?:\.\d+)?)\s*([KMGT]?B)\s*/\s*(\d+(?:\.\d+)?)\s*([KMGT]?B)").unwrap().captures(line) {
downloaded = Some(format!("{} {}", &caps[1], &caps[2]));
total = Some(format!("{} {}", &caps[3], &caps[4]));
}
// Extrair velocidade: "25 MB/s" ou "25MB/s"
if let Some(caps) = regex::Regex::new(r"(\d+(?:\.\d+)?)\s*([KMGT]?B/s)").unwrap().captures(line) {
speed = Some(format!("{} {}", &caps[1], &caps[2]));
}
DownloadProgress {
status,
percent,
downloaded,
total,
speed,
raw: line.to_string(),
}
}
#[command]
async fn pull_model(window: Window, name: String) -> Result<(), String> {
let client = reqwest::Client::new();
// Fazer requisição POST para API do Ollama com streaming
let response = client
.post("http://localhost:11434/api/pull")
.json(&serde_json::json!({ "name": name, "stream": true }))
.send()
.await
.map_err(|e| format!("Failed to connect to Ollama API: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama API returned error: {}", response.status()));
}
let mut stream = response.bytes_stream();
let mut buffer = String::new();
let mut last_completed: u64 = 0;
let mut last_time = Instant::now();
// Processar stream NDJSON (Newline Delimited JSON)
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
let chunk_str = String::from_utf8_lossy(&chunk);
buffer.push_str(&chunk_str);
// Processar linhas completas (separadas por \n)
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim().to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
continue;
}
// Tentar deserializar como PullProgress
match serde_json::from_str::<PullProgress>(&line) {
Ok(json_progress) => {
// Calcular porcentagem se tiver total/completed
let percent = if json_progress.total > 0 {
Some(((json_progress.completed as f64 / json_progress.total as f64) * 100.0) as u8)
} else {
None
};
// Calcular velocidade
let now = Instant::now();
let delta_time = now.duration_since(last_time).as_secs_f64();
let speed = if delta_time > 0.0 && json_progress.completed > last_completed {
let delta_bytes = json_progress.completed - last_completed;
let bytes_per_sec = delta_bytes as f64 / delta_time;
Some(format_speed(bytes_per_sec))
} else {
None
};
last_completed = json_progress.completed;
last_time = now;
// Criar DownloadProgress estruturado
let progress = DownloadProgress {
status: json_progress.status.clone(),
percent,
downloaded: format_bytes(json_progress.completed),
total: format_bytes(json_progress.total),
speed,
raw: line.clone(),
};
// Emitir evento para frontend
if let Ok(json) = serde_json::to_string(&progress) {
window.emit("download-progress", json).unwrap_or(());
}
// Se status for "success", finalizar
if json_progress.status == "success" {
let success_progress = DownloadProgress {
status: "success".to_string(),
percent: Some(100),
downloaded: format_bytes(json_progress.completed),
total: format_bytes(json_progress.total),
speed: None,
raw: "success".to_string(),
};
if let Ok(json) = serde_json::to_string(&success_progress) {
window.emit("download-progress", json).unwrap_or(());
}
return Ok(());
}
}
Err(_) => {
// Se não conseguir parsear como JSON, tratar como linha raw (fallback)
let progress = DownloadProgress {
status: "downloading".to_string(),
percent: None,
downloaded: None,
total: None,
speed: None,
raw: line,
};
if let Ok(json) = serde_json::to_string(&progress) {
window.emit("download-progress", json).unwrap_or(());
}
}
}
}
}
// Se chegou aqui, o stream terminou sem "success" explícito
// Emitir sucesso final
let success_progress = DownloadProgress {
status: "success".to_string(),
percent: Some(100),
downloaded: format_bytes(last_completed),
total: None,
speed: None,
raw: "success".to_string(),
};
if let Ok(json) = serde_json::to_string(&success_progress) {
window.emit("download-progress", json).unwrap_or(());
}
Ok(())
}
// Função auxiliar para formatar velocidade
fn format_speed(bytes_per_sec: f64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
if bytes_per_sec >= GB {
format!("{:.1} GB/s", bytes_per_sec / GB)
} else if bytes_per_sec >= MB {
format!("{:.1} MB/s", bytes_per_sec / MB)
} else if bytes_per_sec >= KB {
format!("{:.1} KB/s", bytes_per_sec / KB)
} else {
format!("{:.0} B/s", bytes_per_sec)
}
}
@@ -589,6 +852,84 @@ async fn check_ollama_running() -> bool {
}
}
/// Verificação completa do Ollama: instalação e execução
#[derive(serde::Serialize)]
struct OllamaCheckResult {
installed: bool,
running: bool,
status: String, // "not_installed" | "installed_stopped" | "running"
}
/// Inicia o Ollama automaticamente se estiver instalado mas não estiver rodando
#[command]
async fn auto_start_ollama() -> Result<bool, String> {
// Verificar se está instalado
let installed = check_ollama_installed();
if !installed {
log::info!("Ollama não está instalado, pulando inicialização automática");
return Ok(false);
}
// Verificar se já está rodando
let running = check_ollama_running().await;
if running {
log::info!("Ollama já está rodando");
return Ok(true);
}
// Tentar iniciar
log::info!("Iniciando Ollama automaticamente...");
match start_ollama_server() {
Ok(_) => {
// Aguardar um pouco para o servidor iniciar
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Verificar se iniciou com sucesso
let is_running = check_ollama_running().await;
if is_running {
log::info!("Ollama iniciado com sucesso");
Ok(true)
} else {
log::warn!("Ollama foi iniciado mas ainda não está respondendo");
Ok(false)
}
}
Err(e) => {
log::error!("Falha ao iniciar Ollama automaticamente: {}", e);
Err(e)
}
}
}
#[command]
async fn check_ollama_full() -> Result<OllamaCheckResult, String> {
let installed = check_ollama_installed();
if !installed {
return Ok(OllamaCheckResult {
installed: false,
running: false,
status: "not_installed".to_string(),
});
}
let running = check_ollama_running().await;
if !running {
return Ok(OllamaCheckResult {
installed: true,
running: false,
status: "installed_stopped".to_string(),
});
}
Ok(OllamaCheckResult {
installed: true,
running: true,
status: "running".to_string(),
})
}
#[command]
fn start_ollama_server() -> Result<(), String> {
let mut cmd = Command::new("ollama");
@@ -596,6 +937,7 @@ fn start_ollama_server() -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
@@ -759,6 +1101,7 @@ fn start_mcp_server(
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
@@ -864,6 +1207,7 @@ fn restart_mcp_server(
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
@@ -970,6 +1314,7 @@ fn restart_all_mcp_servers(
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
@@ -1518,6 +1863,228 @@ fn save_sources_config_command(app_handle: AppHandle, config: SourcesConfig) ->
save_sources_config(&app_handle, config)
}
// ========== Ollama Installer Download Commands ==========
/// Verifica se uma URL de download está disponível
#[command]
async fn check_download_url(url: String) -> Result<bool, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
match client.head(&url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false),
}
}
/// Obtém o caminho do instalador local se existir
#[command]
fn get_local_installer_path(filename: String, app_handle: AppHandle) -> Result<Option<String>, String> {
// Tentar no diretório do executável (dev e produção)
// Em desenvolvimento, os arquivos estão em public/ relativo ao projeto
// Em produção, tentamos encontrar o arquivo em vários locais possíveis
if let Ok(exe_dir) = app_handle.path().executable_dir() {
// Tentar vários caminhos possíveis
let possible_paths = vec![
// Caminho relativo ao executável (dev) - subir até a raiz do projeto
exe_dir.parent()
.and_then(|p| p.parent())
.map(|p| p.join("public").join("installers").join(&filename)),
// Caminho direto do executável (dev)
exe_dir.parent()
.map(|p| p.join("public").join("installers").join(&filename)),
// Caminho absoluto do workspace (dev) - tentar encontrar a raiz
exe_dir.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(|p| p.join("public").join("installers").join(&filename)),
// Em produção, arquivos podem estar no diretório do executável
Some(exe_dir.join("installers").join(&filename)),
// Ou no diretório pai do executável
exe_dir.parent()
.map(|p| p.join("installers").join(&filename)),
];
for path_opt in possible_paths {
if let Some(path) = path_opt {
if path.exists() {
return Ok(Some(path.to_string_lossy().to_string()));
}
}
}
}
Ok(None)
}
/// Faz download do instalador da URL oficial ou usa fallback local
#[command]
async fn download_installer(
url: String,
filename: String,
window: Window,
app_handle: AppHandle,
) -> Result<String, String> {
use std::io::Write;
use futures_util::StreamExt;
// Primeiro, tentar usar instalador local como fallback
if let Some(local_path) = get_local_installer_path(filename.clone(), app_handle.clone())? {
let local_path_buf = PathBuf::from(&local_path);
if local_path_buf.exists() {
// Copiar para app_data_dir/installers
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let installers_dir = app_data_dir.join("installers");
if !installers_dir.exists() {
fs::create_dir_all(&installers_dir)
.map_err(|e| format!("Failed to create installers directory: {}", e))?;
}
let dest_path = installers_dir.join(&filename);
fs::copy(&local_path_buf, &dest_path)
.map_err(|e| format!("Failed to copy local installer: {}", e))?;
window.emit("installer-download-progress", serde_json::json!({
"progress": 100,
"status": "Concluído (versão local)"
})).ok();
return Ok(dest_path.to_string_lossy().to_string());
}
}
// Fazer download da URL
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minutos de timeout
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to download installer: {}", e))?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()));
}
// Obter tamanho total do arquivo
let total_size = response.content_length().unwrap_or(0);
// Criar diretório de instaladores
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let installers_dir = app_data_dir.join("installers");
if !installers_dir.exists() {
fs::create_dir_all(&installers_dir)
.map_err(|e| format!("Failed to create installers directory: {}", e))?;
}
let dest_path = installers_dir.join(&filename);
let mut file = fs::File::create(&dest_path)
.map_err(|e| format!("Failed to create file: {}", e))?;
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(item) = stream.next().await {
let chunk = item.map_err(|e| format!("Failed to read chunk: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Failed to write chunk: {}", e))?;
downloaded += chunk.len() as u64;
// Emitir progresso
let progress = if total_size > 0 {
(downloaded * 100) / total_size
} else {
0
};
window.emit("installer-download-progress", serde_json::json!({
"progress": progress,
"downloaded": downloaded,
"total": total_size,
"status": format!("Baixando... {}%", progress)
})).ok();
}
window.emit("installer-download-progress", serde_json::json!({
"progress": 100,
"status": "Download concluído"
})).ok();
log::info!("Instalador baixado para: {:?}", dest_path);
Ok(dest_path.to_string_lossy().to_string())
}
/// Executa o instalador baixado
#[command]
fn run_installer(file_path: String) -> Result<(), String> {
let path = PathBuf::from(&file_path);
if !path.exists() {
return Err(format!("Instalador não encontrado: {}", file_path));
}
#[cfg(target_os = "windows")]
{
// No Windows, executar o .exe diretamente
Command::new(&path)
.spawn()
.map_err(|e| format!("Failed to run installer: {}", e))?;
}
#[cfg(target_os = "linux")]
{
// No Linux, dar permissão de execução e executar
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path)
.map_err(|e| format!("Failed to get file metadata: {}", e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms)
.map_err(|e| format!("Failed to set executable permissions: {}", e))?;
Command::new(&path)
.spawn()
.map_err(|e| format!("Failed to run installer: {}", e))?;
}
#[cfg(target_os = "macos")]
{
// No macOS, executar o .zip (precisa ser extraído primeiro)
// Por enquanto, apenas abrir o arquivo
Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open installer: {}", e))?;
}
log::info!("Instalador executado: {:?}", path);
Ok(())
}
/// Verifica se o instalador já foi baixado
#[command]
fn get_downloaded_installer_path(filename: String, app_handle: AppHandle) -> Result<Option<String>, String> {
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let installer_path = app_data_dir.join("installers").join(&filename);
if installer_path.exists() {
Ok(Some(installer_path.to_string_lossy().to_string()))
} else {
Ok(None)
}
}
// ========== Export & Backup Commands ==========
/// Exporta todos os dados do app (chats, tasks, sources, settings) para um arquivo ZIP
@@ -1671,6 +2238,19 @@ fn get_recent_logs(app_handle: AppHandle, lines: usize) -> Result<Vec<String>, S
}
}
/// Recebe logs do frontend e os imprime no terminal
#[command]
fn log_to_terminal(level: String, message: String) -> Result<(), String> {
match level.as_str() {
"info" => log::info!("{}", message),
"warn" => log::warn!("{}", message),
"error" => log::error!("{}", message),
"debug" => log::debug!("{}", message),
_ => log::info!("{}", message),
}
Ok(())
}
// ========== System Monitor Commands ==========
/// Obtém estatísticas do sistema em tempo real
@@ -1755,6 +2335,20 @@ async fn toggle_task(
}
}
#[command]
fn classify_intent(query: String) -> String {
use intent_classifier::{IntentClassifier, QueryIntent};
let intent = IntentClassifier::classify(&query);
match intent {
QueryIntent::Factual => "factual".to_string(),
QueryIntent::Conversational => "conversational".to_string(),
QueryIntent::Technical => "technical".to_string(),
QueryIntent::Opinion => "opinion".to_string(),
QueryIntent::Calculation => "calculation".to_string(),
QueryIntent::Unknown => "unknown".to_string(),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -1798,6 +2392,17 @@ pub fn run() {
let app_handle = app.handle().clone();
let scheduler_clone = scheduler_state.clone();
// Inicializar Ollama automaticamente se estiver instalado
tauri::async_runtime::spawn(async move {
// Aguardar um pouco para o app inicializar completamente
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// Tentar iniciar Ollama automaticamente
if let Err(e) = auto_start_ollama().await {
log::warn!("Falha ao iniciar Ollama automaticamente: {}", e);
}
});
// BrowserState não é mais necessário - o scheduler criará o browser quando necessário
// Usar o runtime async do Tauri ao invés de tokio::spawn
tauri::async_runtime::spawn(async move {
@@ -1826,6 +2431,7 @@ pub fn run() {
check_ollama_installed,
check_ollama_running,
get_system_specs,
get_operating_system,
check_if_model_installed,
pull_model,
start_ollama_server,
@@ -1862,12 +2468,21 @@ pub fn run() {
load_sources_config_command,
save_sources_config_command,
get_recent_logs,
log_to_terminal,
get_system_stats,
create_task,
list_tasks,
update_task,
delete_task,
toggle_task
toggle_task,
check_download_url,
get_local_installer_path,
download_installer,
run_installer,
get_downloaded_installer_path,
check_ollama_full,
auto_start_ollama,
classify_intent
])
.manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap)
.run(tauri::generate_context!())
+1
View File
@@ -152,6 +152,7 @@ impl SchedulerService {
}
/// Helper para obter diretório de tasks
#[allow(dead_code)]
pub fn get_tasks_file_path(app_handle: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app_handle
.path()
+491 -27
View File
@@ -1,6 +1,15 @@
use serde::Serialize;
use sysinfo::System;
/// Informações sobre uma GPU
#[derive(Serialize, Clone, Debug)]
pub struct GpuInfo {
pub id: String,
pub name: String,
pub vendor: Option<String>,
pub memory_mb: Option<u64>,
}
/// Estatísticas do sistema em tempo real
#[derive(Serialize, Clone, Debug)]
pub struct SystemStats {
@@ -17,6 +26,7 @@ pub struct SystemStats {
/// Estado persistente do sistema para cálculo de CPU
pub struct SystemMonitorState {
system: System,
#[allow(dead_code)]
last_cpu_check: std::time::Instant,
}
@@ -89,65 +99,519 @@ impl SystemMonitorState {
}
/// Tenta detectar o nome da GPU (implementação básica)
/// Mantido para compatibilidade com SystemStats
fn detect_gpu_name() -> Option<String> {
let gpus = detect_all_gpus();
gpus.first().map(|gpu| gpu.name.clone())
}
/// Detecta todas as GPUs disponíveis no sistema
pub fn detect_all_gpus() -> Vec<GpuInfo> {
log::info!("Iniciando detecção de GPUs...");
let mut gpus = Vec::new();
// Primeiro, tentar obter informações do Ollama se estiver rodando
if let Ok(ollama_gpus) = detect_gpus_ollama_api() {
log::info!("GPUs detectadas via API do Ollama: {}", ollama_gpus.len());
gpus.extend(ollama_gpus);
}
#[cfg(target_os = "windows")]
{
use std::process::Command;
// Tentar múltiplas fontes de detecção
let windows_gpus = detect_gpus_windows();
// Tenta usar wmic para detectar GPU no Windows
if let Ok(output) = Command::new("wmic")
.args(&["path", "win32_VideoController", "get", "name"])
.output()
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
for line in stdout.lines() {
let line = line.trim();
if !line.is_empty() && line != "Name" {
return Some(line.to_string());
// Mesclar com GPUs do Ollama, evitando duplicatas
for windows_gpu in windows_gpus {
if !gpus.iter().any(|g| g.name == windows_gpu.name) {
log::info!("GPU detectada via wmic: {}", windows_gpu.name);
gpus.push(windows_gpu);
}
}
// Se não encontrou GPUs NVIDIA, tentar nvidia-smi
let has_nvidia = gpus.iter().any(|g| g.vendor.as_ref().map(|v| v == "NVIDIA").unwrap_or(false));
if !has_nvidia {
log::info!("Tentando nvidia-smi como fallback...");
if let Ok(nvidia_gpus) = detect_gpus_nvidia_smi() {
// Mesclar resultados, evitando duplicatas
for nvidia_gpu in nvidia_gpus {
if !gpus.iter().any(|g| g.name == nvidia_gpu.name) {
log::info!("GPU detectada via nvidia-smi: {}", nvidia_gpu.name);
gpus.push(nvidia_gpu);
}
}
}
}
log::info!("Total de GPUs detectadas no Windows: {}", gpus.len());
}
#[cfg(target_os = "linux")]
{
use std::process::Command;
log::info!("Tentando detectar GPUs no Linux...");
gpus = detect_gpus_linux();
log::info!("Total de GPUs detectadas no Linux: {}", gpus.len());
}
#[cfg(target_os = "macos")]
{
log::info!("Tentando detectar GPUs no macOS...");
gpus = detect_gpus_macos();
log::info!("Total de GPUs detectadas no macOS: {}", gpus.len());
}
// Se não encontrou nenhuma GPU, retornar GPU genérica
if gpus.is_empty() {
log::warn!("Nenhuma GPU detectada, usando fallback genérico");
gpus.push(GpuInfo {
id: "gpu_default".to_string(),
name: "GPU não detectada".to_string(),
vendor: None,
memory_mb: None,
});
}
log::info!("Detecção de GPUs concluída: {} GPU(s) encontrada(s)", gpus.len());
gpus
}
/// Detecta GPUs no Windows usando wmic (formato CSV melhorado)
#[cfg(target_os = "windows")]
fn detect_gpus_windows() -> Vec<GpuInfo> {
use std::process::Command;
let mut gpus = Vec::new();
log::info!("Tentando detectar GPUs via wmic...");
// Tentar formato CSV primeiro (mais confiável)
if let Ok(output) = Command::new("wmic")
.args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:csv"])
.output()
{
// wmic pode retornar UTF-16LE no Windows, precisamos converter
let stdout = if let Ok(utf8) = String::from_utf8(output.stdout.clone()) {
utf8
} else {
// Tentar UTF-16LE (little-endian)
let bytes = output.stdout;
let mut utf16_chars = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
let low = bytes[i] as u16;
let high = bytes[i + 1] as u16;
utf16_chars.push(low | (high << 8));
i += 2;
}
String::from_utf16_lossy(&utf16_chars)
};
// Tenta usar lspci no Linux
if let Ok(output) = Command::new("lspci")
.args(&["-v", "-s", "$(lspci | grep VGA | cut -d' ' -f1)"])
log::debug!("wmic output (primeiros 500 chars): {}", stdout.chars().take(500).collect::<String>());
// Parse CSV: Node,Name,AdapterRAM,PNPDeviceID
let lines: Vec<&str> = stdout.lines().collect();
for (idx, line) in lines.iter().enumerate() {
if idx == 0 {
continue; // Skip header
}
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() >= 4 {
let name = parts[1].trim_matches('"').to_string();
if !name.is_empty() && name != "Name" {
let adapter_ram_str = parts[2].trim_matches('"');
let pnp_id = parts[3].trim_matches('"').to_string();
let memory_mb = if !adapter_ram_str.is_empty() && adapter_ram_str != "AdapterRAM" {
adapter_ram_str.parse::<u64>().ok().map(|bytes| bytes / (1024 * 1024))
} else {
None
};
let vendor = detect_vendor_from_name(&name);
let id = if !pnp_id.is_empty() && pnp_id != "PNPDeviceID" {
format!("gpu_{}", pnp_id.replace("\\", "_").replace("/", "_"))
} else {
format!("gpu_{}", gpus.len())
};
log::info!("GPU detectada via wmic: {} (VRAM: {:?} MB)", name, memory_mb);
gpus.push(GpuInfo {
id,
name,
vendor,
memory_mb,
});
}
}
}
} else {
log::warn!("Falha ao executar wmic, tentando formato list...");
// Fallback para formato list
if let Ok(output) = Command::new("wmic")
.args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:list"])
.output()
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
let mut current_gpu: Option<GpuInfo> = None;
for line in stdout.lines() {
if line.contains("VGA") || line.contains("Display") {
return Some(line.to_string());
let line = line.trim();
if line.is_empty() {
if let Some(gpu) = current_gpu.take() {
gpus.push(gpu);
}
continue;
}
if line.starts_with("Name=") {
let name = line.replace("Name=", "").trim().to_string();
if !name.is_empty() {
let vendor = detect_vendor_from_name(&name);
current_gpu = Some(GpuInfo {
id: format!("gpu_{}", gpus.len()),
name,
vendor,
memory_mb: None,
});
}
} else if line.starts_with("AdapterRAM=") {
if let Some(gpu) = &mut current_gpu {
if let Ok(memory_bytes) = line.replace("AdapterRAM=", "").trim().parse::<u64>() {
gpu.memory_mb = Some(memory_bytes / (1024 * 1024));
}
}
} else if line.starts_with("PNPDeviceID=") {
if let Some(gpu) = &mut current_gpu {
let pnp_id = line.replace("PNPDeviceID=", "").trim().to_string();
if !pnp_id.is_empty() {
gpu.id = format!("gpu_{}", pnp_id.replace("\\", "_").replace("/", "_"));
}
}
}
}
if let Some(gpu) = current_gpu {
gpus.push(gpu);
}
}
}
}
gpus
}
/// Detecta GPUs NVIDIA usando nvidia-smi
#[cfg(target_os = "windows")]
fn detect_gpus_nvidia_smi() -> Result<Vec<GpuInfo>, String> {
use std::process::Command;
log::info!("Executando nvidia-smi...");
let output = Command::new("nvidia-smi")
.args(&["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
.output()
.map_err(|e| format!("nvidia-smi não encontrado: {}", e))?;
if !output.status.success() {
return Err("nvidia-smi falhou".to_string());
}
let stdout = String::from_utf8(output.stdout)
.map_err(|e| format!("Erro ao parsear output do nvidia-smi: {}", e))?;
log::debug!("nvidia-smi output: {}", stdout);
let mut gpus = Vec::new();
for (idx, line) in stdout.lines().enumerate() {
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() >= 2 {
let name = parts[0].trim().to_string();
let memory_mb = parts[1].trim().parse::<u64>().ok();
if !name.is_empty() {
log::info!("GPU NVIDIA detectada via nvidia-smi: {} ({} MB)", name, memory_mb.unwrap_or(0));
gpus.push(GpuInfo {
id: format!("nvidia_gpu_{}", idx),
name,
vendor: Some("NVIDIA".to_string()),
memory_mb,
});
}
}
}
Ok(gpus)
}
/// Tenta detectar GPUs via API do Ollama (quando disponível)
/// Nota: O Ollama não expõe diretamente informações de GPU via API pública
/// Esta função verifica se o Ollama está rodando, mas a detecção real
/// é feita via métodos do sistema operacional (wmic, nvidia-smi, etc)
fn detect_gpus_ollama_api() -> Result<Vec<GpuInfo>, String> {
// Por enquanto, retornamos vazio pois o Ollama não tem endpoint público de GPU
// A detecção é feita via métodos do sistema operacional que são mais confiáveis
// Esta função pode ser expandida no futuro se o Ollama adicionar um endpoint de GPU
log::debug!("Verificação de API do Ollama não implementada (não há endpoint público de GPU)");
Ok(Vec::new())
}
/// Detecta GPUs no Linux usando múltiplas fontes
#[cfg(target_os = "linux")]
fn detect_gpus_linux() -> Vec<GpuInfo> {
use std::process::Command;
let mut gpus = Vec::new();
// 1. Tentar lspci primeiro
log::info!("Tentando lspci...");
if let Ok(output) = Command::new("lspci")
.args(&["-m", "-d", "::0300"]) // Apenas dispositivos VGA
.output()
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
for (index, line) in stdout.lines().enumerate() {
let parts: Vec<&str> = line.split('"').collect();
if parts.len() >= 2 {
let name = parts[1].trim().to_string();
if !name.is_empty() {
let vendor = detect_vendor_from_name(&name);
log::info!("GPU detectada via lspci: {}", name);
gpus.push(GpuInfo {
id: format!("gpu_{}", index),
name,
vendor,
memory_mb: None,
});
}
}
}
}
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
// Tenta usar system_profiler no macOS
if let Ok(output) = Command::new("system_profiler")
.args(&["SPDisplaysDataType"])
// 2. Se não encontrou, tentar método alternativo
if gpus.is_empty() {
log::info!("Tentando lspci | grep como fallback...");
if let Ok(output) = Command::new("sh")
.args(&["-c", "lspci | grep -i vga"])
.output()
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
for line in stdout.lines() {
if line.contains("Chipset Model") {
return Some(line.replace("Chipset Model:", "").trim().to_string());
for (index, line) in stdout.lines().enumerate() {
let line = line.trim();
if !line.is_empty() && (line.contains("VGA") || line.contains("Display")) {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() >= 2 {
let name = parts[1].trim().to_string();
if !name.is_empty() {
let vendor = detect_vendor_from_name(&name);
log::info!("GPU detectada via lspci grep: {}", name);
gpus.push(GpuInfo {
id: format!("gpu_{}", index),
name,
vendor,
memory_mb: None,
});
}
}
}
}
}
}
}
// 3. Tentar nvidia-smi se não encontrou GPUs NVIDIA
let has_nvidia = gpus.iter().any(|g| g.vendor.as_ref().map(|v| v == "NVIDIA").unwrap_or(false));
if !has_nvidia {
log::info!("Tentando nvidia-smi...");
if let Ok(nvidia_gpus) = detect_gpus_nvidia_smi_linux() {
for nvidia_gpu in nvidia_gpus {
if !gpus.iter().any(|g| g.name == nvidia_gpu.name) {
gpus.push(nvidia_gpu);
}
}
}
}
// 4. Tentar /sys/class/drm/ como último recurso
if gpus.is_empty() {
log::info!("Tentando /sys/class/drm/...");
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
for (idx, entry) in entries.enumerate() {
if let Ok(entry) = entry {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("card") && name != "card" {
if let Ok(name_file) = std::fs::read_to_string(path.join("name")) {
let gpu_name = name_file.trim().to_string();
if !gpu_name.is_empty() {
let vendor = detect_vendor_from_name(&gpu_name);
log::info!("GPU detectada via /sys/class/drm/: {}", gpu_name);
gpus.push(GpuInfo {
id: format!("gpu_{}", idx),
name: gpu_name,
vendor,
memory_mb: None,
});
}
}
}
}
}
}
}
}
gpus
}
/// Detecta GPUs NVIDIA no Linux usando nvidia-smi
#[cfg(target_os = "linux")]
fn detect_gpus_nvidia_smi_linux() -> Result<Vec<GpuInfo>, String> {
use std::process::Command;
let output = Command::new("nvidia-smi")
.args(&["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
.output()
.map_err(|e| format!("nvidia-smi não encontrado: {}", e))?;
if !output.status.success() {
return Err("nvidia-smi falhou".to_string());
}
let stdout = String::from_utf8(output.stdout)
.map_err(|e| format!("Erro ao parsear output do nvidia-smi: {}", e))?;
let mut gpus = Vec::new();
for (idx, line) in stdout.lines().enumerate() {
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() >= 2 {
let name = parts[0].trim().to_string();
let memory_mb = parts[1].trim().parse::<u64>().ok();
if !name.is_empty() {
log::info!("GPU NVIDIA detectada via nvidia-smi: {} ({} MB)", name, memory_mb.unwrap_or(0));
gpus.push(GpuInfo {
id: format!("nvidia_gpu_{}", idx),
name,
vendor: Some("NVIDIA".to_string()),
memory_mb,
});
}
}
}
Ok(gpus)
}
/// Detecta GPUs no macOS usando system_profiler
#[cfg(target_os = "macos")]
fn detect_gpus_macos() -> Vec<GpuInfo> {
use std::process::Command;
let mut gpus = Vec::new();
log::info!("Executando system_profiler...");
if let Ok(output) = Command::new("system_profiler")
.args(&["SPDisplaysDataType"])
.output()
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
let mut current_gpu: Option<GpuInfo> = None;
let mut gpu_index = 0;
for line in stdout.lines() {
let line = line.trim();
if line.starts_with("Chipset Model:") {
// Finalizar GPU anterior se houver
if let Some(gpu) = current_gpu.take() {
gpus.push(gpu);
}
let name = line.replace("Chipset Model:", "").trim().to_string();
if !name.is_empty() {
let vendor = detect_vendor_from_name(&name);
log::info!("GPU detectada no macOS: {}", name);
current_gpu = Some(GpuInfo {
id: format!("gpu_{}", gpu_index),
name,
vendor,
memory_mb: None,
});
gpu_index += 1;
}
} else if line.starts_with("VRAM (Total):") {
if let Some(gpu) = &mut current_gpu {
let vram_str = line.replace("VRAM (Total):", "").trim();
if let Some(mb) = parse_memory_string(&vram_str) {
gpu.memory_mb = Some(mb);
log::info!("VRAM detectada: {} MB", mb);
}
}
}
}
// Adicionar última GPU se houver
if let Some(gpu) = current_gpu {
gpus.push(gpu);
}
}
}
gpus
}
/// Detecta o vendor (fabricante) da GPU baseado no nome
fn detect_vendor_from_name(name: &str) -> Option<String> {
let name_lower = name.to_lowercase();
if name_lower.contains("nvidia") || name_lower.contains("geforce") || name_lower.contains("quadro") || name_lower.contains("tesla") {
return Some("NVIDIA".to_string());
}
if name_lower.contains("amd") || name_lower.contains("radeon") || name_lower.contains("firepro") {
return Some("AMD".to_string());
}
if name_lower.contains("intel") || name_lower.contains("iris") || name_lower.contains("uhd") || name_lower.contains("hd graphics") {
return Some("Intel".to_string());
}
if name_lower.contains("apple") {
return Some("Apple".to_string());
}
None
}
/// Parse string de memória (ex: "8 GB" -> 8192 MB)
#[allow(dead_code)]
fn parse_memory_string(s: &str) -> Option<u64> {
let s = s.trim().to_lowercase();
// Tentar extrair número e unidade
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.is_empty() {
return None;
}
if let Ok(value) = parts[0].parse::<f64>() {
let multiplier = if s.contains("gb") || s.contains("g") {
1024 // GB para MB
} else if s.contains("mb") || s.contains("m") {
1 // Já está em MB
} else {
// Assumir GB se não especificado
1024
};
return Some((value * multiplier as f64) as u64);
}
None
}
+158 -1
View File
@@ -1,5 +1,5 @@
use anyhow::Result;
use headless_chrome::{Browser, LaunchOptions};
use headless_chrome::{Browser, LaunchOptions, Tab};
use reqwest::header::USER_AGENT;
use scraper::{Html, Selector};
use std::sync::Arc;
@@ -715,6 +715,21 @@ fn fetch_and_convert_sync(browser: &Browser, url: &str) -> Result<ScrapedContent
return Err(anyhow::anyhow!("Timeout ao processar página"));
}
// Injetar script para bloquear autoplay de mídia
// Isso garante que nenhum vídeo/áudio seja reproduzido durante o scraping
match disable_media_autoplay(&tab) {
Ok(_) => {
log::debug!("Autoplay de mídia bloqueado para: {}", url);
}
Err(e) => {
log::warn!("Aviso: Falha ao bloquear autoplay em {}: {}", url, e);
// Não falhar o scraping por causa disso, apenas logar
}
}
// Aguardar pequeno delay para garantir que script foi executado
std::thread::sleep(Duration::from_millis(100));
// Extrair HTML renderizado
let content = match tab.get_content() {
Ok(c) => c,
@@ -773,10 +788,152 @@ fn fetch_and_convert_sync(browser: &Browser, url: &str) -> Result<ScrapedContent
}
}
/// Desabilita autoplay de mídia injetando JavaScript na página
/// Esta função pausa todos os elementos de vídeo/áudio e previne autoplay
fn disable_media_autoplay(tab: &Tab) -> Result<()> {
let script = r#"
(function() {
// Função para pausar todos os elementos de mídia
const pauseAllMedia = () => {
const mediaElements = document.querySelectorAll('video, audio');
let pausedCount = 0;
mediaElements.forEach(media => {
if (!media.paused) {
media.pause();
pausedCount++;
}
// Remover atributo autoplay
media.removeAttribute('autoplay');
media.autoplay = false;
// Silenciar mídia
media.muted = true;
media.volume = 0;
});
return pausedCount;
};
// Executar imediatamente
const initialPaused = pauseAllMedia();
// Observar mudanças no DOM para novos elementos de mídia
const observer = new MutationObserver(() => {
pauseAllMedia();
});
if (document.body) {
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Prevenir autoplay interceptando o método play()
const originalPlay = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
// Bloquear play se página não estiver em foco ou estiver oculta
if (document.hidden || !document.hasFocus()) {
return Promise.reject(new Error('Autoplay blocked by scraper'));
}
// Pausar imediatamente após tentativa de play
const result = originalPlay.call(this);
if (result && typeof result.then === 'function') {
result.then(() => {
this.pause();
this.muted = true;
}).catch(() => {});
} else {
this.pause();
this.muted = true;
}
return Promise.reject(new Error('Autoplay blocked'));
};
// Pausar quando página perder foco
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
pauseAllMedia();
}
});
// Bloquear Web Audio API
if (window.AudioContext) {
const OriginalAudioContext = window.AudioContext;
window.AudioContext = function() {
console.warn('AudioContext blocked by scraper');
return null;
};
window.AudioContext.prototype = OriginalAudioContext.prototype;
}
if (window.webkitAudioContext) {
const OriginalWebkitAudioContext = window.webkitAudioContext;
window.webkitAudioContext = function() {
console.warn('webkitAudioContext blocked by scraper');
return null;
};
window.webkitAudioContext.prototype = OriginalWebkitAudioContext.prototype;
}
// Bloquear mídia em iframes também
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
const iframeMedia = iframeDoc.querySelectorAll('video, audio');
iframeMedia.forEach(media => {
media.pause();
media.muted = true;
media.volume = 0;
media.removeAttribute('autoplay');
});
}
} catch (e) {
// Ignorar erros de cross-origin
}
});
// Retornar contagem para logging
return initialPaused;
})();
"#;
match tab.evaluate(script, false) {
Ok(result) => {
// Tentar extrair contagem de elementos pausados do resultado
if let Some(count) = result.value {
log::info!("Script de bloqueio de mídia injetado: {} elementos pausados", count);
} else {
log::debug!("Script de bloqueio de mídia injetado com sucesso");
}
Ok(())
}
Err(e) => {
log::warn!("Erro ao injetar script de bloqueio de mídia: {}", e);
// Não falhar o scraping por causa disso, apenas logar
Ok(())
}
}
}
/// Cria uma instância do Browser (singleton para reutilização)
pub fn create_browser() -> Result<Browser> {
use std::ffi::OsStr;
// Argumentos do Chrome para bloquear autoplay de mídia
// Nota: O bloqueio principal será feito via JavaScript injection, mas esses args ajudam
let chrome_args: Vec<&OsStr> = vec![
OsStr::new("--autoplay-policy=document-user-activation-required"), // Exige interação do usuário para autoplay
OsStr::new("--disable-background-media-playback"), // Desabilita reprodução de mídia em segundo plano
OsStr::new("--mute-audio"), // Silencia todo áudio (mais agressivo, mas garante silêncio)
OsStr::new("--disable-features=AutoplayIgnoreWebAudio"), // Desabilita autoplay de Web Audio
];
let options = LaunchOptions {
headless: true,
args: chrome_args,
..Default::default()
};
+136
View File
@@ -13,6 +13,7 @@ export interface SettingsState {
// AI & Models
ollamaUrl: string;
selectedModel: string;
selectedGpu: string | null;
systemPrompt: string;
contextWindow: number;
@@ -28,6 +29,17 @@ export interface SettingsState {
userCustomSites: string[];
};
// Content Processing
contentProcessing: {
enabled: boolean;
autoSummarize: boolean;
chunkingEnabled: boolean;
maxChunkSize: number;
minRelevanceScore: number;
useKeyFactsExtraction: boolean;
fallbackToSummarization: boolean;
};
// Sources Config (from Rust backend)
sourcesConfig: SourcesConfig | null;
@@ -40,9 +52,19 @@ export interface SettingsState {
keywords: string[];
};
// Query Preprocessing
queryPreprocessing: {
enabled: boolean;
minLength: number;
maxLength: number;
autoSplitQuestions: boolean;
irrelevantPatterns: string[];
};
// Actions
setOllamaUrl: (url: string) => void;
setSelectedModel: (model: string) => void;
setSelectedGpu: (gpuId: string) => void;
setSystemPrompt: (prompt: string) => void;
setContextWindow: (ctx: number) => void;
setWebSearchEnabled: (enabled: boolean) => void;
@@ -65,6 +87,23 @@ export interface SettingsState {
setNotificationsEnabled: (enabled: boolean) => void;
addNotificationKeyword: (keyword: string) => void;
removeNotificationKeyword: (keyword: string) => void;
// Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled: boolean) => void;
setQueryPreprocessingMinLength: (min: number) => void;
setQueryPreprocessingMaxLength: (max: number) => void;
setQueryPreprocessingAutoSplit: (autoSplit: boolean) => void;
addIrrelevantPattern: (pattern: string) => void;
removeIrrelevantPattern: (pattern: string) => void;
// Content Processing Actions
setContentProcessingEnabled: (enabled: boolean) => void;
setContentProcessingAutoSummarize: (enabled: boolean) => void;
setContentProcessingChunkingEnabled: (enabled: boolean) => void;
setContentProcessingMaxChunkSize: (size: number) => void;
setContentProcessingMinRelevanceScore: (score: number) => void;
setContentProcessingUseKeyFactsExtraction: (enabled: boolean) => void;
setContentProcessingFallbackToSummarization: (enabled: boolean) => void;
}
const defaultSystemPrompt = `Você é um assistente de IA local integrado ao OllaHub.
@@ -151,6 +190,7 @@ const defaultCategories: SearchCategory[] = [
const initialState = {
ollamaUrl: 'http://localhost:11434',
selectedModel: '',
selectedGpu: null,
systemPrompt: defaultSystemPrompt,
contextWindow: 4096,
webSearch: {
@@ -169,6 +209,31 @@ const initialState = {
enabled: true,
keywords: [],
},
queryPreprocessing: {
enabled: true,
minLength: 3,
maxLength: 2000,
autoSplitQuestions: true,
irrelevantPatterns: [
'oi',
'olá',
'ola',
'teste',
'test',
'hello',
'hi',
'hey',
],
},
contentProcessing: {
enabled: true,
autoSummarize: true,
chunkingEnabled: true,
maxChunkSize: 1024,
minRelevanceScore: 0.1,
useKeyFactsExtraction: true,
fallbackToSummarization: true,
},
};
export const useSettingsStore = create<SettingsState>()(
@@ -178,6 +243,7 @@ export const useSettingsStore = create<SettingsState>()(
setOllamaUrl: (url) => set({ ollamaUrl: url }),
setSelectedModel: (model) => set({ selectedModel: model }),
setSelectedGpu: (gpuId) => set({ selectedGpu: gpuId }),
setSystemPrompt: (prompt) => set({ systemPrompt: prompt }),
setContextWindow: (ctx) => set({ contextWindow: ctx }),
setWebSearchEnabled: (enabled) =>
@@ -312,6 +378,76 @@ export const useSettingsStore = create<SettingsState>()(
keywords: state.notifications.keywords.filter((k) => k !== keyword),
},
})),
// Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled) =>
set((state) => ({
queryPreprocessing: { ...state.queryPreprocessing, enabled },
})),
setQueryPreprocessingMinLength: (min) =>
set((state) => ({
queryPreprocessing: { ...state.queryPreprocessing, minLength: min },
})),
setQueryPreprocessingMaxLength: (max) =>
set((state) => ({
queryPreprocessing: { ...state.queryPreprocessing, maxLength: max },
})),
setQueryPreprocessingAutoSplit: (autoSplit) =>
set((state) => ({
queryPreprocessing: { ...state.queryPreprocessing, autoSplitQuestions: autoSplit },
})),
addIrrelevantPattern: (pattern) =>
set((state) => {
const normalized = pattern.toLowerCase().trim();
if (!normalized || state.queryPreprocessing.irrelevantPatterns.includes(normalized)) {
return state;
}
return {
queryPreprocessing: {
...state.queryPreprocessing,
irrelevantPatterns: [...state.queryPreprocessing.irrelevantPatterns, normalized],
},
};
}),
removeIrrelevantPattern: (pattern) =>
set((state) => ({
queryPreprocessing: {
...state.queryPreprocessing,
irrelevantPatterns: state.queryPreprocessing.irrelevantPatterns.filter(
(p) => p !== pattern.toLowerCase().trim()
),
},
})),
// Content Processing Actions
setContentProcessingEnabled: (enabled) =>
set((state) => ({
contentProcessing: { ...state.contentProcessing, enabled },
})),
setContentProcessingAutoSummarize: (enabled) =>
set((state) => ({
contentProcessing: { ...state.contentProcessing, autoSummarize: enabled },
})),
setContentProcessingChunkingEnabled: (enabled) =>
set((state) => ({
contentProcessing: { ...state.contentProcessing, chunkingEnabled: enabled },
})),
setContentProcessingMaxChunkSize: (size) =>
set((state) => ({
contentProcessing: { ...state.contentProcessing, maxChunkSize: size },
})),
setContentProcessingMinRelevanceScore: (score) =>
set((state) => ({
contentProcessing: { ...state.contentProcessing, minRelevanceScore: score },
})),
setContentProcessingUseKeyFactsExtraction: (enabled) =>
set((state) => ({
contentProcessing: { ...state.contentProcessing, useKeyFactsExtraction: enabled },
})),
setContentProcessingFallbackToSummarization: (enabled) =>
set((state) => ({
contentProcessing: { ...state.contentProcessing, fallbackToSummarization: enabled },
})),
}),
{
name: 'ollahub-settings',