chore: expurgo de blobs excedentes e realinhamento de estado
release.yml (push) Invalid workflow file
release.yml (push) Invalid workflow file
This commit is contained in:
1 parent
593efd42e0
commit
a7cbd1a3b4
114 files changed
+20716
-4258
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 “{searchQuery}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Sim" ou "Allow" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user