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

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

No files matched your search

+105 -51
View File
@@ -1,13 +1,31 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Square, Globe, WifiOff, ChevronUp } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle } from "lucide-react";
import { useState, useRef, useEffect, useMemo } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { quickValidate, validateQuery } from "@/lib/query-validation";
import { useSettingsStore, type SearchCategory } from "@/store/settings-store";
interface ChatInputProps {
onSend: (message: string) => void;
@@ -31,6 +49,35 @@ export function ChatInput({
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [webDialogOpen, setWebDialogOpen] = useState(false);
const settings = useSettingsStore();
// Validação em tempo real
const validation = useMemo(() => {
if (!input.trim()) {
return { isValid: true, errors: [], warnings: [] };
}
const preprocessingConfig = settings.queryPreprocessing || {
enabled: true,
minLength: 3,
maxLength: 2000,
autoSplitQuestions: true,
irrelevantPatterns: [],
};
if (!preprocessingConfig.enabled) {
return { isValid: true, errors: [], warnings: [] };
}
return validateQuery(input, {
minLength: preprocessingConfig.minLength,
maxLength: preprocessingConfig.maxLength,
irrelevantPatterns: preprocessingConfig.irrelevantPatterns.length > 0
? preprocessingConfig.irrelevantPatterns
: undefined,
});
}, [input, settings.queryPreprocessing]);
const isValid = validation.isValid;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -40,7 +87,7 @@ export function ChatInput({
};
const handleSend = () => {
if (input.trim() && !isLoading) {
if (input.trim() && !isLoading && isValid) {
onSend(input);
setInput("");
}
@@ -49,31 +96,37 @@ export function ChatInput({
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const scrollHeight = textareaRef.current.scrollHeight;
// Limitar altura máxima a uns 200px
textareaRef.current.style.height = `${Math.min(scrollHeight, 200)}px`;
}
}, [input]);
return (
<div className="p-6 bg-transparent">
<div className="relative max-w-3xl mx-auto w-full">
<div className="flex-1 relative">
<div className="w-full bg-transparent">
<div className="relative w-full">
<div className="flex-1 relative bg-background dark:bg-zinc-900/50 rounded-[2rem] border border-border/50 shadow-lg backdrop-blur-xl transition-all duration-200 focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/40 hover:border-primary/30">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="min-h-[48px] resize-none overflow-hidden py-4 pr-12 rounded-xl border shadow-lg"
placeholder="Pergunte qualquer coisa..."
className={`min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-4 pl-6 pr-32 text-base sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${
input.trim() && !isValid ? 'text-destructive' : ''
}`}
rows={1}
/>
<div className="absolute right-2 bottom-2 flex items-center gap-2">
{/* Right Actions */}
<div className="absolute right-3 bottom-2.5 flex items-center gap-1.5">
{onWebSearchToggle && (
<TooltipProvider>
<Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
size="icon"
variant={webSearchEnabled ? "default" : "ghost"}
variant={webSearchEnabled ? "ghost" : "ghost"}
onClick={() => {
if (!webSearchEnabled) {
onWebSearchToggle(true);
@@ -81,7 +134,11 @@ export function ChatInput({
setWebDialogOpen(true);
}
}}
className="h-8 w-8"
className={`h-9 w-9 rounded-full transition-all ${
webSearchEnabled
? "text-primary bg-primary/10 hover:bg-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
disabled={isLoading}
>
{webSearchEnabled ? (
@@ -101,14 +158,15 @@ export function ChatInput({
</Tooltip>
</TooltipProvider>
)}
{onToggleCategory && categories && categories.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" className="h-8 w-8" title="Gerenciar fontes">
<Button size="icon" variant="ghost" className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted" title="Gerenciar fontes">
<ChevronUp className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuContent align="end" sideOffset={12} className="w-56 rounded-xl">
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
<DropdownMenuSeparator />
{categories.map((cat) => (
@@ -123,68 +181,64 @@ export function ChatInput({
</DropdownMenuContent>
</DropdownMenu>
)}
{isLoading ? (
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
<Square className="h-4 w-4 fill-current" />
<Button
size="icon"
variant="destructive"
onClick={onStop}
className="h-9 w-9 rounded-full shadow-sm animate-in fade-in zoom-in duration-200"
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
) : (
<Button
size="icon"
onClick={handleSend}
disabled={!input.trim()}
className="h-8 w-8"
disabled={!input.trim() || !isValid}
className={`h-9 w-9 rounded-full transition-all shadow-sm ${
input.trim() && isValid
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
>
<Send className="h-4 w-4" />
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
{/* Validation Errors */}
{input.trim() && !isValid && validation.errors.length > 0 && (
<div className="absolute -top-12 left-0 right-0 mx-auto w-max max-w-[90%] animate-in fade-in slide-in-from-bottom-2">
<div className="bg-destructive/90 text-destructive-foreground text-xs px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 backdrop-blur-sm">
<AlertCircle className="h-3.5 w-3.5" />
<span>{validation.errors[0]}</span>
</div>
</div>
)}
</div>
<Dialog open={webDialogOpen} onOpenChange={setWebDialogOpen}>
<DialogContent>
<DialogContent className="sm:max-w-[425px] rounded-2xl">
<DialogHeader>
<DialogTitle>Desativar pesquisa web?</DialogTitle>
<DialogDescription>
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes, preços, notícias e conteúdo em rápida mudança.
Use com cautela — modelos locais sem contexto web tendem a alucinar mais nesses cenários.
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setWebDialogOpen(false)}>Manter ativado</Button>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setWebDialogOpen(false)} className="rounded-full">Cancelar</Button>
<Button
variant="destructive"
onClick={() => { onWebSearchToggle?.(false); setWebDialogOpen(false); }}
className="rounded-full"
>
Desativar pesquisa web
Desativar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="text-xs text-center text-muted-foreground mt-2">
OllaHub pode cometer erros. Verifique informações importantes.
</div>
</div>
);
}
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { SearchCategory } from "@/store/settings-store";
+95 -41
View File
@@ -1,71 +1,125 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { cn } from "@/lib/utils";
import { Message } from "@/hooks/use-chat";
import { Bot, User } from "lucide-react";
import { markdownRenderers } from "./markdown-renderers";
import { MessageActions } from "./message-actions";
import { MessageBackground } from "./message-background";
import { MessageStepThinking } from "./message-step-thinking";
import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
}
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) {
// Verificar se é mensagem de processo de pensamento
const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' &&
'type' in message.metadata &&
message.metadata.type === 'thinking';
if (isThinkingMessage) {
return <MessageStepThinking metadata={message.metadata as ThinkingMessageMetadata} />;
}
export function ChatMessage({ message }: { message: Message }) {
const isUser = message.role === 'user';
const [selected, setSelected] = useState(false);
const isEmpty = !message.content || message.content.trim() === '';
// Memoizar processamento de conteúdo
const processedContent = useMemo(() => {
let content = message.content || '';
// Remove metadata tags
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Also remove common prefixes
const prefixes = [
'Bloco oculto de metadados:', 'Metadata:', 'Metadados:',
'Hidden metadata block:', 'JSON metadata:', '---'
];
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(content)) {
content = content.replace(regex, '').trim();
}
}
return content || message.content;
}, [message.content]);
return (
<MessageBackground
role={isUser ? 'user' : 'assistant'}
state={selected ? 'selected' : 'normal'}
className={cn("cursor-pointer")}
className={cn(
"cursor-default transition-colors rounded-xl",
selected && "bg-muted/40",
isUser ? "mb-2" : "mb-8"
)}
onClick={() => setSelected((v) => !v)}
tabIndex={0}
>
<div className="flex-shrink-0 mt-1">
<div className="flex-shrink-0 mt-0.5">
<div className={cn(
"w-8 h-8 rounded-md flex items-center justify-center",
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
isUser
? "bg-background text-muted-foreground"
: "bg-primary/10 text-primary ring-primary/20"
)}>
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
</div>
</div>
<div className="flex-1 overflow-hidden min-w-0">
<div className="prose dark:prose-invert max-w-none break-words leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{(() => {
let content = message.content || '';
// Remove metadata tags
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Also remove common prefixes if they appear at the end
const prefixes = [
'Bloco oculto de metadados:',
'Metadata:',
'Metadados:',
'Hidden metadata block:',
'JSON metadata:',
'---'
];
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(content)) {
content = content.replace(regex, '').trim();
}
}
return content || message.content;
})()}
</ReactMarkdown>
</div>
<div className="flex-1 overflow-hidden min-w-0 pt-1">
{isUser ? (
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
{processedContent}
</div>
) : (
// Estilo Assistant
isEmpty && isStreaming ? (
<div className="flex items-center gap-2 text-muted-foreground py-1">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-sm font-medium">Gerando resposta...</span>
</div>
) : (
<div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
"prose-p:leading-7 prose-li:leading-7",
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
isStreaming && "relative"
)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{processedContent}
</ReactMarkdown>
{isStreaming && !isEmpty && (
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
)}
</div>
)
)}
</div>
{!isUser && (
<div className="absolute top-4 right-4">
{!isUser && !isStreaming && (
<div className={cn(
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
"group-hover:opacity-100",
selected && "opacity-100"
)}>
<MessageActions content={message.content} role={message.role} />
</div>
)}
</MessageBackground>
);
}
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { AlertCircle, RefreshCw, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface ErrorDisplayProps {
error: string | Error;
onRetry?: () => void;
onDismiss?: () => void;
className?: string;
}
export function ErrorDisplay({ error, onRetry, onDismiss, className }: ErrorDisplayProps) {
const errorMessage = error instanceof Error ? error.message : String(error);
return (
<div
className={cn(
"mx-4 mb-3 px-4 py-3 rounded-lg border border-red-500/50 bg-red-500/10",
className
)}
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">
Erro ao processar
</div>
<p className="text-sm text-red-700 dark:text-red-300 break-words">
{errorMessage}
</p>
{(onRetry || onDismiss) && (
<div className="flex items-center gap-2 mt-3">
{onRetry && (
<Button
size="sm"
variant="outline"
onClick={onRetry}
className="h-7 text-xs border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-500/20"
>
<RefreshCw className="w-3 h-3 mr-1.5" />
Tentar novamente
</Button>
)}
{onDismiss && (
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
>
<X className="w-3 h-3 mr-1.5" />
Fechar
</Button>
)}
</div>
)}
</div>
</div>
</div>
);
}
+13 -5
View File
@@ -18,11 +18,19 @@ export function MessageBackground({
children,
...props
}: MessageBackgroundProps) {
const base = "group flex w-full gap-3 md:gap-4 relative transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
const base = "group flex w-full gap-4 md:gap-6 relative transition-all duration-200";
// User: Fundo sutil, alinhado à direita (se quiséssemos, mas o layout atual é lista).
// Vamos manter layout lista mas com estilo clean.
// Perplexity usa: User message apenas texto bold/destacado, Assistant texto normal.
const roleCl = role === "user"
? "rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-3"
: "bg-transparent px-0 py-0";
const stateCl = state === "selected" ? "ring-2 ring-primary/40" : state === "highlighted" ? "ring-1 ring-accent/30" : "";
? "bg-transparent text-foreground px-0 py-2" // Minimalista
: "bg-transparent text-foreground/90 px-0 py-2";
// Estado de seleção sutil
const stateCl = state === "selected" ? "bg-muted/30 -mx-4 px-4 rounded-lg" : "";
const hc = highContrast ? "hc:bg-message-high-contrast hc:text-foreground" : "";
return (
@@ -34,4 +42,4 @@ export function MessageBackground({
{children}
</div>
);
}
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import {
Search,
Brain,
Globe,
Package,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
ChevronDown,
ChevronUp,
ExternalLink,
Copy,
Check
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
interface MessageStepThinkingProps {
metadata: ThinkingMessageMetadata;
}
export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const getStepConfig = () => {
switch (metadata.stepType) {
case 'preprocessing':
return {
icon: Brain,
textColor: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-900/20",
};
case 'web-research':
return {
icon: Globe,
textColor: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-900/20",
};
case 'sources-found':
return {
icon: Package,
textColor: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-900/20",
};
case 'processing':
return {
icon: Brain,
textColor: "text-indigo-600 dark:text-indigo-400",
bgColor: "bg-indigo-50 dark:bg-indigo-900/20",
};
case 'response-generation':
return {
icon: Loader2,
textColor: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-50 dark:bg-orange-900/20",
};
case 'fallback':
return {
icon: AlertCircle,
textColor: "text-yellow-600 dark:text-yellow-400",
bgColor: "bg-yellow-50 dark:bg-yellow-900/20",
};
case 'error':
return {
icon: XCircle,
textColor: "text-red-600 dark:text-red-400",
bgColor: "bg-red-50 dark:bg-red-900/20",
};
default:
return {
icon: Search,
textColor: "text-muted-foreground",
bgColor: "bg-muted/30",
};
}
};
const getStepIcon = () => {
const config = getStepConfig();
const Icon = config.icon;
const isSpinning = metadata.stepType === 'response-generation' && metadata.status === 'running';
return <Icon className={cn("w-4 h-4", config.textColor, isSpinning && "animate-spin")} />;
};
const copyToClipboard = async (url: string, index: number) => {
try {
await navigator.clipboard.writeText(url);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (error) {
console.error('Failed to copy URL:', error);
}
};
const config = getStepConfig();
return (
<div className="mb-4 ml-10 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className={cn(
"flex items-center gap-3 p-2 rounded-lg transition-all w-fit max-w-full",
config.bgColor
)}>
<div className="flex items-center gap-2">
{getStepIcon()}
<span className={cn("text-sm font-medium", config.textColor)}>
{metadata.label}
</span>
</div>
{metadata.status === 'running' && (
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground/50" />
)}
{(metadata.details || metadata.sources || metadata.error) && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-3.5 h-3.5 opacity-50" />
) : (
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
)}
</button>
)}
</div>
{/* Progress Bar Compacta */}
{metadata.progress !== undefined && metadata.progress < 100 && (
<div className="ml-1 mt-1 w-32 h-1 bg-muted/50 rounded-full overflow-hidden">
<div
className={cn("h-full transition-all duration-300", config.textColor.replace('text-', 'bg-'))}
style={{ width: `${metadata.progress}%` }}
/>
</div>
)}
{/* Conteúdo Expandido */}
{isExpanded && (
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
{metadata.details && (
<div className="whitespace-pre-wrap">{metadata.details}</div>
)}
{metadata.sources && metadata.sources.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{metadata.sources.map((source, idx) => (
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
<ExternalLink className="w-3 h-3 opacity-50" />
<a href={source.url} target="_blank" className="truncate hover:underline flex-1">{source.title || source.url}</a>
<button onClick={() => copyToClipboard(source.url, idx)} className="ml-1 opacity-50 hover:opacity-100">
{copiedIndex === idx ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
</button>
</div>
))}
</div>
)}
{metadata.error && (
<div className="text-red-500 font-medium flex items-center gap-2">
<XCircle className="w-4 h-4" />
{metadata.error}
</div>
)}
</div>
)}
</div>
);
}
+367
View File
@@ -0,0 +1,367 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Label } from "@/components/ui/label";
import { Download, Loader2, X } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface ModelDownloadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (modelName: string) => void;
}
interface DownloadInfo {
status: string;
percent?: number;
downloaded?: string;
total?: string;
speed?: string;
raw: string;
}
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
const [modelName, setModelName] = useState("");
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState("");
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const isDownloadingRef = useRef(false);
const modelNameRef = useRef("");
// Atualizar refs quando estado muda
useEffect(() => {
isDownloadingRef.current = isDownloading;
modelNameRef.current = modelName;
}, [isDownloading, modelName]);
// Resetar estado quando dialog abre/fecha
useEffect(() => {
if (open) {
setModelName("");
setIsDownloading(false);
setDownloadProgress(0);
setDownloadStatus("");
setDownloadInfo(null);
setError(null);
setIsInstalled(false);
isDownloadingRef.current = false;
modelNameRef.current = "";
}
}, [open]);
// Verificar se modelo já está instalado quando nome muda
useEffect(() => {
if (modelName.trim() && !isDownloading) {
checkInstallation(modelName.trim());
}
}, [modelName, isDownloading]);
// Listener para progresso do download - sempre ativo quando dialog está aberto
useEffect(() => {
if (!open) return;
let unlistenFn: (() => void) | null = null;
const setupListener = async () => {
const unlisten = await listen<string>('download-progress', (event) => {
// Só processar se estiver fazendo download (usar ref para evitar stale closure)
if (!isDownloadingRef.current) return;
const payload = event.payload;
// Tentar parsear como JSON estruturado
let info: DownloadInfo | null = null;
try {
info = JSON.parse(payload) as DownloadInfo;
} catch {
// Fallback: tratar como string raw
info = {
status: "downloading",
raw: payload,
};
}
if (info) {
setDownloadInfo(info);
// Atualizar status text
const statusText = info.status === "pulling" ? "Baixando modelo..." :
info.status === "verifying" ? "Verificando..." :
info.status === "writing" ? "Salvando..." :
info.status === "success" ? "Concluído!" :
"Baixando...";
setDownloadStatus(statusText);
// Atualizar progresso
if (info.percent !== undefined) {
setDownloadProgress(info.percent);
} else {
// Fallback: estimar progresso baseado no status
if (info.status === "pulling") {
setDownloadProgress(prev => prev < 10 ? 10 : prev);
} else if (info.status === "verifying") {
setDownloadProgress(90);
} else if (info.status === "writing") {
setDownloadProgress(95);
}
}
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
if (info.percent === 100 || info.status === "success") {
setTimeout(async () => {
setIsInstalled(true);
setIsDownloading(false);
isDownloadingRef.current = false;
const currentModelName = modelNameRef.current.trim();
await checkInstallation(currentModelName);
// Após verificar instalação, chamar onSuccess e fechar
if (onSuccess) {
onSuccess(currentModelName);
}
onOpenChange(false);
}, 500);
}
}
});
unlistenFn = unlisten;
};
setupListener();
return () => {
if (unlistenFn) {
unlistenFn();
}
};
}, [open, onSuccess, onOpenChange]);
const checkInstallation = async (name: string) => {
try {
const installed = await invoke<boolean>('check_if_model_installed', { name });
setIsInstalled(installed);
if (installed) {
setError(null);
}
} catch (e) {
console.error("Check failed", e);
}
};
const validateModelName = (name: string): string | null => {
const trimmed = name.trim();
if (!trimmed) {
return "Nome do modelo não pode estar vazio";
}
// Validar formato: aceita letras, números, pontos, underscores, hífens, dois pontos e barras
// Exemplos válidos: "llama3.2:1b", "huihui_ai/qwen3-abliterated:1.7b", "mistral:latest"
if (!/^[a-zA-Z0-9._:\/-]+$/.test(trimmed)) {
return "Nome do modelo contém caracteres inválidos";
}
// Não pode começar ou terminar com barra, dois pontos ou hífen
if (/^[\/:\-]|[\/:\-]$/.test(trimmed)) {
return "Nome do modelo não pode começar ou terminar com /, : ou -";
}
return null;
};
const handleDownload = async () => {
const trimmedName = modelName.trim();
const validationError = validateModelName(trimmedName);
if (validationError) {
setError(validationError);
return;
}
if (isInstalled) {
// Se já está instalado, apenas fechar e notificar sucesso
handleSuccess(trimmedName);
return;
}
setIsDownloading(true);
isDownloadingRef.current = true;
setDownloadProgress(0);
setDownloadStatus("Iniciando download...");
setError(null);
modelNameRef.current = trimmedName;
try {
await invoke('pull_model', { name: trimmedName });
// O status será atualizado pelo listener quando o download completar
} catch (error: any) {
console.error("Download failed", error);
setError(error?.message || "Falha no download. Tente novamente.");
setDownloadStatus("");
setDownloadProgress(0);
setIsDownloading(false);
isDownloadingRef.current = false;
}
};
const handleSuccess = (name: string) => {
// Só fechar e chamar onSuccess se realmente completou o download
// Se já estava instalado, também chamar onSuccess
if (onSuccess) {
onSuccess(name);
}
// Não fechar automaticamente se foi apenas verificação de instalação
// Deixar o usuário decidir quando fechar
};
const handleClose = () => {
// Permitir fechar sempre, mas avisar se estiver baixando
if (isDownloading) {
const confirmed = window.confirm("O download está em andamento. Deseja realmente fechar? O download continuará em segundo plano.");
if (!confirmed) {
return;
}
}
onOpenChange(false);
};
const handleCancel = () => {
// Cancelar download (por enquanto apenas fecha o dialog e para o estado)
// TODO: Implementar cancelamento real do processo ollama pull
if (isDownloading) {
const confirmed = window.confirm("Deseja cancelar o download? O processo continuará em segundo plano.");
if (confirmed) {
setIsDownloading(false);
isDownloadingRef.current = false;
setDownloadProgress(0);
setDownloadStatus("");
onOpenChange(false);
}
} else {
onOpenChange(false);
}
};
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
return (
<Dialog open={open} onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Adicionar Modelo</DialogTitle>
<DialogDescription>
Digite o nome do modelo do Ollama que deseja baixar (ex: llama3.2:1b, mistral:latest)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="model-name">Nome do Modelo</Label>
<Input
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ex: llama3.2:1b"
disabled={isDownloading}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid && !isDownloading) {
handleDownload();
}
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{isInstalled && !isDownloading && (
<p className="text-sm text-green-600 dark:text-green-400">
✓ Este modelo já está instalado
</p>
)}
</div>
{isDownloading && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{downloadStatus || "Iniciando..."}</span>
<span>{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadInfo.downloaded && downloadInfo.total
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
: downloadInfo.downloaded || ""}
</span>
{downloadInfo.speed && (
<span>{downloadInfo.speed}</span>
)}
</div>
)}
</div>
)}
{isInstalled && !isDownloading && (
<div className="bg-muted/50 p-3 rounded-md text-sm">
<p className="text-muted-foreground">
O modelo está instalado e pronto para uso.
</p>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
>
{isDownloading ? (
<>
<X className="mr-2 h-4 w-4" />
Cancelar Download
</>
) : (
<>
<X className="mr-2 h-4 w-4" />
Cancelar
</>
)}
</Button>
<Button
onClick={() => {
if (isInstalled && !isDownloading) {
handleSuccess(modelName.trim());
onOpenChange(false);
} else {
handleDownload();
}
}}
disabled={!isValid || isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Baixando...
</>
) : isInstalled ? (
<>
Usar Modelo
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Baixar
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { ChevronDown, ChevronUp, Clock, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
export interface ProcessStep {
id: string;
label: string;
status: 'pending' | 'running' | 'completed' | 'error';
timestamp?: number;
duration?: number;
details?: string;
error?: string;
}
interface PostProcessingPanelProps {
steps: ProcessStep[];
className?: string;
}
export function PostProcessingPanel({ steps, className }: PostProcessingPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!steps || steps.length === 0) {
return null;
}
const completedSteps = steps.filter(s => s.status === 'completed').length;
const hasErrors = steps.some(s => s.status === 'error');
const isRunning = steps.some(s => s.status === 'running');
const getStepIcon = (step: ProcessStep) => {
switch (step.status) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />;
case 'error':
return <XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />;
case 'running':
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
default:
return <Clock className="w-4 h-4 text-muted-foreground" />;
}
};
const formatDuration = (ms?: number) => {
if (!ms) return '';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
return (
<div className={cn("border-t border-muted bg-muted/20", className)}>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-6 py-3 flex items-center justify-between hover:bg-muted/40 transition-colors"
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{hasErrors ? (
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
) : isRunning ? (
<Loader2 className="w-4 h-4 text-primary animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
)}
<span className="text-sm font-medium">
Processo de Pensamento
</span>
</div>
<span className="text-xs text-muted-foreground">
({completedSteps}/{steps.length} etapas)
</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="px-6 pb-4 space-y-3">
{steps.map((step, idx) => (
<div
key={step.id}
className={cn(
"p-3 rounded-lg border transition-all",
step.status === 'completed' && "bg-green-500/5 border-green-500/20",
step.status === 'error' && "bg-red-500/5 border-red-500/20",
step.status === 'running' && "bg-primary/5 border-primary/20",
step.status === 'pending' && "bg-muted/50 border-muted"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 flex-1 min-w-0">
{getStepIcon(step)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{step.label}</span>
{step.duration && (
<span className="text-xs text-muted-foreground">
({formatDuration(step.duration)})
</span>
)}
</div>
{step.details && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
{step.details}
</p>
)}
{step.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{step.error}
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
'use client';
import { Search, Brain, Package, Loader2, CheckCircle2, Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState, useEffect } from 'react';
export type ProcessingStep =
| 'collecting'
| 'processing'
| 'searching'
| 'preparing'
| 'streaming';
interface ProcessingIndicatorProps {
currentStep: ProcessingStep;
progress?: number; // 0-100
sourcesCount?: number;
}
export function ProcessingIndicator({
currentStep,
progress = 0,
sourcesCount = 0,
}: ProcessingIndicatorProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Fade-in animation
setIsVisible(true);
}, []);
const steps: Array<{
key: ProcessingStep;
label: string;
icon: React.ReactNode;
emoji: string;
}> = [
{
key: 'collecting',
label: 'Coletando Dados',
icon: <Search className="w-4 h-4" />,
emoji: '🔍',
},
{
key: 'processing',
label: 'Processando...',
icon: <Brain className="w-4 h-4" />,
emoji: '🤖',
},
{
key: 'searching',
label: sourcesCount > 0 ? `Buscando na Web (${sourcesCount} fontes)...` : 'Buscando na Web...',
icon: <Globe className="w-4 h-4" />,
emoji: '🌐',
},
{
key: 'preparing',
label: 'Preparando Contexto',
icon: <Package className="w-4 h-4" />,
emoji: '📦',
},
{
key: 'streaming',
label: 'Gerando Resposta...',
icon: <Loader2 className="w-4 h-4 animate-spin" />,
emoji: '💬',
},
];
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
const currentStepData = steps[currentStepIndex] || steps[0];
return (
<div
className={cn(
"mx-4 mb-3 px-4 py-3 rounded-lg border border-muted/50 bg-muted/20 transition-all duration-300",
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
)}
>
{/* Card de Status Atual */}
<div className="flex items-center gap-3 mb-3">
<div className="flex-shrink-0">
<div className="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
{currentStep === 'collecting' && (
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-ping" />
)}
{currentStep === 'processing' && (
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-pulse" />
)}
{currentStep === 'searching' && (
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-spin" style={{ animationDuration: '2s' }} />
)}
<span className="text-xl relative z-10">{currentStepData.emoji}</span>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">
{currentStepData.label}
</div>
{progress > 0 && progress < 100 && (
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
</div>
{/* Timeline Visual */}
<div className="hidden md:flex items-center gap-2 text-xs text-muted-foreground">
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
const isActive = idx === currentStepIndex;
const isPast = idx < currentStepIndex;
return (
<div key={step.key} className="flex items-center gap-1.5">
<div
className={cn(
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-300",
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-primary/20 text-primary animate-pulse",
!isActive && !isPast && "bg-muted text-muted-foreground"
)}
>
{isPast ? (
<CheckCircle2 className="w-3 h-3" />
) : isActive ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
step.icon
)}
</div>
<span
className={cn(
"transition-colors duration-300",
isActive && "text-foreground font-medium",
isPast && "text-muted-foreground opacity-60"
)}
>
{step.label.split(' ')[0]} {/* Mostrar apenas primeira palavra na timeline */}
</span>
{idx < currentStepIndex && (
<div className="w-6 h-px bg-muted mx-1" />
)}
</div>
);
})}
</div>
{/* Timeline Vertical para Mobile */}
<div className="md:hidden space-y-2">
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
const isActive = idx === currentStepIndex;
const isPast = idx < currentStepIndex;
return (
<div key={step.key} className="flex items-center gap-2">
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full transition-all",
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-primary/20 text-primary animate-pulse",
!isActive && !isPast && "bg-muted text-muted-foreground"
)}
>
{isPast ? (
<CheckCircle2 className="w-3 h-3" />
) : isActive ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
step.icon
)}
</div>
<span
className={cn(
"text-xs transition-colors",
isActive && "text-foreground font-medium",
isPast && "text-muted-foreground opacity-60"
)}
>
{step.label}
</span>
</div>
);
})}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { ExternalLink, Copy, Check, Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState, useMemo } from 'react';
import type { ScrapedContent } from '@/services/webSearch';
interface SourcesDisplayProps {
sources: ScrapedContent[];
className?: string;
}
export function SourcesDisplay({ sources, className }: SourcesDisplayProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
if (!sources || sources.length === 0) {
return null;
}
const copyToClipboard = async (url: string, index: number) => {
try {
await navigator.clipboard.writeText(url);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (error) {
console.error('Failed to copy URL:', error);
}
};
// Memoizar URLs de favicon para evitar recálculos
const faviconUrls = useMemo(() => {
return sources.map(source => {
try {
const domain = new URL(source.url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
} catch {
return null;
}
});
}, [sources]);
return (
<div className={cn("px-6 py-4 border-t border-muted bg-muted/20", className)}>
<div className="flex items-center gap-2 mb-3">
<Globe className="w-4 h-4 text-muted-foreground" />
<p className="text-xs font-medium text-muted-foreground">
Fontes consultadas ({sources.length}):
</p>
</div>
<div className="flex flex-wrap gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{sources.map((source, idx) => {
const faviconUrl = faviconUrls[idx];
const isCopied = copiedIndex === idx;
return (
<div
key={idx}
className="group flex-shrink-0 flex items-center gap-2 px-3 py-2 rounded-md bg-background hover:bg-muted/80 transition-all duration-200 border border-muted hover:border-primary/50 shadow-sm hover:shadow-md"
>
{/* Favicon */}
{faviconUrl ? (
<img
src={faviconUrl}
alt=""
className="w-4 h-4 flex-shrink-0 rounded-sm"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Globe className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
)}
{/* Title and URL */}
<div className="flex-1 min-w-0 max-w-[200px] md:max-w-[300px]">
<div className="text-xs font-medium text-foreground truncate" title={source.title || source.url}>
{source.title || `Fonte ${idx + 1}`}
</div>
<div className="text-[10px] text-muted-foreground truncate" title={source.url}>
{source.url.replace(/^https?:\/\//, '').replace(/^www\./, '')}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{/* Copy Button */}
<button
onClick={() => copyToClipboard(source.url, idx)}
className="p-1 rounded hover:bg-muted transition-colors"
title="Copiar URL"
aria-label="Copiar URL"
>
{isCopied ? (
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
) : (
<Copy className="w-3 h-3 text-muted-foreground" />
)}
</button>
{/* External Link Button */}
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded hover:bg-muted transition-colors"
title="Abrir em nova aba"
aria-label="Abrir em nova aba"
>
<ExternalLink className="w-3 h-3 text-muted-foreground" />
</a>
</div>
</div>
);
})}
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { Loader2, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
interface StreamingIndicatorProps {
isStreaming: boolean;
className?: string;
}
export function StreamingIndicator({ isStreaming, className }: StreamingIndicatorProps) {
const [dots, setDots] = useState('');
useEffect(() => {
if (!isStreaming) {
setDots('');
return;
}
const interval = setInterval(() => {
setDots(prev => {
if (prev === '...') return '';
return prev + '.';
});
}, 500);
return () => clearInterval(interval);
}, [isStreaming]);
if (!isStreaming) return null;
return (
<div className={cn("flex items-center gap-2 text-xs text-muted-foreground px-6 py-2", className)}>
<div className="relative">
<Sparkles className="w-3 h-3 text-primary animate-pulse" />
<div className="absolute inset-0 bg-primary/20 rounded-full animate-ping" />
</div>
<span>Streaming{dots}</span>
</div>
);
}
+17 -5
View File
@@ -3,8 +3,10 @@ import { cn } from '@/lib/utils';
export type ThinkingStep =
| 'analyzing'
| 'planning'
| 'searching'
| 'reading'
| 'validating'
| 'formulating'
| 'complete';
@@ -13,6 +15,7 @@ interface ThinkingIndicatorProps {
searchQuery?: string;
sourcesRead?: number;
totalSources?: number;
planSize?: number;
onComplete?: () => void;
}
@@ -21,6 +24,7 @@ export function ThinkingIndicator({
searchQuery,
sourcesRead = 0,
totalSources = 0,
planSize = 0,
}: ThinkingIndicatorProps) {
const steps: Array<{
key: ThinkingStep;
@@ -29,21 +33,29 @@ export function ThinkingIndicator({
}> = [
{
key: 'analyzing',
label: 'Analisando intenção...',
label: 'Analisando...',
icon: <Brain className="w-3 h-3" />,
},
{
key: 'planning',
label: 'Planejando pesquisa...',
icon: <Brain className="w-3 h-3" />,
},
{
key: 'searching',
label: searchQuery ? `Pesquisando: "${searchQuery}"` : 'Pesquisando na web...',
label: planSize > 0 ? `Pesquisando (${planSize} tópicos)...` : 'Pesquisando na web...',
icon: <Search className="w-3 h-3" />,
},
{
key: 'reading',
label: totalSources > 0
? `Lendo ${sourcesRead}/${totalSources} fontes...`
: 'Lendo conteúdo...',
label: 'Lendo conteúdo...',
icon: <BookOpen className="w-3 h-3" />,
},
{
key: 'validating',
label: 'Validando dados...',
icon: <CheckCircle2 className="w-3 h-3" />,
},
{
key: 'formulating',
label: 'Formulando resposta...',
+254 -25
View File
@@ -1,3 +1,6 @@
'use client';
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -7,7 +10,12 @@ import {
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Download } from "lucide-react"
import { Progress } from "@/components/ui/progress"
import { Download, Loader2, AlertCircle, CheckCircle2, Play } from "lucide-react"
import { useOllamaDownload } from "@/hooks/use-ollama-download"
import { useToast } from "@/hooks/use-toast"
import { useOperatingSystem } from "@/hooks/use-operating-system"
import type { OS } from "@/lib/download-utils"
interface InstallModalProps {
open: boolean;
@@ -15,6 +23,162 @@ interface InstallModalProps {
}
export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
const { os: detectedOS } = useOperatingSystem();
const [activeTab, setActiveTab] = useState<OS>('mac');
const downloadState = useOllamaDownload();
const { toast } = useToast();
// Detectar OS e definir aba ativa quando modal abrir
useEffect(() => {
if (open && detectedOS) {
setActiveTab(detectedOS);
}
}, [open, detectedOS]);
// Resetar estado quando o modal abrir
useEffect(() => {
if (open) {
downloadState.reset();
// Verificar se já existe download para a tab ativa
downloadState.checkExistingDownload(activeTab);
} else {
// Parar verificação automática quando modal fechar
downloadState.stopAutoCheck();
}
}, [open, activeTab]);
// Verificar download existente ao mudar de tab
useEffect(() => {
if (open) {
downloadState.checkExistingDownload(activeTab);
}
}, [activeTab, open]);
// Escutar evento de Ollama instalado
useEffect(() => {
if (!open) return;
const handleOllamaInstalled = () => {
// Ollama foi encontrado, verificar novamente para atualizar o estado
onCheckAgain();
};
window.addEventListener('ollama-installed', handleOllamaInstalled);
return () => {
window.removeEventListener('ollama-installed', handleOllamaInstalled);
};
}, [open, onCheckAgain]);
// Mostrar toast quando houver erro
useEffect(() => {
if (downloadState.downloadError) {
toast({
title: 'Erro no download',
description: downloadState.downloadError,
variant: 'destructive',
});
}
}, [downloadState.downloadError, toast]);
const handleDownload = async (os: OS) => {
await downloadState.handleDownload(os);
};
const handleInstall = async () => {
try {
await downloadState.handleInstall();
toast({
title: 'Instalador iniciado',
description: 'O instalador foi aberto. Siga as instruções na tela.',
});
} catch (error) {
toast({
title: 'Erro ao executar instalador',
description: error instanceof Error ? error.message : 'Erro desconhecido',
variant: 'destructive',
});
}
};
const DownloadButton = ({ os, label }: { os: OS; label: string }) => {
const isActive = activeTab === os;
const isDownloading = downloadState.isDownloading && isActive;
const hasDownloaded = downloadState.filePath !== null && isActive;
if (hasDownloaded && !isDownloading) {
return (
<Button
variant="default"
className="w-full"
onClick={handleInstall}
>
<Play className="mr-2 h-4 w-4" />
Instalar Ollama
</Button>
);
}
return (
<Button
variant="outline"
className="w-full"
onClick={() => handleDownload(os)}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{downloadState.downloadStatus || 'Baixando...'}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{label}
</>
)}
</Button>
);
};
const DownloadProgress = ({ os }: { os: OS }) => {
const isActive = activeTab === os;
const isDownloading = downloadState.isDownloading && isActive;
const hasDownloaded = downloadState.filePath !== null && isActive;
if (!isDownloading && !hasDownloaded) {
return null;
}
return (
<div className="space-y-2">
{isDownloading && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{downloadState.downloadStatus || 'Baixando...'}
</span>
<span className="font-medium">{downloadState.downloadProgress}%</span>
</div>
<Progress value={downloadState.downloadProgress} className="h-2" />
</>
)}
{hasDownloaded && !isDownloading && (
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
<div className="text-sm flex-1">
<p className="font-medium text-green-900 dark:text-green-100">
Download concluído
</p>
<p className="text-green-700 dark:text-green-300">
Clique em "Instalar Ollama" para iniciar a instalação.
</p>
</div>
</div>
)}
</div>
);
};
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-[600px]">
@@ -22,29 +186,41 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
<DialogTitle>Ollama não encontrado</DialogTitle>
<DialogDescription>
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
Siga os passos abaixo para o seu sistema operacional.
Baixe e instale o Ollama usando os passos abaixo.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="mac" className="w-full">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as OS)} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="mac">macOS</TabsTrigger>
<TabsTrigger value="linux">Linux</TabsTrigger>
<TabsTrigger value="windows">Windows</TabsTrigger>
</TabsList>
<TabsContent value="mac" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">1. Download e Instalação</h3>
<Button variant="outline" className="w-full" asChild>
<a href="https://ollama.com/download/Ollama-darwin.zip" target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Baixar para macOS
</a>
</Button>
<DownloadButton os="mac" label="Baixar para macOS" />
<DownloadProgress os="mac" />
</div>
<div className="text-sm text-muted-foreground">
Após instalar, abra o terminal e digite <code>ollama serve</code> se necessário.
{downloadState.downloadError && activeTab === 'mac' && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
<div className="text-sm">
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-red-700 dark:text-red-300">
{downloadState.downloadError}
</p>
</div>
</div>
)}
<div className="space-y-2 text-sm text-muted-foreground">
<p>Após instalar, o Ollama será iniciado automaticamente. Se necessário, abra o terminal e digite <code className="bg-muted px-1 rounded">ollama serve</code>.</p>
<p className="font-medium text-foreground">
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
</p>
</div>
</TabsContent>
<TabsContent value="linux" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">Instalação via Terminal</h3>
@@ -52,35 +228,88 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
<code className="text-sm break-all">curl -fsSL https://ollama.com/install.sh | sh</code>
</div>
</div>
<div className="space-y-2">
<div className="space-y-2">
<h3 className="font-medium">Ou baixar e instalar via interface</h3>
<DownloadButton os="linux" label="Baixar Script de Instalação" />
<DownloadProgress os="linux" />
</div>
{downloadState.downloadError && activeTab === 'linux' && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
<div className="text-sm">
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-red-700 dark:text-red-300">
{downloadState.downloadError}
</p>
</div>
</div>
)}
<div className="space-y-2">
<h3 className="font-medium">Comando Manual</h3>
<div className="text-sm text-muted-foreground">
<div className="text-sm text-muted-foreground">
Ou consulte <a href="https://ollama.com/download/linux" className="underline text-primary" target="_blank" rel="noopener noreferrer">ollama.com/download/linux</a> para instruções manuais.
</div>
</div>
<p className="text-sm font-medium text-foreground">
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
</p>
</div>
</TabsContent>
<TabsContent value="windows" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">1. Download do Instalador</h3>
<Button variant="outline" className="w-full" asChild>
<a href="https://ollama.com/download/OllamaSetup.exe" target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Baixar para Windows
</a>
</Button>
<DownloadButton os="windows" label="Baixar para Windows" />
<DownloadProgress os="windows" />
</div>
<div className="text-sm text-muted-foreground">
Execute o instalador baixado e siga as instruções na tela.
{downloadState.downloadError && activeTab === 'windows' && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
<div className="text-sm">
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-red-700 dark:text-red-300">
{downloadState.downloadError}
</p>
</div>
</div>
)}
<div className="space-y-2 text-sm text-muted-foreground">
<p>Após clicar em "Instalar Ollama", o instalador será aberto. Siga as instruções na tela para completar a instalação.</p>
<p className="font-medium text-foreground">
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
</p>
</div>
</TabsContent>
</Tabs>
{/* Status de verificação automática */}
{downloadState.isChecking && (
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md mt-4">
<Loader2 className="h-4 w-4 mt-0.5 text-blue-600 dark:text-blue-400 animate-spin" />
<div className="text-sm flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100">
Verificando instalação do Ollama...
</p>
<p className="text-blue-700 dark:text-blue-300">
{downloadState.checkStatus || 'Aguardando instalação ser concluída...'}
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
Esta verificação continuará em segundo plano até encontrar o Ollama.
</p>
</div>
</div>
)}
<div className="flex justify-end gap-2 mt-4">
<Button onClick={onCheckAgain}>
Verificar Novamente
<Button
onClick={() => {
// Chamar verificação completa (instalação + execução)
onCheckAgain();
}}
>
Verificar Novamente
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,67 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Heart } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function WelcomeNotification() {
const [show, setShow] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Verificar se já foi mostrado
const welcomeShown = localStorage.getItem('ollahub_welcome_shown');
if (!welcomeShown) {
setShow(true);
// Marcar como mostrado
localStorage.setItem('ollahub_welcome_shown', 'true');
// Auto-fechar após 5 segundos
const timer = setTimeout(() => {
setShow(false);
}, 5000);
return () => clearTimeout(timer);
}
}, []);
if (!mounted || !show) {
return null;
}
return (
<div
className={cn(
"fixed top-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md",
"animate-in slide-in-from-top-5 fade-in duration-500"
)}
>
<div className="bg-primary text-primary-foreground rounded-lg shadow-lg p-4 mx-4">
<div className="flex items-start gap-3">
<Heart className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-sm mb-1">
Obrigado por usar o OllaHub! 🎉
</h3>
<p className="text-xs text-primary-foreground/90">
Estamos felizes em tê-lo conosco. Comece a explorar e aproveite sua experiência!
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-primary-foreground hover:bg-primary-foreground/20 flex-shrink-0"
onClick={() => setShow(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Check, Cpu, Monitor, Zap, Laptop } from "lucide-react";
import { GpuInfo } from "@/lib/recommendation";
import { useSettingsStore } from "@/store/settings-store";
import { cn } from "@/lib/utils";
interface GpuSelectorProps {
gpus: GpuInfo[];
}
export function GpuSelector({ gpus }: GpuSelectorProps) {
const selectedGpu = useSettingsStore((state) => state.selectedGpu);
const setSelectedGpu = useSettingsStore((state) => state.setSelectedGpu);
// Se não há GPUs ou apenas uma, não mostrar seletor
if (gpus.length === 0) {
return null;
}
// Se há apenas uma GPU, selecionar automaticamente
if (gpus.length === 1 && !selectedGpu) {
setSelectedGpu(gpus[0].id);
}
// Selecionar primeira GPU por padrão se nenhuma estiver selecionada
useEffect(() => {
if (!selectedGpu && gpus.length > 0) {
setSelectedGpu(gpus[0].id);
}
}, [selectedGpu, gpus, setSelectedGpu]);
const getVendorIcon = (vendor: string | null) => {
const iconClass = "h-5 w-5";
switch (vendor?.toUpperCase()) {
case 'NVIDIA':
return <Zap className={cn(iconClass, "text-green-500")} />;
case 'AMD':
return <Zap className={cn(iconClass, "text-red-500")} />;
case 'INTEL':
return <Cpu className={cn(iconClass, "text-blue-500")} />;
case 'APPLE':
return <Monitor className={cn(iconClass, "text-gray-500")} />;
default:
return <Monitor className={cn(iconClass, "text-muted-foreground")} />;
}
};
const formatMemory = (memoryMb: number | null) => {
if (!memoryMb) return null;
if (memoryMb >= 1024) {
return `${(memoryMb / 1024).toFixed(1)} GB`;
}
return `${memoryMb} MB`;
};
return (
<Card className="w-full max-w-2xl mx-auto bg-muted/30 animate-in fade-in slide-in-from-bottom-4">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Monitor className="text-primary" />
Placas de Vídeo Detectadas
</CardTitle>
<CardDescription>
Selecione qual GPU você deseja usar para processamento de IA
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{gpus.map((gpu) => {
const isSelected = selectedGpu === gpu.id;
const memoryStr = formatMemory(gpu.memory_mb);
return (
<button
key={gpu.id}
onClick={() => setSelectedGpu(gpu.id)}
className={cn(
"relative p-4 rounded-lg border-2 transition-all text-left",
"hover:border-primary/50 hover:bg-accent/50",
isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border bg-background"
)}
>
{isSelected && (
<div className="absolute top-2 right-2">
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-start gap-2">
<div className="mt-0.5">
{getVendorIcon(gpu.vendor)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate">{gpu.name}</h3>
{gpu.vendor && (
<Badge variant="outline" className="mt-1 text-xs">
{gpu.vendor}
</Badge>
)}
</div>
</div>
{memoryStr && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Cpu className="h-3 w-3" />
<span>{memoryStr} VRAM</span>
</div>
)}
{!memoryStr && gpu.vendor && (
<div className="text-xs text-muted-foreground">
{gpu.vendor === 'Intel' ? 'GPU Integrada' : 'GPU Dedicada'}
</div>
)}
</div>
</button>
);
})}
</div>
{gpus.length === 1 && (
<p className="text-sm text-muted-foreground mt-4 text-center">
Apenas uma GPU foi detectada e será usada automaticamente.
</p>
)}
</CardContent>
</Card>
);
}
+72 -13
View File
@@ -14,12 +14,22 @@ interface ModelSelectorProps {
onComplete: () => void;
}
interface DownloadInfo {
status: string;
percent?: number;
downloaded?: string;
total?: string;
speed?: string;
raw: string;
}
export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState(recommendation.modelId);
const [isInstalled, setIsInstalled] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState("");
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
@@ -29,25 +39,60 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
useEffect(() => {
const unlisten = listen<string>('download-progress', (event) => {
const line = event.payload;
setDownloadStatus(line);
const payload = event.payload;
// Simple heuristic parsing for progress
// Ollama output example: "pulling manifest", "downloading 10%", "verifying sha256 digest"
if (line.includes('%')) {
const match = line.match(/(\d+)%/);
if (match) {
setDownloadProgress(parseInt(match[1], 10));
// Tentar parsear como JSON estruturado
let info: DownloadInfo | null = null;
try {
info = JSON.parse(payload) as DownloadInfo;
} catch {
// Fallback: tratar como string raw
info = {
status: "downloading",
raw: payload,
};
}
if (info) {
setDownloadInfo(info);
// Atualizar status text
const statusText = info.status === "pulling" ? "Baixando modelo..." :
info.status === "verifying" ? "Verificando..." :
info.status === "writing" ? "Salvando..." :
info.status === "success" ? "Concluído!" :
"Baixando...";
setDownloadStatus(statusText);
// Atualizar progresso
if (info.percent !== undefined) {
setDownloadProgress(info.percent);
} else {
// Fallback: estimar progresso baseado no status
if (info.status === "pulling") {
setDownloadProgress(prev => prev < 10 ? 10 : prev);
} else if (info.status === "verifying") {
setDownloadProgress(90);
} else if (info.status === "writing") {
setDownloadProgress(95);
}
}
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
if (info.percent === 100 || info.status === "success") {
setTimeout(() => {
setIsInstalled(true);
setIsDownloading(false);
checkInstallation(selectedModelId);
}, 500);
}
} else if (line.includes('success')) {
setDownloadProgress(100);
}
});
return () => {
unlisten.then(f => f());
};
}, []);
}, [selectedModelId]);
const checkInstallation = async (modelId: string) => {
try {
@@ -61,13 +106,15 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
const handleDownload = async () => {
setIsDownloading(true);
setDownloadProgress(0);
setDownloadStatus("Iniciando download...");
try {
await invoke('pull_model', { name: selectedModelId });
setIsInstalled(true);
setIsDownloading(false);
// O status será atualizado pelo listener quando o download completar
// Não precisamos setar aqui pois o listener já faz isso
} catch (error) {
console.error("Download failed", error);
setDownloadStatus("Falha no download. Tente novamente.");
setDownloadProgress(0);
setIsDownloading(false);
}
};
@@ -128,6 +175,18 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
<span>{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadInfo.downloaded && downloadInfo.total
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
: downloadInfo.downloaded || ""}
</span>
{downloadInfo.speed && (
<span>{downloadInfo.speed}</span>
)}
</div>
)}
</div>
)}
</CardContent>