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:
1 parent
4275295e4f
commit
84ea2dec19
51 files changed
+7857
-573
No files matched your search
+762
-236
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).`
|
||||
};
|
||||
@@ -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.
|
||||
`
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }))
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+16
@@ -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]: {}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -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.
|
||||
|
||||
Generated
+22
@@ -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"
|
||||
|
||||
@@ -44,3 +44,4 @@ futures-util = "0.3"
|
||||
regex = "1.10"
|
||||
walkdir = "2.5"
|
||||
tauri-plugin-autostart = "2"
|
||||
dirs = "5.0"
|
||||
@@ -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
@@ -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!())
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user