chore: expurgo de blobs excedentes e realinhamento de estado
release.yml (push) Invalid workflow file

This commit is contained in:
Evandro Fonseca Junior committed 2026-02-22 14:06:01 -03:00
1 parent 593efd42e0
commit a7cbd1a3b4
114 files changed
+20716 -4258

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { Progress } from '@/components/ui/progress';
interface LogLine {
line: string;
type: 'stdout' | 'stderr';
progress?: number;
}
interface InstallationTerminalProps {
onComplete?: () => void;
onError?: (error: string) => void;
}
export function InstallationTerminal({ onComplete, onError }: InstallationTerminalProps) {
const [logs, setLogs] = useState<LogLine[]>([]);
const [progress, setProgress] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const lastProgressRef = useRef<number>(0);
// Auto-scroll para última linha
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
// Escutar eventos de log
useEffect(() => {
const unlistenPromise = listen<LogLine>('install-log', (event) => {
const logLine = event.payload;
setLogs(prev => [...prev, logLine]);
// Atualizar progresso se disponível
if (logLine.progress !== undefined) {
setProgress(logLine.progress);
lastProgressRef.current = logLine.progress;
} else {
// Tentar extrair progresso da linha de texto
const extractedProgress = extractProgressFromText(logLine.line);
if (extractedProgress !== null) {
setProgress(extractedProgress);
lastProgressRef.current = extractedProgress;
}
}
// Verificar se instalação foi concluída
const lineLower = logLine.line.toLowerCase();
if (lineLower.includes('success') || lineLower.includes('completed') || lineLower.includes('finished')) {
setProgress(100);
if (onComplete) {
setTimeout(() => onComplete(), 500);
}
}
// Verificar se houve erro
if (logLine.type === 'stderr' && (lineLower.includes('error') || lineLower.includes('failed'))) {
if (onError) {
onError(logLine.line);
}
}
});
return () => {
unlistenPromise.then(unlisten => unlisten());
};
}, [onComplete, onError]);
return (
<div className="space-y-2 sm:space-y-3">
<div className="bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden max-h-[50vh] flex flex-col">
<div className="px-2 sm:px-3 py-1.5 sm:py-2 bg-zinc-900 border-b border-zinc-800 flex items-center justify-between gap-2 flex-shrink-0">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-red-500"></div>
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-yellow-500"></div>
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-green-500"></div>
<span className="text-[10px] sm:text-xs text-zinc-400 ml-1 sm:ml-2">Terminal</span>
</div>
{progress > 0 && (
<span className="text-[10px] sm:text-xs text-zinc-400 font-mono shrink-0">{progress}%</span>
)}
</div>
<div
ref={scrollRef}
className="h-48 sm:h-64 md:h-80 flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 font-mono text-[10px] sm:text-xs"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#3f3f46 #18181b',
scrollBehavior: 'smooth'
}}
>
{logs.length === 0 ? (
<div className="text-zinc-500 text-[10px] sm:text-xs">Aguardando logs...</div>
) : (
logs.map((log, index) => (
<div
key={index}
className={`
${log.type === 'stderr' ? 'text-red-400' : 'text-green-400'}
whitespace-pre-wrap
break-words
`}
>
{log.line}
</div>
))
)}
</div>
</div>
{progress > 0 && (
<div className="space-y-1">
<Progress value={progress} className="h-1.5 sm:h-2" />
<div className="text-[10px] sm:text-xs text-muted-foreground text-center">
{progress < 100 ? `Instalando... ${progress}%` : 'Instalação concluída'}
</div>
</div>
)}
</div>
);
}
/// Extrai progresso percentual de uma linha de texto
function extractProgressFromText(line: string): number | null {
// Procurar por padrão "Progress: n%" ou "n%"
const percentMatch = line.match(/(\d+)%/);
if (percentMatch) {
const num = parseInt(percentMatch[1], 10);
if (num >= 0 && num <= 100) {
return num;
}
}
// Mapear keywords para progresso aproximado
const lineLower = line.toLowerCase();
if (lineLower.includes('downloading')) {
return 25;
} else if (lineLower.includes('installing')) {
return 50;
} else if (lineLower.includes('shim') || lineLower.includes('creating')) {
return 75;
} else if (lineLower.includes('finished') || lineLower.includes('completed') || lineLower.includes('success')) {
return 100;
}
return null;
}
+425
View File
@@ -0,0 +1,425 @@
'use client';
import { useEffect, useMemo, useState, useCallback, startTransition } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useDownloadContext } from '@/contexts/download-context';
import { Search, Download, CheckCircle2, AlertCircle, Loader2, Sparkles, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ModelInfo {
id: string;
name: string;
size: string;
minRam: number;
description?: string;
}
// Lista expandida de modelos populares
const POPULAR_MODELS: ModelInfo[] = [
{ id: 'llama3.2:1b', name: 'Llama 3.2 1B', size: '1.3GB', minRam: 4, description: 'Leve e rápido, ideal para iniciantes' },
{ id: 'llama3.2:3b', name: 'Llama 3.2 3B', size: '2.0GB', minRam: 8, description: 'Equilíbrio perfeito entre velocidade e qualidade' },
{ id: 'llama3.1:8b', name: 'Llama 3.1 8B', size: '4.7GB', minRam: 16, description: 'Padrão da indústria, alta capacidade' },
{ id: 'mistral:latest', name: 'Mistral 7B', size: '4.1GB', minRam: 16, description: 'Excelente para raciocínio e código' },
{ id: 'gemma2:9b', name: 'Gemma 2 9B', size: '5.4GB', minRam: 12, description: 'Modelo avançado do Google' },
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', size: '4.5GB', minRam: 16, description: 'Multilíngue e versátil' },
{ id: 'phi3:mini', name: 'Phi-3 Mini', size: '2.3GB', minRam: 8, description: 'Compacto e eficiente da Microsoft' },
{ id: 'deepseek-coder:6.7b', name: 'DeepSeek Coder 6.7B', size: '4.2GB', minRam: 16, description: 'Especializado em programação' },
];
interface ModelDownloadStepProps {
onNext: () => void;
}
export function ModelDownloadStep({ onNext }: ModelDownloadStepProps) {
const { state, checkSetupState } = useSetupWizard();
const { state: downloadState, startDownload } = useDownloadContext();
const [searchQuery, setSearchQuery] = useState('');
const [customModelInput, setCustomModelInput] = useState('');
const [activeModelTag, setActiveModelTag] = useState<string | null>(null);
const [installedModels, setInstalledModels] = useState<Set<string>>(new Set());
const [customModelInstalled, setCustomModelInstalled] = useState(false);
// Verificar modelos instalados ao montar
useEffect(() => {
const checkAllModels = async () => {
const installed = new Set<string>();
// Verificar todos os modelos em paralelo
const checks = POPULAR_MODELS.map(async (model) => {
try {
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: model.id });
if (isInstalled) {
installed.add(model.id);
}
} catch (error) {
console.error(`Erro ao verificar ${model.id}:`, error);
}
});
await Promise.all(checks);
startTransition(() => {
setInstalledModels(installed);
});
};
checkAllModels();
}, []);
// Verificar modelo customizado quando o usuário digita
useEffect(() => {
const checkCustomModel = async () => {
const modelTag = customModelInput.trim();
if (!modelTag) {
setCustomModelInstalled(false);
return;
}
try {
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: modelTag });
setCustomModelInstalled(isInstalled);
} catch (error) {
console.error(`Erro ao verificar modelo customizado ${modelTag}:`, error);
setCustomModelInstalled(false);
}
};
// Debounce: aguardar 500ms após o usuário parar de digitar
const timer = setTimeout(checkCustomModel, 500);
return () => clearTimeout(timer);
}, [customModelInput]);
// Filtrar modelos baseado na pesquisa
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return POPULAR_MODELS;
const query = searchQuery.toLowerCase();
return POPULAR_MODELS.filter(
model =>
model.name.toLowerCase().includes(query) ||
model.id.toLowerCase().includes(query) ||
model.description?.toLowerCase().includes(query)
);
}, [searchQuery]);
// Progresso do download ativo
const activeProgress = useMemo(() => {
if (!downloadState.isDownloading || downloadState.currentModel !== activeModelTag) {
return null;
}
return {
percent: downloadState.progress.percent,
downloaded: downloadState.progress.downloadedBytes,
total: downloadState.progress.totalBytes,
downloadedFormatted: downloadState.progress.downloadedFormatted,
totalFormatted: downloadState.progress.totalFormatted,
status: downloadState.progress.status,
speed: downloadState.progress.speed,
};
}, [downloadState, activeModelTag]);
// Atualizar lista de instalados e limpar activeModelTag quando download completar
useEffect(() => {
if (downloadState.isSuccess && activeModelTag) {
const modelTag = activeModelTag;
// Adicionar modelo à lista de instalados usando startTransition para evitar cascading renders
startTransition(() => {
setInstalledModels(prev => new Set([...prev, modelTag]));
// Verificar também o modelo customizado se for o caso
if (modelTag === customModelInput.trim()) {
setCustomModelInstalled(true);
}
});
// Limpar activeModelTag após um pequeno delay para garantir que o estado foi atualizado
const timer = setTimeout(() => {
setActiveModelTag(null);
}, 100);
return () => clearTimeout(timer);
}
}, [downloadState.isSuccess, activeModelTag, customModelInput]);
// Verificar estado do setup quando download completar
useEffect(() => {
if (downloadState.isSuccess || (!downloadState.isDownloading && downloadState.progress.percent === 100 && downloadState.progress.status === 'success')) {
checkSetupState().catch(console.error);
}
}, [downloadState.isSuccess, downloadState.isDownloading, downloadState.progress.percent, downloadState.progress.status, checkSetupState]);
const handleModelDownload = useCallback(async (modelTag: string) => {
if (downloadState.isDownloading) {
console.warn('Download já em andamento');
return;
}
setActiveModelTag(modelTag);
try {
await startDownload(modelTag);
} catch (error) {
console.error('Erro ao iniciar download:', error);
setActiveModelTag(null);
}
}, [downloadState.isDownloading, startDownload]);
const handleCustomDownload = useCallback(async () => {
const modelTag = customModelInput.trim();
if (!modelTag) return;
await handleModelDownload(modelTag);
setCustomModelInput('');
}, [customModelInput, handleModelDownload]);
if (state.modelDownloaded) {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
<span>Modelo Instalado</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Modelo {state.modelName || 'padrão'} pronto para uso.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-100">Download concluído com sucesso!</p>
<p className="text-xs text-emerald-300 mt-1">Avançando para o próximo passo...</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-6xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl max-h-[calc(100vh-20rem)] flex flex-col">
<CardHeader className="p-6 sm:p-8 flex-shrink-0">
<CardTitle className="text-xl font-bold text-white flex items-center gap-2 mb-1">
<Download className="w-6 h-6 shrink-0" />
<span>Download do Modelo</span>
</CardTitle>
<CardDescription className="text-zinc-400 text-xs">
Escolha um modelo e/ou informe uma tag custom para baixar.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-4 sm:pb-6 pt-0 space-y-4 flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col md:flex-row gap-3 mb-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Pesquisar modelos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-10 text-sm"
/>
</div>
<div className="flex gap-2 md:w-1/3">
<Input
type="text"
placeholder="Tag custom (ex: deepseek-coder:6.7b)"
value={customModelInput}
onChange={(e) => setCustomModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCustomDownload();
}
}}
disabled={downloadState.isDownloading}
className="flex-1 h-10 text-sm"
/>
<Button
onClick={handleCustomDownload}
disabled={!customModelInput.trim() || downloadState.isDownloading || customModelInstalled}
variant={customModelInstalled ? 'outline' : 'default'}
className={cn(
"gap-2 h-10 px-4 text-sm",
customModelInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
)}
>
{downloadState.isDownloading && activeModelTag === customModelInput.trim() ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Baixando
</>
) : customModelInstalled ? (
<>
<CheckCircle2 className="w-4 h-4" />
Já Instalado
</>
) : (
<>
<Download className="w-4 h-4" />
Baixar
</>
)}
</Button>
</div>
</div>
{/* Lista de Modelos Recomendados */}
<div className="space-y-4">
<div className="flex items-center gap-2 mt-2 mb-2">
<Sparkles className="w-4 h-4 text-primary" />
<h3 className="text-xs font-semibold">Modelos Recomendados</h3>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 min-h-[300px] max-h-[450px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-zinc-800 custom-scrollbar">
{filteredModels.length === 0 ? (
<div className="col-span-1 sm:col-span-2 text-center py-12 text-sm text-muted-foreground">
Nenhum modelo encontrado para &ldquo;{searchQuery}&rdquo;
</div>
) : (
filteredModels.map((model) => {
const isActive = activeModelTag === model.id;
const isDownloading = downloadState.isDownloading && isActive;
const progress = isActive ? activeProgress : null;
const isInstalled = installedModels.has(model.id);
return (
<Card
key={model.id}
className={cn(
'relative border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-900 transition-all',
isDownloading && 'border-emerald-500/50',
isInstalled && !isDownloading && 'border-emerald-500/30 bg-emerald-500/5'
)}
>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-bold text-sm truncate">{model.name}</h4>
{isInstalled && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-[10px] px-1.5 py-0">
<CheckCircle2 className="w-3 h-3 mr-1" />
Instalado
</Badge>
)}
</div>
<p className="hidden md:block text-[11px] text-muted-foreground mt-1 line-clamp-1">
{model.description || ''}
</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px] sm:text-xs">
{model.size}
</Badge>
</div>
<div className="flex items-center justify-between text-[10px] sm:text-xs text-muted-foreground gap-2">
<span className="truncate">Mín. {model.minRam}GB RAM</span>
<code className="text-[9px] sm:text-[10px] bg-muted px-1 sm:px-1.5 py-0.5 rounded shrink-0 truncate max-w-[120px] sm:max-w-none">
{model.id}
</code>
</div>
{isDownloading && progress && (
<div className="space-y-1 pt-2 border-t border-zinc-800/50">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{progress.status || 'Baixando...'}
</span>
{progress.speed && (
<span className="font-mono text-[10px] text-muted-foreground">
{progress.speed}
</span>
)}
</div>
<Progress
value={progress.total > 0 && progress.percent !== null ? progress.percent : undefined}
className="h-1"
/>
{progress.total > 0 && (
<div className="flex justify-between text-[10px] text-muted-foreground/70">
<span>
{progress.downloadedFormatted} / {progress.totalFormatted}
</span>
<span>{progress.percent ?? 0}%</span>
</div>
)}
</div>
)}
<Button
onClick={() => handleModelDownload(model.id)}
disabled={downloadState.isDownloading || isInstalled}
variant={isInstalled ? 'outline' : isDownloading ? 'outline' : 'default'}
className={cn(
"w-full gap-2 h-8 text-xs",
isInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20"
)}
>
{isDownloading ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span className="hidden sm:inline">Baixando...</span>
</>
) : isInstalled ? (
<>
<CheckCircle2 className="w-3 h-3" />
<span className="hidden sm:inline">Já Instalado</span>
</>
) : (
<>
<Download className="w-3 h-3" />
<span className="hidden sm:inline">Baixar</span>
</>
)}
</Button>
</div>
</Card>
);
})
)}
</div>
</div>
{/* Mensagens de Erro */}
{state.lastError && (
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900/50">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1 break-words">{state.lastError}</p>
</div>
</div>
)}
{/* SLIM FOOTER NAVIGATION */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 mt-4 border-t border-zinc-800 bg-zinc-950/50 backdrop-blur-sm flex-shrink-0">
<div className="text-xs text-zinc-500">
<span>Selecione um modelo ou pule para configurar depois.</span>
</div>
<div className="flex gap-3 w-full sm:w-auto">
<Button
variant="ghost"
onClick={onNext}
className="text-zinc-400 hover:text-white hover:bg-zinc-800 flex-1 sm:flex-initial"
disabled={downloadState.isDownloading}
>
Pular etapa
</Button>
<Button
onClick={onNext}
className="bg-white text-black hover:bg-zinc-200 px-8 flex-1 sm:flex-initial"
disabled={downloadState.isDownloading}
>
Continuar <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</CardContent>
<CardFooter className="p-6 sm:p-8 pt-0 border-t-0 flex-shrink-0 hidden">
{/* Footer vazio - mantido para compatibilidade, mas conteúdo movido para CardContent */}
</CardFooter>
</Card>
);
}
+638
View File
@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useOllamaDownload } from '@/hooks/use-ollama-download';
import { useOllamaCheck } from '@/hooks/use-ollama-check';
import { useOperatingSystem } from '@/hooks/use-operating-system';
import { InstallationTerminal } from './installation-terminal';
import { Loader2, CheckCircle2, AlertCircle, Download, RefreshCw, Shield, Clock, WifiOff, ArrowRight } from 'lucide-react';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function formatTime(seconds: number): string {
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
type InstallErrorType = 'ERR_NO_PRIVILEGE' | 'ERR_DOWNLOAD_FAIL' | 'ERR_TIMEOUT' | 'ERR_INSTALL_FAILED';
function parseInstallError(error: string): InstallErrorType {
const errorLower = error.toLowerCase();
// Verificar privilégios/permissões
if (
errorLower.includes('privilege') ||
errorLower.includes('permission') ||
errorLower.includes('elevated') ||
errorLower.includes('uac') ||
errorLower.includes('administrator') ||
errorLower.includes('access denied') ||
errorLower.includes('acesso negado')
) {
return 'ERR_NO_PRIVILEGE';
}
// Verificar erros de download/rede
if (
errorLower.includes('download') ||
errorLower.includes('network') ||
errorLower.includes('connection') ||
errorLower.includes('failed to download') ||
errorLower.includes('falha ao baixar') ||
errorLower.includes('conexão') ||
errorLower.includes('timeout') && (errorLower.includes('download') || errorLower.includes('conexão'))
) {
return 'ERR_DOWNLOAD_FAIL';
}
// Verificar timeout
if (
errorLower.includes('timeout') ||
errorLower.includes('90 segundos') ||
errorLower.includes('timed out') ||
errorLower.includes('tempo esgotado')
) {
return 'ERR_TIMEOUT';
}
return 'ERR_INSTALL_FAILED';
}
interface ErrorDisplayProps {
errorType: InstallErrorType;
errorMessage: string;
onRetry: () => void;
isRetrying: boolean;
}
function ErrorDisplay({ errorType, errorMessage, onRetry, isRetrying }: ErrorDisplayProps) {
switch (errorType) {
case 'ERR_NO_PRIVILEGE':
return (
<Card className="border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-amber-900 dark:text-amber-100">
Permissões Administrativas Necessárias
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-amber-700 dark:text-amber-300 mt-1 sm:mt-2">
O Windows solicitou permissões de administrador para instalar o Ollama.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-amber-200 dark:border-amber-800">
<p className="text-xs sm:text-sm font-medium text-foreground mb-2">O que fazer:</p>
<ol className="list-decimal list-inside space-y-1 text-xs sm:text-sm text-muted-foreground">
<li>Verifique se apareceu um prompt do Windows pedindo permissões</li>
<li>Clique em &quot;Sim&quot; ou &quot;Allow&quot; no prompt de UAC (User Account Control)</li>
<li>Aguarde alguns segundos para a instalação continuar</li>
<li>Se o prompt não apareceu, tente executar o OllaHub como administrador</li>
</ol>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
<p className="text-xs text-muted-foreground text-center">
{errorMessage}
</p>
</CardContent>
</Card>
);
case 'ERR_DOWNLOAD_FAIL':
return (
<Card className="border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<WifiOff className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-red-900 dark:text-red-100">
Erro ao Baixar Instalador
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 sm:mt-2">
Não foi possível baixar o instalador do Ollama. Verifique sua conexão com a internet.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{errorMessage}
</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Tentando Novamente...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Tentar Novamente
</>
)}
</Button>
</CardContent>
</Card>
);
case 'ERR_TIMEOUT':
return (
<Card className="border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600 dark:text-orange-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-orange-900 dark:text-orange-100">
Instalação Demorando Mais do Que o Esperado
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-orange-700 dark:text-orange-300 mt-1 sm:mt-2">
A instalação pode ainda estar em andamento em segundo plano.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-orange-200 dark:border-orange-800">
<p className="text-xs sm:text-sm text-muted-foreground mb-2 break-words">
O processo de instalação pode levar alguns minutos. Se o Ollama estiver sendo instalado,
você pode verificar o status manualmente.
</p>
<p className="text-[10px] sm:text-xs text-muted-foreground break-words">
{errorMessage}
</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
</CardContent>
</Card>
);
default:
return (
<div className="flex items-start gap-2 p-3 sm:p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 space-y-2 sm:space-y-3 min-w-0">
<div>
<p className="text-sm sm:text-base font-medium text-red-900 dark:text-red-100">Erro na instalação</p>
<p className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 break-words">{errorMessage}</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
size="sm"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
</div>
</div>
);
}
}
type InstallStepState = 'IDLE' | 'INSTALLING' | 'VERIFYING' | 'SUCCESS';
interface OllamaInstallStepProps {
onNext: () => void;
}
export function OllamaInstallStep({ onNext }: OllamaInstallStepProps) {
const { state, installOllama, checkChocolatey, installChocolatey, installOllamaViaChoco, checkSetupState } = useSetupWizard();
const { os: detectedOS } = useOperatingSystem();
const downloadState = useOllamaDownload();
const ollamaCheck = useOllamaCheck();
const [stepState, setStepState] = useState<InstallStepState>('IDLE');
const [useChocolatey, setUseChocolatey] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const installationInProgressRef = useRef(false);
const hasCheckedInitialRef = useRef(false);
// Função de polling ativo para aguardar serviço ficar disponível
const waitForService = async (maxAttempts = 15): Promise<boolean> => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (isIntact) {
return true;
}
} catch (error) {
console.warn(`Tentativa ${attempt}/${maxAttempts} falhou:`, error);
}
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
throw new Error('Ollama não ficou disponível após 30 segundos');
};
// Verificação inicial no mount: se Ollama já estiver instalado, ir direto para SUCCESS
useEffect(() => {
if (hasCheckedInitialRef.current) return;
const checkInitial = async () => {
if (stepState === 'IDLE') {
try {
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (isIntact) {
// Se a integridade está OK, atualizar estado do wizard e ir para SUCCESS
await checkSetupState();
setStepState('SUCCESS');
}
} catch {
// Silenciosamente ignorar - usuário ainda não instalou
}
}
hasCheckedInitialRef.current = true;
};
checkInitial();
}, [stepState, checkSetupState]);
// Sincronizar estado do polling com wizard state e atualizar FSM
useEffect(() => {
if (ollamaCheck.status === 'running' && !state.ollamaRunning) {
// Polling detectou que Ollama está rodando, atualizar wizard state
checkSetupState().catch(console.error);
}
// Atualizar FSM baseado no status
if (stepState === 'VERIFYING' && ollamaCheck.status === 'running' && state.ollamaInstalled && state.ollamaRunning) {
setStepState('SUCCESS');
ollamaCheck.stopPolling();
}
}, [ollamaCheck.status, state.ollamaRunning, state.ollamaInstalled, checkSetupState, stepState, ollamaCheck]);
const handleManualRetry = async () => {
setIsRetrying(true);
try {
await checkSetupState();
// Se já estiver instalado, ir para SUCCESS
if (state.ollamaInstalled && state.ollamaRunning) {
setStepState('SUCCESS');
} else {
// Iniciar verificação manual
setStepState('VERIFYING');
ollamaCheck.startPolling();
}
} catch (error) {
console.error('Erro ao verificar novamente:', error);
} finally {
setIsRetrying(false);
}
};
const handleCheckExisting = async () => {
setStepState('VERIFYING');
ollamaCheck.startPolling();
await checkSetupState();
};
const handleInstall = async () => {
if (installationInProgressRef.current) {
return; // Evitar múltiplas instalações simultâneas
}
installationInProgressRef.current = true;
setStepState('INSTALLING');
setShowTerminal(true);
try {
// No Windows, verificar se Chocolatey está disponível
if (detectedOS === 'windows') {
const hasChoco = await checkChocolatey();
if (hasChoco) {
setUseChocolatey(true);
// Instalar Ollama via Chocolatey
await installOllamaViaChoco();
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
installationInProgressRef.current = false;
return;
} else {
// Tentar instalar Chocolatey primeiro
try {
await installChocolatey();
setUseChocolatey(true);
await installOllamaViaChoco();
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
installationInProgressRef.current = false;
return;
} catch (error) {
console.warn('Falha ao usar Chocolatey, usando instalação direta:', error);
setUseChocolatey(false);
setShowTerminal(false);
}
}
}
// Fallback: instalação direta
if (!downloadState.filePath && detectedOS) {
await downloadState.handleDownload(detectedOS);
}
if (downloadState.filePath) {
await installOllama(downloadState.filePath);
} else {
await installOllama();
}
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
} catch (error) {
console.error('Erro ao aguardar serviço:', error);
setStepState('IDLE');
} finally {
installationInProgressRef.current = false;
}
};
// Estado SUCCESS: mostrar mensagem de sucesso e botão Continuar
if (stepState === 'SUCCESS' || (state.ollamaInstalled && state.ollamaRunning)) {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
<span>Instalação Concluída</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Ollama está instalado e rodando com sucesso.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-100">Ollama Service is Running</p>
<p className="text-xs text-emerald-300 mt-1">Pronto para continuar com o download de modelos.</p>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button onClick={onNext} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
Continuar <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</CardFooter>
</Card>
);
}
// Estado IDLE: mostrar card informativo
if (stepState === 'IDLE') {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<Download className="w-6 h-6 shrink-0" />
<span>Ollama Engine</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
O OllaHub requer o motor Ollama para executar IAs localmente.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<p className="text-sm text-zinc-400 leading-relaxed">
O Ollama é uma ferramenta open-source que permite executar modelos de linguagem grandes (LLMs) diretamente no seu computador, sem necessidade de conexão com a internet.
</p>
</CardContent>
<CardFooter className="flex flex-col sm:flex-row justify-end gap-4 p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button onClick={handleCheckExisting} variant="outline" className="w-full sm:w-auto h-12 border-zinc-700 text-zinc-300 hover:bg-zinc-800">
Já tenho o Ollama instalado
</Button>
<Button onClick={handleInstall} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
<Download className="w-4 h-4 mr-2" />
Instalar Ollama
</Button>
</CardFooter>
</Card>
);
}
const errorType = state.lastError ? parseInstallError(state.lastError) : null;
const showRetryButton = state.lastError || state.progressStatus === 'error';
const showManualRetry = showRetryButton && state.lastError;
const hasTerminal = showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey);
return (
<Card className={`w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl ${hasTerminal ? 'max-h-[calc(100vh-16rem)] flex flex-col' : ''}`}>
<CardHeader className={`p-6 sm:p-8 ${hasTerminal ? 'flex-shrink-0' : ''}`}>
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
{stepState === 'INSTALLING' && <Loader2 className="w-6 h-6 animate-spin" />}
{stepState === 'VERIFYING' && <Loader2 className="w-6 h-6 animate-spin" />}
{(state.lastError || state.progressStatus === 'error') && <AlertCircle className="w-6 h-6 text-red-500" />}
<span>
{stepState === 'INSTALLING' ? 'Instalando Ollama...' :
stepState === 'VERIFYING' ? 'Verificando Instalação...' :
'Instalação em Progresso'}
</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Acompanhe o progresso da configuração do ambiente.
</CardDescription>
</CardHeader>
<CardContent className={`px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6 ${hasTerminal ? 'flex-1 min-h-0 overflow-y-auto' : ''}`}>
{/* Terminal integrado para instalação via Chocolatey */}
{(showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey)) && (
<InstallationTerminal
onComplete={() => {
setShowTerminal(false);
}}
onError={(error) => {
console.error('Erro na instalação:', error);
}}
/>
)}
{/* Progresso para instalação direta (fallback) */}
{state.progressMessage && !showTerminal && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{state.progressMessage}</span>
{ollamaCheck.isChecking && (
<span className="text-xs text-muted-foreground">
Verificando... {formatTime(ollamaCheck.elapsedTime)}
</span>
)}
</div>
{state.progressStatus === 'installing_ollama' && (
<Progress value={undefined} className="h-2" />
)}
</div>
)}
{/* Feedback do polling em background (estado VERIFYING) */}
{stepState === 'VERIFYING' && (
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span className="break-words">
Instalação concluída. Aguardando inicialização do serviço...
</span>
</div>
)}
{/* Display de erro granular */}
{state.lastError && errorType && (
<ErrorDisplay
errorType={errorType}
errorMessage={state.lastError}
onRetry={handleManualRetry}
isRetrying={isRetrying}
/>
)}
{/* Botão de retry manual quando não há erro específico */}
{showRetryButton && !showManualRetry && (
<Button
onClick={handleManualRetry}
disabled={isRetrying || stepState === 'INSTALLING'}
variant="outline"
className="w-full"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
)}
{downloadState.isDownloading && (
<div className="space-y-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-2">
{downloadState.status === 'verifying' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
{downloadState.status === 'verifying'
? 'Verificando integridade...'
: 'Baixando...'}
</span>
<span className="font-mono">{downloadState.speed || '0 MB/s'}</span>
</div>
<Progress
value={downloadState.downloadProgress}
className="h-2 w-full transition-all duration-300"
/>
<div className="flex justify-between text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
<span>
{formatBytes(downloadState.downloadedBytes)} / {formatBytes(downloadState.totalBytes)}
</span>
<span>{downloadState.downloadProgress.toFixed(1)}%</span>
</div>
{downloadState.eta > 0 && downloadState.status === 'downloading' && (
<div className="text-xs text-muted-foreground text-center">
Tempo restante: {formatTime(downloadState.eta)}
</div>
)}
</div>
)}
{/* Estado INSTALLING: mostrar progresso */}
{stepState === 'INSTALLING' && (
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span className="break-words">Instalando Ollama... Isso pode levar alguns minutos.</span>
</div>
)}
</CardContent>
{/* Footer opcional se necessário */}
</Card>
);
}
+173
View File
@@ -0,0 +1,173 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useSettingsStore } from '@/store/settings-store';
import { useRouter } from 'next/navigation';
import { CheckCircle2, ArrowRight, AlertCircle, Sparkles, Loader2 } from 'lucide-react';
import { startTransition } from 'react';
import { invoke } from '@tauri-apps/api/core';
export function SetupCompleteStep() {
const { state, goToStep } = useSetupWizard();
const { setSetupCompleted } = useSettingsStore();
const router = useRouter();
const [integrityError, setIntegrityError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(true);
useEffect(() => {
const verifyAndComplete = async () => {
try {
setIsVerifying(true);
setIntegrityError(null);
// Verificar integridade ANTES de marcar como completo
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (!isOllamaIntact) {
// Redirecionar silenciosamente para o passo de instalação do Ollama
setIsVerifying(false);
goToStep(2);
return;
}
// Só definir flag e redirecionar se Ollama estiver íntegro
setSetupCompleted(true);
setIsVerifying(false);
const timer = setTimeout(() => {
startTransition(() => {
router.replace('/chat');
});
}, 2000);
return () => clearTimeout(timer);
} catch {
// Em caso de erro na verificação, redirecionar para instalação
setIsVerifying(false);
goToStep(2);
}
};
verifyAndComplete();
}, [setSetupCompleted, router, goToStep]);
const handleGoToChat = async () => {
try {
// Verificar integridade antes de redirecionar manualmente também
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (!isOllamaIntact) {
// Redirecionar para o passo de instalação do Ollama
goToStep(2);
return;
}
setSetupCompleted(true);
startTransition(() => {
router.replace('/chat');
});
} catch {
// Em caso de erro na verificação, redirecionar para instalação
goToStep(2);
}
};
const handleGoToInstallation = () => {
goToStep(2);
};
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
{isVerifying ? (
<Loader2 className="w-6 h-6 animate-spin shrink-0" />
) : integrityError ? (
<AlertCircle className="w-6 h-6 text-red-500 shrink-0" />
) : (
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
)}
<span>
{isVerifying ? 'Verificando Integridade' :
integrityError ? 'Erro na Configuração' :
'Configuração Completa'}
</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
{isVerifying ? 'Validando instalação do Ollama...' :
integrityError ? 'Encontramos um problema.' :
'Tudo pronto para começar a usar o OllaHub.'}
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
{isVerifying ? (
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
<div className="w-16 h-16 rounded-full bg-zinc-900/50 flex items-center justify-center border border-zinc-800">
<CheckCircle2 className="w-8 h-8 text-zinc-700 animate-pulse" />
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Estamos verificando se o serviço Ollama está respondendo corretamente antes de liberar o acesso.
</p>
</div>
) : integrityError ? (
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
<div className="w-16 h-16 rounded-full bg-red-950/20 flex items-center justify-center border border-red-900/50">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<div className="text-center space-y-2 max-w-md">
<h3 className="font-semibold text-red-400">Falha na Verificação</h3>
<p className="text-sm text-zinc-400">
{integrityError}
</p>
</div>
</div>
) : (
<>
<div className="flex items-start gap-4 p-6 bg-emerald-950/10 rounded-xl border border-emerald-900/20">
<div className="p-3 bg-emerald-500/10 rounded-lg shrink-0">
<Sparkles className="w-6 h-6 text-emerald-500" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-emerald-400 text-lg">Ambiente Configurado</h3>
<p className="text-emerald-200/70 text-sm leading-relaxed">
Seu assistente de IA local está pronto. Você pode começar a conversar, gerar códigos ou analisar documentos agora mesmo.
</p>
</div>
</div>
<div className="space-y-3 p-4 bg-zinc-900/50 rounded-xl border border-zinc-800/50">
<p className="text-sm font-medium text-zinc-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
Resumo da Instalação:
</p>
<ul className="text-sm text-zinc-500 space-y-2 pl-6 list-disc marker:text-zinc-700">
<li>Hardware compatível detectado</li>
{state.ollamaInstalled && <li>Ollama Engine instalado e ativo</li>}
{state.modelDownloaded && (
<li>Modelo de IA <strong>{state.modelName || 'padrão'}</strong> baixado</li>
)}
</ul>
</div>
</>
)}
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button
onClick={integrityError ? handleGoToInstallation : handleGoToChat}
className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium"
disabled={isVerifying}
>
{isVerifying ? 'Verificando...' : integrityError ? 'Instalar Ollama' : 'Ir para o Chat'}
{!isVerifying && !integrityError && <ArrowRight className="w-4 h-4 ml-2" />}
</Button>
</CardFooter>
</Card>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { useEffect } from 'react';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { SystemCheckStep } from './system-check-step';
import { OllamaInstallStep } from './ollama-install-step';
import { ModelDownloadStep } from './model-download-step';
import { SetupCompleteStep } from './setup-complete-step';
import { CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const steps = [
{ id: 1, title: 'Verificação do Sistema', description: 'Analisando hardware' },
{ id: 2, title: 'Instalação do Ollama', description: 'Configurando engine' },
{ id: 3, title: 'Download do Modelo', description: 'Baixando modelo padrão' },
{ id: 4, title: 'Conclusão', description: 'Tudo pronto!' },
];
export function SetupWizard() {
const { state, checkSetupState, goToStep, nextStep } = useSetupWizard();
useEffect(() => {
checkSetupState();
}, [checkSetupState]);
const handleNext = () => {
nextStep();
};
const renderStep = () => {
switch (state.currentStep) {
case 1:
return <SystemCheckStep onNext={handleNext} />;
case 2:
return <OllamaInstallStep onNext={handleNext} />;
case 3:
return <ModelDownloadStep onNext={handleNext} />;
case 4:
return <SetupCompleteStep />;
default:
return <SystemCheckStep onNext={handleNext} />;
}
};
const containerWidth = state.currentStep === 3 ? 'max-w-6xl' : 'max-w-2xl';
return (
<div className="h-screen max-h-screen w-full bg-background flex flex-col items-center overflow-y-auto p-4 sm:p-6 lg:p-8">
<div className={`w-full ${containerWidth} flex-shrink-0 transition-all duration-500 ease-in-out space-y-4 sm:space-y-6 lg:space-y-8 py-4`}>
<div className="text-center space-y-2 flex-shrink-0">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
<p className="text-sm sm:text-base text-muted-foreground">
Vamos preparar o ambiente ideal para você começar a usar o OllaHub.
</p>
</div>
<div className={`w-full ${containerWidth} mx-auto mb-20 sm:mb-24 md:mb-28 px-2 sm:px-0 transition-all duration-500 ease-in-out flex-shrink-0 relative z-0`}>
<div className="flex items-center justify-between relative">
{steps.map((step, index) => {
const isCompleted = state.completedSteps.includes(step.id);
const isCurrent = state.currentStep === step.id;
const isFuture = !isCompleted && !isCurrent;
const canNavigate = step.id <= state.currentStep;
const maxReachableStep = Math.max(state.currentStep, ...state.completedSteps);
const isClickable = canNavigate || step.id === maxReachableStep + 1;
const isLineActive = state.currentStep > index + 1 || state.completedSteps.includes(step.id);
return (
<div key={step.id} className={cn("contents")}>
{/* STEP CIRCLE & LABEL */}
<div className="flex flex-col items-center relative z-0 group pb-0 md:pb-0">
<div
onClick={() => isClickable && step.id !== state.currentStep && goToStep(step.id)}
className={cn(
"w-10 h-10 sm:w-12 sm:h-12 rounded-full flex items-center justify-center border-2 transition-all duration-300 relative z-0",
// Estética dos Círculos
isCompleted && "bg-emerald-500 border-emerald-500 text-white",
isCurrent && "bg-zinc-900 border-white text-white shadow-[0_0_10px_rgba(255,255,255,0.5)]",
isFuture && "bg-zinc-900 border-zinc-800 text-zinc-500",
// Interatividade
isClickable ? "cursor-pointer" : "cursor-default"
)}
>
{isCompleted ? (
<CheckCircle2 className="w-5 h-5 sm:w-6 sm:h-6" />
) : (
<span className="text-sm sm:text-base font-bold">{step.id}</span>
)}
</div>
{/* Rótulos (Labels) - Desktop Only */}
<div className="hidden md:flex absolute top-14 flex-col items-center w-32 text-center z-10">
<span className={cn(
"text-sm font-medium transition-colors duration-300",
isCurrent ? "text-white" : "text-muted-foreground"
)}>
{step.title}
</span>
</div>
</div>
{/* LINHA CONECTORA (Entre passos) */}
{index < steps.length - 1 && (
<div className={cn(
"flex-1 h-[2px] mx-2 sm:mx-4 rounded transition-colors duration-500 relative z-0",
isLineActive ? "bg-emerald-500" : "bg-zinc-800"
)} />
)}
</div>
);
})}
</div>
</div>
<div className="w-full flex-shrink-0 relative z-10">
{renderStep()}
</div>
</div>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { motion } from 'framer-motion';
import { CheckCircle2, Cpu, HardDrive, Zap, AlertCircle, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useHardware } from '@/hooks/use-hardware';
import { cn } from '@/lib/utils';
// Componente Skeleton simples para loading
function Skeleton({ className }: { className?: string }) {
return (
<div className={cn('animate-pulse bg-zinc-900 rounded-xl', className)} />
);
}
interface SystemCheckStepProps {
onNext: () => void;
}
export function SystemCheckStep({ onNext }: SystemCheckStepProps) {
const { specs, loading } = useHardware();
const status = loading ? 'checking' : (specs ? 'success' : 'error');
const ramGB = specs ? (specs.total_memory / (1024 * 1024 * 1024)).toFixed(1) : 0;
const isCompatible = specs ? specs.total_memory >= 4 * 1024 * 1024 * 1024 : false; // Mínimo 4GB
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-0">
<div className="flex-1 min-w-0">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<Zap className="w-6 h-6 text-emerald-500 shrink-0" />
<span className="truncate">Verificação de Hardware</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Analisando compatibilidade para execução de IA local.
</CardDescription>
</div>
{status === 'success' && isCompatible && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 px-2 py-1 text-xs shrink-0">
Compatível
</Badge>
)}
</div>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
{/* Estado de Carregamento ou Grid de Specs */}
{status === 'checking' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : specs ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
{/* Card RAM */}
<div className="p-4 sm:p-6 rounded-xl bg-zinc-900/50 border border-zinc-800 flex items-start gap-4 hover:border-zinc-700 transition-colors">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400 shrink-0">
<HardDrive className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-400">Memória RAM</p>
<h3 className="text-2xl font-bold text-white mt-1">
{ramGB} <span className="text-sm font-normal text-zinc-500">GB</span>
</h3>
<p className="text-xs text-zinc-500 mt-1">Mínimo recomendado: 16GB</p>
</div>
</div>
{/* Card CPU */}
<div className="p-4 sm:p-6 rounded-xl bg-zinc-900/50 border border-zinc-800 flex items-start gap-4 hover:border-zinc-700 transition-colors">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400 shrink-0">
<Cpu className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-400">Processador</p>
<h3 className="text-2xl font-bold text-white mt-1">
{specs.cpu_count} <span className="text-sm font-normal text-zinc-500">Núcleos</span>
</h3>
<p className="text-xs text-zinc-500 mt-1">Alta capacidade de threads</p>
</div>
</div>
</motion.div>
) : null}
{/* Feedback Visual */}
<div className="pt-6">
{status === 'checking' && (
<p className="text-sm text-zinc-500 flex items-center gap-2 animate-pulse">
<span className="w-2 h-2 rounded-full bg-zinc-500 block" />
Escaneando componentes...
</p>
)}
{status === 'success' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-start sm:items-center gap-3 text-sm text-emerald-400 bg-emerald-950/30 p-4 rounded-lg border border-emerald-900/50"
>
<CheckCircle2 className="w-5 h-5 shrink-0" />
<span>Hardware detectado com sucesso. Pronto para instalar o Ollama.</span>
</motion.div>
)}
{status === 'error' && (
<div className="flex items-start sm:items-center gap-3 text-sm text-red-400 bg-red-950/30 p-4 rounded-lg border border-red-900/50">
<AlertCircle className="w-5 h-5 shrink-0" />
<span>Não foi possível detectar o hardware. Verifique as permissões.</span>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button
onClick={onNext}
disabled={status !== 'success'}
className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium"
>
{status === 'checking' ? 'Verificando...' : 'Continuar'}
{status === 'success' && <ArrowRight className="w-4 h-4 ml-2" />}
</Button>
</CardFooter>
</Card>
);
}