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
@@ -18,7 +18,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
<<<<<<< HEAD
|
||||
import { Message, ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||
=======
|
||||
import { Message } from '@/hooks/use-chat';
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
import type { DeepResearchState } from '@/hooks/use-deep-research';
|
||||
import type { ScrapedContent } from '@/services/webSearch';
|
||||
|
||||
@@ -39,6 +43,18 @@ export interface DebugData {
|
||||
contextual: string[];
|
||||
};
|
||||
sources: ScrapedContent[];
|
||||
<<<<<<< HEAD
|
||||
sitesResearched?: Array<{
|
||||
url: string;
|
||||
title: string;
|
||||
status: string;
|
||||
source: string;
|
||||
duration?: number;
|
||||
contentLength: number;
|
||||
scrapedAt: number;
|
||||
}>;
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
logs: Array<{
|
||||
stage: string;
|
||||
timestamp: number;
|
||||
@@ -53,6 +69,35 @@ export interface DebugData {
|
||||
title: string;
|
||||
content: string;
|
||||
}>;
|
||||
<<<<<<< HEAD
|
||||
activeQueries?: Array<{
|
||||
query: string;
|
||||
source: string;
|
||||
round?: number;
|
||||
startedAt: number;
|
||||
}>;
|
||||
activeUrls?: Array<{
|
||||
url: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
source?: string;
|
||||
startedAt: number;
|
||||
duration?: number;
|
||||
}>;
|
||||
};
|
||||
deepResearchState?: DeepResearchState;
|
||||
thinkingSteps?: ThinkingMessageMetadata[];
|
||||
reasoning?: {
|
||||
steps?: string[];
|
||||
intermediateResults?: any[];
|
||||
};
|
||||
finalResponse: string;
|
||||
rawResponse: string;
|
||||
tokenUsage?: {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
=======
|
||||
};
|
||||
deepResearchState?: DeepResearchState;
|
||||
finalResponse: string;
|
||||
@@ -60,6 +105,7 @@ export interface DebugData {
|
||||
tokenUsage?: {
|
||||
input: number;
|
||||
output: number;
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||
import { SearchQueriesMonitor } from './search-queries-monitor';
|
||||
import { ScrapingUrlsMonitor } from './scraping-urls-monitor';
|
||||
|
||||
interface MessageStepThinkingProps {
|
||||
metadata: ThinkingMessageMetadata;
|
||||
@@ -105,13 +107,31 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
||||
|
||||
{/* Conteúdo Expandido */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
|
||||
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-4 text-sm text-muted-foreground animate-in fade-in">
|
||||
{metadata.details && (
|
||||
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
||||
)}
|
||||
|
||||
{/* Monitoramento em Tempo Real - Queries */}
|
||||
{metadata.activeQueries && metadata.activeQueries.length > 0 && (
|
||||
<div className="pt-2 border-t border-muted/30">
|
||||
<SearchQueriesMonitor queries={metadata.activeQueries} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monitoramento em Tempo Real - URLs */}
|
||||
{metadata.activeUrls && metadata.activeUrls.length > 0 && (
|
||||
<div className="pt-2 border-t border-muted/30">
|
||||
<ScrapingUrlsMonitor urls={metadata.activeUrls} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadata.sources && metadata.sources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<div className="pt-2 border-t border-muted/30">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Fontes Encontradas ({metadata.sources.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{metadata.sources.map((source, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
@@ -121,6 +141,7 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown, Search, DownloadCloud, Cpu, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useModelSelection } from '@/hooks/use-model-selection';
|
||||
import { useDownloadContext } from '@/contexts/download-context';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function ModelSelector() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const { myModels, availableModels, selectedModel, setSelectedModel, installedVariantsByFamily, refreshModels } = useModelSelection();
|
||||
const { startDownload, state: downloadState } = useDownloadContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Track selected variant for each model in the catalog
|
||||
const [selectedVariants, setSelectedVariants] = React.useState<Record<string, string>>({});
|
||||
|
||||
const handleDownload = async (e: React.MouseEvent, modelName: string) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await startDownload(modelName);
|
||||
toast({
|
||||
title: "Download Iniciado",
|
||||
description: `Baixando ${modelName}...`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: "Falha ao iniciar download.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSmartDownload = async () => {
|
||||
const term = searchQuery.trim();
|
||||
if (!term) return;
|
||||
|
||||
try {
|
||||
await startDownload(term);
|
||||
setOpen(false);
|
||||
setSearchQuery("");
|
||||
toast({
|
||||
title: "Download Iniciado",
|
||||
description: `Baixando ${term} da biblioteca Ollama...`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: "Falha ao iniciar download.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (downloadState?.isSuccess) {
|
||||
refreshModels();
|
||||
}
|
||||
}, [downloadState?.isSuccess, refreshModels]);
|
||||
|
||||
const filteredMyModels = React.useMemo(() => {
|
||||
if (!searchQuery) return myModels;
|
||||
return myModels.filter(m =>
|
||||
m.model.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [myModels, searchQuery]);
|
||||
|
||||
const filteredAvailableModels = React.useMemo(() => {
|
||||
if (!searchQuery) return availableModels;
|
||||
return availableModels.filter(m =>
|
||||
m.model.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [availableModels, searchQuery]);
|
||||
|
||||
// Helper to check if a model is currently selected (partial match for variants)
|
||||
const isSelected = (modelName: string) => {
|
||||
if (!selectedModel) return false;
|
||||
return selectedModel.startsWith(modelName) || selectedModel === modelName;
|
||||
};
|
||||
|
||||
// Helper to extract tag/parameters from model name
|
||||
const getVariantDisplay = (name: string) => {
|
||||
if (name.includes(':')) {
|
||||
return name.split(':')[1];
|
||||
}
|
||||
return 'latest';
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[260px] justify-between"
|
||||
>
|
||||
{selectedModel ? (
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{selectedModel}</span>
|
||||
</span>
|
||||
) : (
|
||||
"Selecione um modelo..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<div className="flex flex-col">
|
||||
{/* Search Input */}
|
||||
<div className="flex items-center border-b border-border/50 px-3 bg-muted/30">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Buscar modelos..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && searchQuery && filteredAvailableModels.length === 0) {
|
||||
handleSmartDownload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="installed" className="w-full">
|
||||
<TabsList className="w-full rounded-none border-b bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="installed"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
|
||||
>
|
||||
Instalados
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="available"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
|
||||
>
|
||||
Catálogo
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="installed" className="m-0 max-h-[400px] overflow-y-auto p-2">
|
||||
{filteredMyModels.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{searchQuery ? (
|
||||
<span>Nenhum modelo instalado encontrado.</span>
|
||||
) : (
|
||||
<span>Nenhum modelo instalado.</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{filteredMyModels.map((modelDef) => {
|
||||
const family = modelDef.model;
|
||||
const installedTags = installedVariantsByFamily[family] || [];
|
||||
const isFamilySelected = selectedModel ? (selectedModel === family || selectedModel.startsWith(family + ":")) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={family}
|
||||
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between w-full mb-2">
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
|
||||
<span className="font-bold text-base truncate flex items-center gap-2">
|
||||
{modelDef.model}
|
||||
{isFamilySelected && (
|
||||
<Check className="h-3 w-3 text-primary animate-in fade-in zoom-in" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
|
||||
{modelDef.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{installedTags.map((fullTag) => {
|
||||
const tag = getVariantDisplay(fullTag);
|
||||
const isExactSelected = selectedModel === fullTag;
|
||||
// Find variant def for size info if available
|
||||
const variantDef = modelDef.variants.find(v => v.name === fullTag);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={fullTag}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
|
||||
isExactSelected
|
||||
? "bg-foreground text-background hover:bg-foreground/90"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
|
||||
)}
|
||||
title={variantDef ? `Tamanho: ${variantDef.size}` : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedModel(fullTag);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="available" className="m-0 max-h-[400px] overflow-y-auto p-2">
|
||||
{filteredAvailableModels.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm">
|
||||
{searchQuery ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-muted-foreground">Modelo não encontrado no catálogo.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-2 px-4 border-dashed border-zinc-500/50 hover:border-primary hover:bg-primary/10"
|
||||
onClick={handleSmartDownload}
|
||||
>
|
||||
<DownloadCloud className="mr-2 h-4 w-4" />
|
||||
<span>Baixar <span className="font-bold ml-1">{searchQuery}</span></span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Nenhum modelo encontrado.</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{filteredAvailableModels.map((modelDef) => {
|
||||
// Determine active variant for this item
|
||||
const activeVariantName = selectedVariants[modelDef.model] || modelDef.variants[0]?.name;
|
||||
const activeVariant = modelDef.variants.find(v => v.name === activeVariantName) || modelDef.variants[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modelDef.model}
|
||||
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between w-full mb-2">
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
|
||||
<span className="font-bold text-base truncate">{modelDef.model}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
|
||||
{modelDef.description}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => handleDownload(e, activeVariant.name)}
|
||||
disabled={downloadState.isDownloading && downloadState.currentModel === activeVariant.name}
|
||||
title={`Baixar ${activeVariant.name}`}
|
||||
>
|
||||
{downloadState.isDownloading && downloadState.currentModel === activeVariant.name ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Variants Pills */}
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{modelDef.variants.map((variant) => {
|
||||
const isVariantSelected = activeVariant.name === variant.name;
|
||||
return (
|
||||
<Badge
|
||||
key={variant.name}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
|
||||
isVariantSelected
|
||||
? "bg-foreground text-background hover:bg-foreground/90"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
|
||||
)}
|
||||
title={`Tamanho em disco: ${variant.size}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedVariants(prev => ({
|
||||
...prev,
|
||||
[modelDef.model]: variant.name
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{getVariantDisplay(variant.name)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, CheckCircle2, XCircle, ExternalLink, Globe } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ActiveUrl } from '@/hooks/use-web-search-events';
|
||||
|
||||
interface ScrapingUrlsMonitorProps {
|
||||
urls: ActiveUrl[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScrapingUrlsMonitor({ urls, className }: ScrapingUrlsMonitorProps) {
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
const getFaviconUrl = (url: string): string => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: ActiveUrl['status']) => {
|
||||
switch (status) {
|
||||
case 'started':
|
||||
return <Loader2 className="w-3 h-3 animate-spin text-primary" />;
|
||||
case 'completed':
|
||||
case 'cached':
|
||||
return <CheckCircle2 className="w-3 h-3 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-3 h-3 text-red-500" />;
|
||||
default:
|
||||
return <Globe className="w-3 h-3 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceBadge = (source?: string) => {
|
||||
if (!source) return null;
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
static: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
|
||||
nodriver: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
|
||||
playwright: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
|
||||
cached: 'bg-gray-500/10 text-gray-600 border-gray-500/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn('text-xs', colors[source] || 'bg-muted')}>
|
||||
{source}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Sites Sendo Acessados ({urls.length})
|
||||
</div>
|
||||
{urls.map((url, idx) => (
|
||||
<div
|
||||
key={`${url.url}-${idx}`}
|
||||
className="flex items-start gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getStatusIcon(url.status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<img
|
||||
src={getFaviconUrl(url.url)}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded mt-0.5 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={url.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
|
||||
>
|
||||
{url.title || url.url}
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
{getSourceBadge(url.source)}
|
||||
{url.duration && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{url.duration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{url.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, CheckCircle2, Globe, Search } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ActiveQuery } from '@/hooks/use-web-search-events';
|
||||
|
||||
interface SearchQueriesMonitorProps {
|
||||
queries: ActiveQuery[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchQueriesMonitor({ queries, className }: SearchQueriesMonitorProps) {
|
||||
if (queries.length === 0) return null;
|
||||
|
||||
const getSourceColor = (source: string) => {
|
||||
switch (source) {
|
||||
case 'brave':
|
||||
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
|
||||
case 'searxng':
|
||||
return 'bg-green-500/10 text-green-600 border-green-500/20';
|
||||
case 'duckduckgo':
|
||||
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground border-muted';
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceLabel = (source: string) => {
|
||||
switch (source) {
|
||||
case 'brave':
|
||||
return 'Brave';
|
||||
case 'searxng':
|
||||
return 'SearXNG';
|
||||
case 'duckduckgo':
|
||||
return 'DuckDuckGo';
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Consultas Ativas ({queries.length})
|
||||
</div>
|
||||
{queries.map((query, idx) => (
|
||||
<div
|
||||
key={`${query.query}-${query.source}-${idx}`}
|
||||
className="flex items-center gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs truncate">{query.query}</span>
|
||||
<Badge variant="outline" className={cn('text-xs', getSourceColor(query.source))}>
|
||||
{getSourceLabel(query.source)}
|
||||
</Badge>
|
||||
{query.round && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Round {query.round}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useDownloadContext } from '@/contexts/download-context';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function GlobalDownloadStatusBar() {
|
||||
const { state, cancelDownload } = useDownloadContext();
|
||||
|
||||
if (!state.isDownloading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { progress, currentModel, isCancelling } = state;
|
||||
const modelDisplayName = currentModel || 'Modelo';
|
||||
const percent = progress.percent ?? 0;
|
||||
const hasSizeInfo = progress.totalBytes > 0 && progress.downloadedBytes > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 z-50',
|
||||
'bg-background/95 backdrop-blur-sm border-t border-border',
|
||||
'shadow-lg',
|
||||
'animate-in slide-in-from-bottom duration-300',
|
||||
'transition-all'
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{isCancelling ? (
|
||||
'Cancelando download...'
|
||||
) : (
|
||||
`Baixando Modelo: ${modelDisplayName} (${percent}%)`
|
||||
)}
|
||||
</span>
|
||||
{progress.speed && !isCancelling && (
|
||||
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
|
||||
{progress.speed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={percent}
|
||||
className="h-1.5 mb-1.5"
|
||||
/>
|
||||
|
||||
{hasSizeInfo && (
|
||||
<div className="flex justify-between items-center text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
|
||||
<span>
|
||||
{progress.downloadedFormatted} / {progress.totalFormatted}
|
||||
</span>
|
||||
{progress.status && (
|
||||
<span className="normal-case">{progress.status}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={cancelDownload}
|
||||
disabled={isCancelling}
|
||||
className="shrink-0 h-8 w-8"
|
||||
aria-label="Cancelar download"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,10 +87,7 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
await downloadState.handleInstall();
|
||||
toast({
|
||||
title: 'Instalador iniciado',
|
||||
description: 'O instalador foi aberto. Siga as instruções na tela.',
|
||||
});
|
||||
// Toast será mostrado automaticamente via eventos de progresso
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Erro ao executar instalador',
|
||||
@@ -144,8 +141,10 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
const isActive = activeTab === os;
|
||||
const isDownloading = downloadState.isDownloading && isActive;
|
||||
const hasDownloaded = downloadState.filePath !== null && isActive;
|
||||
const isInstalling = downloadState.isInstalling && isActive;
|
||||
const installStatus = downloadState.installStatus;
|
||||
|
||||
if (!isDownloading && !hasDownloaded) {
|
||||
if (!isDownloading && !hasDownloaded && !isInstalling) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -162,7 +161,18 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
<Progress value={downloadState.downloadProgress} className="h-2" />
|
||||
</>
|
||||
)}
|
||||
{hasDownloaded && !isDownloading && (
|
||||
{isInstalling && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{installStatus || 'Instalando...'}
|
||||
</span>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
</div>
|
||||
<Progress value={undefined} className="h-2" />
|
||||
</>
|
||||
)}
|
||||
{hasDownloaded && !isDownloading && !isInstalling && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||
<div className="text-sm flex-1">
|
||||
@@ -170,7 +180,20 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
Download concluído
|
||||
</p>
|
||||
<p className="text-green-700 dark:text-green-300">
|
||||
Clique em "Instalar Ollama" para iniciar a instalação.
|
||||
Clique em "Instalar Ollama" para iniciar a instalação automática.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{downloadState.isInstalled && !isInstalling && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||
<div className="text-sm flex-1">
|
||||
<p className="font-medium text-green-900 dark:text-green-100">
|
||||
Instalação concluída!
|
||||
</p>
|
||||
<p className="text-green-700 dark:text-green-300">
|
||||
{installStatus || 'Ollama foi instalado com sucesso.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +206,9 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ollama não encontrado</DialogTitle>
|
||||
<DialogTitle data-tauri-drag-region className="cursor-move select-none">
|
||||
Ollama não encontrado
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
||||
Baixe e instale o Ollama usando os passos abaixo.
|
||||
|
||||
@@ -67,3 +67,11 @@ export function WelcomeNotification() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -269,3 +269,11 @@ export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ExternalLink, Copy, Check, Terminal } from 'lucide-react';
|
||||
import { ProblemSolution } from '@/lib/docker-problems';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DockerCommandsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
problem: ProblemSolution | null;
|
||||
}
|
||||
|
||||
export function DockerCommandsModal({ open, onOpenChange, problem }: DockerCommandsModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
if (!problem) return null;
|
||||
|
||||
const handleCopyCommand = async (command: string, index: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopiedIndex(index);
|
||||
toast({
|
||||
title: 'Comando copiado',
|
||||
description: 'Comando copiado para a área de transferência',
|
||||
});
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Erro ao copiar',
|
||||
description: 'Não foi possível copiar o comando',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenLink = async (url: string) => {
|
||||
try {
|
||||
await invoke('open_url', { url });
|
||||
} catch (error) {
|
||||
console.error('Error opening URL:', error);
|
||||
// Fallback para window.open se Tauri falhar
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-4 sm:p-6">
|
||||
<DialogHeader className="flex-shrink-0 pb-2 sm:pb-4">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm sm:text-base md:text-lg">
|
||||
<Terminal className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||
<span className="break-words">{problem.problem}</span>
|
||||
</DialogTitle>
|
||||
{problem.description && (
|
||||
<DialogDescription className="text-xs sm:text-sm mt-1 sm:mt-2">
|
||||
{problem.description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4 overflow-y-auto flex-1 min-h-0 pr-1 -mr-1">
|
||||
{/* Links de Documentação */}
|
||||
{problem.links.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2 sm:pb-3">
|
||||
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
|
||||
<ExternalLink className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
Documentação Oficial
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">
|
||||
Links para documentação oficial sobre este problema
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{problem.links.map((link, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs sm:text-sm h-9 sm:h-10"
|
||||
onClick={() => handleOpenLink(link.url)}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 sm:w-4 sm:h-4 mr-2 shrink-0" />
|
||||
<span className="truncate">{link.title}</span>
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Comandos */}
|
||||
{problem.commands && problem.commands.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2 sm:pb-3">
|
||||
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
|
||||
<Terminal className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
Comandos
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">
|
||||
Execute estes comandos no terminal para resolver o problema
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
{problem.commands.map((command, idx) => (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium flex-1 break-words">{command.title}</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleCopyCommand(command.code, idx)}
|
||||
className="h-7 sm:h-8 shrink-0 text-xs"
|
||||
>
|
||||
{copiedIndex === idx ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1 text-green-500" />
|
||||
<span className="hidden sm:inline">Copiado</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
<span className="hidden sm:inline">Copiar</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{command.description && (
|
||||
<p className="text-xs text-muted-foreground">{command.description}</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<pre className="bg-muted p-2 sm:p-3 md:p-4 rounded-lg overflow-x-auto text-xs sm:text-sm">
|
||||
<code className="font-mono whitespace-pre-wrap break-all">{command.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Mensagem quando não há comandos nem links */}
|
||||
{problem.links.length === 0 && (!problem.commands || problem.commands.length === 0) && (
|
||||
<Card>
|
||||
<CardContent className="py-4 sm:py-6 text-center text-muted-foreground">
|
||||
<p className="text-xs sm:text-sm">Nenhuma solução disponível para este problema.</p>
|
||||
<p className="text-xs mt-2">
|
||||
Consulte a documentação oficial do Docker para mais informações.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Download,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Wrench,
|
||||
Server,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Shield,
|
||||
Zap,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { SearxngInstallWizard } from './searxng-install-wizard';
|
||||
import { DockerCommandsModal } from './docker-commands-modal';
|
||||
import { findProblemSolution, ProblemSolution } from '@/lib/docker-problems';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SystemRequirements {
|
||||
status: 'pass' | 'fail' | 'warn';
|
||||
os: string;
|
||||
arch: string;
|
||||
ram_mb: number;
|
||||
disk_gb: number;
|
||||
cpu_cores: number;
|
||||
kernel_version?: string;
|
||||
issues: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface InstallProgressEvent {
|
||||
event_type: 'step_start' | 'log' | 'step_complete' | 'error' | 'complete';
|
||||
step?: number;
|
||||
step_name?: string;
|
||||
total_steps?: number;
|
||||
progress: number;
|
||||
message?: string;
|
||||
log_line?: string;
|
||||
}
|
||||
|
||||
interface FixProgressEvent {
|
||||
event_type: 'start' | 'progress' | 'complete' | 'error';
|
||||
message: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
status: 'pass' | 'fail' | 'partial';
|
||||
tests: Array<{
|
||||
name: string;
|
||||
status: 'pass' | 'fail' | 'warn';
|
||||
message?: string;
|
||||
}>;
|
||||
docker_version?: string;
|
||||
}
|
||||
|
||||
export function DockerSettingsPanel() {
|
||||
const { toast } = useToast();
|
||||
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null);
|
||||
const [dockerRunning, setDockerRunning] = useState<boolean | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [requirements, setRequirements] = useState<SystemRequirements | null>(null);
|
||||
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
|
||||
const [isInstallingDocker, setIsInstallingDocker] = useState(false);
|
||||
const [isFixing, setIsFixing] = useState(false);
|
||||
const [fixProgress, setFixProgress] = useState<{ message: string; progress: number } | null>(null);
|
||||
const [dockerInstallProgress, setDockerInstallProgress] = useState<{
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
progress: number;
|
||||
currentStepName: string;
|
||||
logs: string[];
|
||||
}>({
|
||||
currentStep: 0,
|
||||
totalSteps: 0,
|
||||
progress: 0,
|
||||
currentStepName: '',
|
||||
logs: [],
|
||||
});
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [showInstallWizard, setShowInstallWizard] = useState(false);
|
||||
const [showCommandsModal, setShowCommandsModal] = useState(false);
|
||||
const [selectedProblem, setSelectedProblem] = useState<ProblemSolution | null>(null);
|
||||
|
||||
// Verificar Docker ao montar
|
||||
useEffect(() => {
|
||||
checkDocker();
|
||||
checkRequirements();
|
||||
}, []);
|
||||
|
||||
// Listener de eventos de progresso Docker
|
||||
useEffect(() => {
|
||||
let unlistenPromise: Promise<() => void> | null = null;
|
||||
|
||||
if (isInstallingDocker) {
|
||||
unlistenPromise = listen<InstallProgressEvent>('docker-install-progress', (event) => {
|
||||
const data = event.payload;
|
||||
|
||||
switch (data.event_type) {
|
||||
case 'step_start':
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
currentStep: data.step || 0,
|
||||
totalSteps: data.total_steps || prev.totalSteps,
|
||||
currentStepName: data.step_name || '',
|
||||
progress: data.progress,
|
||||
}));
|
||||
if (data.message) {
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
logs: [...prev.logs.slice(-49), data.message!],
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'log':
|
||||
if (data.log_line) {
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
logs: [...prev.logs.slice(-49), data.log_line!],
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step_complete':
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
progress: data.progress,
|
||||
}));
|
||||
if (data.message) {
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
logs: [...prev.logs.slice(-49), data.message!],
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
toast({
|
||||
title: 'Erro na instalação',
|
||||
description: data.message || 'Erro desconhecido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsInstallingDocker(false);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
setDockerInstallProgress(prev => ({ ...prev, progress: 100 }));
|
||||
setIsInstallingDocker(false);
|
||||
toast({
|
||||
title: 'Docker instalado',
|
||||
description: 'Docker foi instalado com sucesso!',
|
||||
});
|
||||
setTimeout(() => {
|
||||
validateDocker();
|
||||
checkDocker();
|
||||
}, 1000);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (unlistenPromise) {
|
||||
unlistenPromise.then(fn => fn());
|
||||
}
|
||||
};
|
||||
}, [isInstallingDocker, toast]);
|
||||
|
||||
// Listener de eventos de correção
|
||||
useEffect(() => {
|
||||
let unlistenPromise: Promise<() => void> | null = null;
|
||||
|
||||
if (isFixing) {
|
||||
unlistenPromise = listen<FixProgressEvent>('docker-fix-progress', (event) => {
|
||||
const data = event.payload;
|
||||
|
||||
switch (data.event_type) {
|
||||
case 'start':
|
||||
setFixProgress({ message: data.message, progress: 0 });
|
||||
break;
|
||||
case 'progress':
|
||||
setFixProgress({ message: data.message, progress: data.progress || 0 });
|
||||
break;
|
||||
case 'complete':
|
||||
setFixProgress({ message: data.message, progress: 100 });
|
||||
setTimeout(() => {
|
||||
setIsFixing(false);
|
||||
setFixProgress(null);
|
||||
checkRequirements();
|
||||
toast({
|
||||
title: 'Correção concluída',
|
||||
description: data.message,
|
||||
});
|
||||
}, 1500);
|
||||
break;
|
||||
case 'error':
|
||||
setIsFixing(false);
|
||||
setFixProgress(null);
|
||||
toast({
|
||||
title: 'Erro na correção',
|
||||
description: data.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (unlistenPromise) {
|
||||
unlistenPromise.then(fn => fn());
|
||||
}
|
||||
};
|
||||
}, [isFixing, toast]);
|
||||
|
||||
const checkDocker = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const available = await invoke<boolean>('check_docker_available');
|
||||
setDockerAvailable(available);
|
||||
|
||||
if (available) {
|
||||
const running = await invoke<boolean>('check_docker_running');
|
||||
setDockerRunning(running);
|
||||
} else {
|
||||
setDockerRunning(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Docker:', error);
|
||||
setDockerAvailable(false);
|
||||
setDockerRunning(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkRequirements = async () => {
|
||||
setIsCheckingRequirements(true);
|
||||
try {
|
||||
const reqs = await invoke<SystemRequirements>('check_docker_requirements');
|
||||
setRequirements(reqs);
|
||||
} catch (error) {
|
||||
console.error('Error checking requirements:', error);
|
||||
setRequirements({
|
||||
status: 'fail',
|
||||
os: 'unknown',
|
||||
arch: 'unknown',
|
||||
ram_mb: 0,
|
||||
disk_gb: 0,
|
||||
cpu_cores: 0,
|
||||
issues: ['Erro ao verificar requisitos do sistema'],
|
||||
warnings: [],
|
||||
});
|
||||
} finally {
|
||||
setIsCheckingRequirements(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFixRequirements = async () => {
|
||||
if (!requirements || requirements.issues.length === 0) {
|
||||
toast({
|
||||
title: 'Nada para corrigir',
|
||||
description: 'Não há problemas encontrados que possam ser corrigidos automaticamente',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFixing(true);
|
||||
setFixProgress({ message: 'Preparando correção...', progress: 0 });
|
||||
|
||||
try {
|
||||
await invoke('fix_docker_requirements', {
|
||||
os: requirements.os,
|
||||
issues: requirements.issues,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fixing requirements:', error);
|
||||
setIsFixing(false);
|
||||
setFixProgress(null);
|
||||
toast({
|
||||
title: 'Erro na correção',
|
||||
description: error instanceof Error ? error.message : 'Erro desconhecido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallDocker = async () => {
|
||||
setIsInstallingDocker(true);
|
||||
setDockerInstallProgress({
|
||||
currentStep: 0,
|
||||
totalSteps: 0,
|
||||
progress: 0,
|
||||
currentStepName: '',
|
||||
logs: [],
|
||||
});
|
||||
|
||||
try {
|
||||
await invoke('install_docker');
|
||||
} catch (error) {
|
||||
console.error('Docker installation error:', error);
|
||||
toast({
|
||||
title: 'Erro na instalação',
|
||||
description: error instanceof Error ? error.message : 'Erro desconhecido durante instalação do Docker',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsInstallingDocker(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateDocker = async () => {
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await invoke<ValidationResult>('validate_docker_installation');
|
||||
setValidationResult(result);
|
||||
|
||||
if (result.status === 'pass' || result.status === 'partial') {
|
||||
setDockerAvailable(true);
|
||||
setDockerRunning(true);
|
||||
toast({
|
||||
title: 'Docker validado',
|
||||
description: 'Docker está funcionando corretamente',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Validação falhou',
|
||||
description: 'Alguns testes de validação falharam',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
toast({
|
||||
title: 'Erro na validação',
|
||||
description: error instanceof Error ? error.message : 'Erro ao validar Docker',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getStatusIcon = (status: 'pass' | 'fail' | 'warn') => {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||
case 'fail':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
case 'warn':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Status do Docker */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-primary" />
|
||||
<CardTitle className="text-lg">Status do Docker</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkDocker}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4 mr-1", isChecking && "animate-spin")} />
|
||||
Atualizar
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Verifique se Docker está instalado e rodando
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isChecking ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
|
||||
<span className="text-sm text-muted-foreground">Verificando...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className={cn(
|
||||
"p-4 rounded-lg border-2 transition-colors",
|
||||
dockerAvailable
|
||||
? "border-green-500/50 bg-green-500/10 dark:bg-green-500/20"
|
||||
: "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
|
||||
)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-foreground">Instalação</span>
|
||||
{dockerAvailable ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
dockerAvailable
|
||||
? "text-green-700 dark:text-green-300 border-green-600 dark:border-green-400 bg-green-100 dark:bg-green-500/20"
|
||||
: "text-red-700 dark:text-red-300 border-red-600 dark:border-red-400 bg-red-100 dark:bg-red-500/20"
|
||||
)}
|
||||
>
|
||||
{dockerAvailable ? 'Instalado' : 'Não Instalado'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"p-4 rounded-lg border-2 transition-colors",
|
||||
dockerRunning
|
||||
? "border-green-500/50 bg-green-500/10 dark:bg-green-500/20"
|
||||
: "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
|
||||
)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-foreground">Serviço</span>
|
||||
{dockerRunning ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
dockerRunning
|
||||
? "text-green-700 dark:text-green-300 border-green-600 dark:border-green-400 bg-green-100 dark:bg-green-500/20"
|
||||
: "text-red-700 dark:text-red-300 border-red-600 dark:border-red-400 bg-red-100 dark:bg-red-500/20"
|
||||
)}
|
||||
>
|
||||
{dockerRunning ? 'Rodando' : 'Parado'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dockerAvailable === false && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
Docker não está instalado. Instale-o para usar SearXNG.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{dockerAvailable && !dockerRunning && (
|
||||
<Alert>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
Docker está instalado mas não está rodando. Inicie o Docker Desktop.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requisitos do Sistema */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<CardTitle className="text-lg">Requisitos do Sistema</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{requirements && requirements.issues.length > 0 && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleFixRequirements}
|
||||
disabled={isFixing || isCheckingRequirements}
|
||||
>
|
||||
<Wrench className="w-4 h-4 mr-1" />
|
||||
Corrigir
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkRequirements}
|
||||
disabled={isCheckingRequirements}
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4 mr-1", isCheckingRequirements && "animate-spin")} />
|
||||
Verificar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Verifique se seu sistema atende aos requisitos para Docker
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isCheckingRequirements ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
|
||||
<span className="text-sm text-muted-foreground">Verificando requisitos...</span>
|
||||
</div>
|
||||
) : requirements ? (
|
||||
<>
|
||||
{/* Status Geral */}
|
||||
<div className={cn(
|
||||
"p-4 rounded-lg border-2 flex items-center justify-between",
|
||||
requirements.status === 'pass' && "border-green-500/50 bg-green-500/10 dark:bg-green-500/20",
|
||||
requirements.status === 'warn' && "border-yellow-500/50 bg-yellow-500/10 dark:bg-yellow-500/20",
|
||||
requirements.status === 'fail' && "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(requirements.status)}
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Status Geral</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{requirements.status === 'pass' && 'Sistema compatível'}
|
||||
{requirements.status === 'warn' && 'Avisos encontrados'}
|
||||
{requirements.status === 'fail' && 'Problemas encontrados'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={cn(
|
||||
requirements.status === 'pass' && "bg-green-500",
|
||||
requirements.status === 'warn' && "bg-yellow-500",
|
||||
requirements.status === 'fail' && "bg-red-500"
|
||||
)}>
|
||||
{requirements.status === 'pass' && 'Compatível'}
|
||||
{requirements.status === 'warn' && 'Avisos'}
|
||||
{requirements.status === 'fail' && 'Incompatível'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Especificações do Sistema */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MemoryStick className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">RAM</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{(requirements.ram_mb / 1024).toFixed(1)} GB</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<HardDrive className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Disco Livre</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{requirements.disk_gb} GB</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">CPU Cores</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{requirements.cpu_cores}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Sistema</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{requirements.os} ({requirements.arch})</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progresso de Correção */}
|
||||
{isFixing && fixProgress && (
|
||||
<div className="space-y-2 p-4 rounded-lg border bg-blue-50 border-blue-200">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{fixProgress.message}</span>
|
||||
<span>{fixProgress.progress}%</span>
|
||||
</div>
|
||||
<Progress value={fixProgress.progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Problemas */}
|
||||
{requirements.issues.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-medium mb-2">Problemas Encontrados:</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{requirements.issues.map((issue, idx) => {
|
||||
const solution = findProblemSolution(issue, requirements.os);
|
||||
return (
|
||||
<li key={idx} className="flex items-start justify-between gap-2">
|
||||
<span className="flex-1">{issue}</span>
|
||||
{solution && (
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{solution.commands && solution.commands.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedProblem(solution);
|
||||
setShowCommandsModal(true);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Terminal className="w-3 h-3 mr-1" />
|
||||
Comandos
|
||||
</Button>
|
||||
)}
|
||||
{solution.links.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (solution.links[0]) {
|
||||
try {
|
||||
await invoke('open_url', { url: solution.links[0].url });
|
||||
} catch (error) {
|
||||
console.error('Error opening URL:', error);
|
||||
window.open(solution.links[0].url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
Docs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Avisos */}
|
||||
{requirements.warnings.length > 0 && (
|
||||
<Alert>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-medium mb-2">Avisos:</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{requirements.warnings.map((warning, idx) => {
|
||||
const solution = findProblemSolution(warning, requirements.os);
|
||||
return (
|
||||
<li key={idx} className="flex items-start justify-between gap-2">
|
||||
<span className="flex-1">{warning}</span>
|
||||
{solution && (
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{solution.commands && solution.commands.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedProblem(solution);
|
||||
setShowCommandsModal(true);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Terminal className="w-3 h-3 mr-1" />
|
||||
Comandos
|
||||
</Button>
|
||||
)}
|
||||
{solution.links.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (solution.links[0]) {
|
||||
try {
|
||||
await invoke('open_url', { url: solution.links[0].url });
|
||||
} catch (error) {
|
||||
console.error('Error opening URL:', error);
|
||||
window.open(solution.links[0].url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
Docs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instalação Docker */}
|
||||
{dockerAvailable === false && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5 text-primary" />
|
||||
<CardTitle className="text-lg">Instalar Docker</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Instale Docker automaticamente ou manualmente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.open('https://www.docker.com/products/docker-desktop', '_blank');
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Instalação Manual
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleInstallDocker}
|
||||
disabled={isInstallingDocker || requirements?.status === 'fail'}
|
||||
className="flex-1"
|
||||
>
|
||||
{isInstallingDocker ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Instalando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Instalar Agora
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isInstallingDocker && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{dockerInstallProgress.currentStepName || 'Instalando Docker...'}</span>
|
||||
<span className="text-muted-foreground">{dockerInstallProgress.progress}%</span>
|
||||
</div>
|
||||
<Progress value={dockerInstallProgress.progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{dockerInstallProgress.logs.length > 0 && (
|
||||
<div className="bg-black text-green-400 font-mono text-xs p-3 rounded-lg h-40 overflow-y-auto">
|
||||
{dockerInstallProgress.logs.map((log, idx) => (
|
||||
<div key={idx}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Validação */}
|
||||
{dockerAvailable && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-primary" />
|
||||
<CardTitle className="text-lg">Validação</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={validateDocker}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Validando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Validar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Execute testes para verificar se Docker está funcionando corretamente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{validationResult ? (
|
||||
<div className="space-y-2">
|
||||
{validationResult.tests.map((test, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border",
|
||||
test.status === 'pass' && "bg-green-50 border-green-200",
|
||||
test.status === 'warn' && "bg-yellow-50 border-yellow-200",
|
||||
test.status === 'fail' && "bg-red-50 border-red-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{test.name}</p>
|
||||
{test.message && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{test.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{test.status === 'pass' ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||
) : test.status === 'warn' ? (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{validationResult.docker_version && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Docker {validationResult.docker_version} instalado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Clique em "Validar" para executar os testes
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Botão para configurar SearXNG */}
|
||||
{dockerAvailable && dockerRunning && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setShowInstallWizard(true)}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Settings className="w-5 h-5 mr-2" />
|
||||
Configurar SearXNG
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showInstallWizard && (
|
||||
<SearxngInstallWizard
|
||||
onComplete={() => {
|
||||
setShowInstallWizard(false);
|
||||
checkDocker();
|
||||
toast({
|
||||
title: 'SearXNG configurado',
|
||||
description: 'SearXNG foi instalado e configurado com sucesso!',
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowInstallWizard(false)}
|
||||
initialUrl="http://localhost:8080"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DockerCommandsModal
|
||||
open={showCommandsModal}
|
||||
onOpenChange={setShowCommandsModal}
|
||||
problem={selectedProblem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useSettingsStore } from '@/store/settings-store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Zap, Cpu } from 'lucide-react';
|
||||
|
||||
interface LocalGgufModel {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
quantization?: string;
|
||||
architecture?: string;
|
||||
}
|
||||
|
||||
export function RuntimeSettingsPanel() {
|
||||
const { runtime, llamaCppParams, setRuntime, setLlamaCppParams } = useSettingsStore();
|
||||
const [localModels, setLocalModels] = useState<LocalGgufModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadLocalModels();
|
||||
}, []);
|
||||
|
||||
const loadLocalModels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const models = await invoke<LocalGgufModel[]>('list_local_gguf_models');
|
||||
setLocalModels(models);
|
||||
} catch (error) {
|
||||
console.error('Failed to load local GGUF models:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasLocalModels = localModels.length > 0;
|
||||
const isLlamaCppAvailable = hasLocalModels;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Runtime de Modelos</CardTitle>
|
||||
<CardDescription>
|
||||
Escolha entre Ollama (servidor externo) ou llama.cpp nativo (mais rápido, sem servidor)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Toggle de Runtime */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="runtime-toggle">Usar llama.cpp nativo</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{runtime === 'ollama'
|
||||
? 'Usando Ollama como runtime (requer servidor externo)'
|
||||
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant={runtime === 'ollama' ? 'default' : 'secondary'} className="gap-1">
|
||||
{runtime === 'ollama' ? (
|
||||
<>
|
||||
<span>🦙</span> Ollama
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-3 w-3" />
|
||||
llama.cpp
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{runtime === 'ollama'
|
||||
? 'Usando Ollama como runtime (requer servidor externo)'
|
||||
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Switch
|
||||
id="runtime-toggle"
|
||||
checked={runtime === 'llama-cpp'}
|
||||
onCheckedChange={(checked) => setRuntime(checked ? 'llama-cpp' : 'ollama')}
|
||||
disabled={!isLlamaCppAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLlamaCppAvailable && (
|
||||
<div className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p>
|
||||
Nenhum modelo GGUF local encontrado. Baixe modelos do Hugging Face para usar llama.cpp nativo.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-sm text-muted-foreground">Carregando modelos locais...</div>
|
||||
)}
|
||||
|
||||
{hasLocalModels && (
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Modelos GGUF locais encontrados: {localModels.length}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{localModels.slice(0, 5).map((model, idx) => (
|
||||
<div key={idx} className="text-xs text-muted-foreground">
|
||||
• {model.name} {model.quantization && `(${model.quantization})`}
|
||||
</div>
|
||||
))}
|
||||
{localModels.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
... e mais {localModels.length - 5} modelo(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configurações do llama.cpp */}
|
||||
{runtime === 'llama-cpp' && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div>
|
||||
<Label>Camadas GPU (n_gpu_layers)</Label>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Número de camadas para carregar na GPU. 0 = CPU apenas
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Slider
|
||||
value={[llamaCppParams.nGpuLayers]}
|
||||
onValueChange={([value]) =>
|
||||
setLlamaCppParams({ nGpuLayers: value })
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={llamaCppParams.nGpuLayers}
|
||||
onChange={(e) =>
|
||||
setLlamaCppParams({ nGpuLayers: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
className="w-20"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tamanho do Contexto (n_ctx)</Label>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Tamanho máximo do contexto em tokens
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Slider
|
||||
value={[llamaCppParams.nCtx]}
|
||||
onValueChange={([value]) =>
|
||||
setLlamaCppParams({ nCtx: value })
|
||||
}
|
||||
min={512}
|
||||
max={32768}
|
||||
step={512}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={llamaCppParams.nCtx}
|
||||
onChange={(e) =>
|
||||
setLlamaCppParams({ nCtx: parseInt(e.target.value) || 4096 })
|
||||
}
|
||||
className="w-24"
|
||||
min={512}
|
||||
max={32768}
|
||||
step={512}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Threads</Label>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Número de threads (0 = automático)
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={llamaCppParams.threads || 0}
|
||||
onChange={(e) =>
|
||||
setLlamaCppParams({ threads: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
className="w-32"
|
||||
min={0}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Temperatura</Label>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Controla a aleatoriedade das respostas (0.0 - 2.0)
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Slider
|
||||
value={[llamaCppParams.temperature]}
|
||||
onValueChange={([value]) =>
|
||||
setLlamaCppParams({ temperature: value })
|
||||
}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={llamaCppParams.temperature}
|
||||
onChange={(e) =>
|
||||
setLlamaCppParams({ temperature: parseFloat(e.target.value) || 0.7 })
|
||||
}
|
||||
className="w-20"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Loader2, Save, Search, Shield } from 'lucide-react';
|
||||
|
||||
interface SearchConfig {
|
||||
region: string;
|
||||
safe_search: string;
|
||||
max_results: number;
|
||||
enabled: boolean;
|
||||
log_search_content: boolean;
|
||||
retention_days: number;
|
||||
}
|
||||
|
||||
export function SearchSettingsPanel() {
|
||||
const { toast } = useToast();
|
||||
const [config, setConfig] = useState<SearchConfig | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const loadedConfig = await invoke<SearchConfig>('load_search_config_command');
|
||||
setConfig(loadedConfig);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Search config:', error);
|
||||
toast({
|
||||
title: 'Error loading configuration',
|
||||
description: String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke('save_search_config_command', { config });
|
||||
toast({
|
||||
title: 'Configuration saved',
|
||||
description: 'Search settings have been updated successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save Search config:', error);
|
||||
toast({
|
||||
title: 'Error saving configuration',
|
||||
description: String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="text-center p-8 text-muted-foreground">
|
||||
Failed to load configuration.
|
||||
<Button variant="outline" className="ml-4" onClick={loadConfig}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
DuckDuckGo Search Integration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure anonymous web search settings provided by DuckDuckGo.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) => setConfig({ ...config, enabled: checked })}
|
||||
id="search-enabled"
|
||||
/>
|
||||
<Label htmlFor="search-enabled">Enabled</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="region">Region</Label>
|
||||
<Select
|
||||
value={config.region}
|
||||
onValueChange={(val) => setConfig({ ...config, region: val })}
|
||||
>
|
||||
<SelectTrigger id="region">
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wt-wt">Global (wt-wt)</SelectItem>
|
||||
<SelectItem value="us-en">United States (us-en)</SelectItem>
|
||||
<SelectItem value="uk-en">United Kingdom (uk-en)</SelectItem>
|
||||
<SelectItem value="br-pt">Brazil (br-pt)</SelectItem>
|
||||
<SelectItem value="de-de">Germany (de-de)</SelectItem>
|
||||
<SelectItem value="fr-fr">France (fr-fr)</SelectItem>
|
||||
<SelectItem value="es-es">Spain (es-es)</SelectItem>
|
||||
<SelectItem value="it-it">Italy (it-it)</SelectItem>
|
||||
<SelectItem value="jp-jp">Japan (jp-jp)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="safe-search" className="flex items-center gap-2">
|
||||
<Shield className="h-3 w-3" /> Safe Search
|
||||
</Label>
|
||||
<Select
|
||||
value={config.safe_search}
|
||||
onValueChange={(val) => setConfig({ ...config, safe_search: val })}
|
||||
>
|
||||
<SelectTrigger id="safe-search">
|
||||
<SelectValue placeholder="Select safe search level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="on">Strict (On)</SelectItem>
|
||||
<SelectItem value="moderate">Moderate</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-results">Max Results</Label>
|
||||
<Input
|
||||
id="max-results"
|
||||
type="number"
|
||||
value={config.max_results}
|
||||
onChange={(e) => setConfig({ ...config, max_results: parseInt(e.target.value) || 10 })}
|
||||
min={1}
|
||||
max={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention">Log Retention (days)</Label>
|
||||
<Input
|
||||
id="retention"
|
||||
type="number"
|
||||
value={config.retention_days}
|
||||
onChange={(e) => setConfig({ ...config, retention_days: parseInt(e.target.value) || 30 })}
|
||||
min={1}
|
||||
max={365}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-sm font-medium">Privacy</h3>
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">Log Search Content</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Store query text in local history
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.log_search_content}
|
||||
onCheckedChange={(checked) => setConfig({ ...config, log_search_content: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Settings,
|
||||
Play,
|
||||
Square,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SearxngInstallWizardProps {
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
type Step = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
interface SystemRequirements {
|
||||
status: 'pass' | 'fail' | 'warn';
|
||||
os: string;
|
||||
arch: string;
|
||||
ram_mb: number;
|
||||
disk_gb: number;
|
||||
cpu_cores: number;
|
||||
kernel_version?: string;
|
||||
issues: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface InstallProgressEvent {
|
||||
event_type: 'step_start' | 'log' | 'step_complete' | 'error' | 'complete';
|
||||
step?: number;
|
||||
step_name?: string;
|
||||
total_steps?: number;
|
||||
progress: number;
|
||||
message?: string;
|
||||
log_line?: string;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
status: 'pass' | 'fail' | 'partial';
|
||||
tests: Array<{
|
||||
name: string;
|
||||
status: 'pass' | 'fail' | 'warn';
|
||||
message?: string;
|
||||
}>;
|
||||
docker_version?: string;
|
||||
}
|
||||
|
||||
export function SearxngInstallWizard({ onComplete, onCancel, initialUrl = 'http://localhost:8080' }: SearxngInstallWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(0);
|
||||
const [requirements, setRequirements] = useState<SystemRequirements | null>(null);
|
||||
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
|
||||
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null);
|
||||
const [dockerRunning, setDockerRunning] = useState<boolean | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [port, setPort] = useState('8080');
|
||||
const [autoStart, setAutoStart] = useState(true);
|
||||
const [installProgress, setInstallProgress] = useState<string>('');
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [dockerInstallProgress, setDockerInstallProgress] = useState<{
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
progress: number;
|
||||
currentStepName: string;
|
||||
logs: string[];
|
||||
}>({
|
||||
currentStep: 0,
|
||||
totalSteps: 0,
|
||||
progress: 0,
|
||||
currentStepName: '',
|
||||
logs: [],
|
||||
});
|
||||
const [isInstallingDocker, setIsInstallingDocker] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [verificationStatus, setVerificationStatus] = useState<'checking' | 'success' | 'error' | null>(null);
|
||||
const [verificationError, setVerificationError] = useState<string | null>(null);
|
||||
|
||||
// Step 0: Verificar Requisitos
|
||||
useEffect(() => {
|
||||
if (currentStep === 0) {
|
||||
checkRequirements();
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Step 1: Verificar Docker
|
||||
useEffect(() => {
|
||||
if (currentStep === 1) {
|
||||
checkDocker();
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Listener de eventos de progresso Docker
|
||||
useEffect(() => {
|
||||
let unlistenPromise: Promise<() => void> | null = null;
|
||||
|
||||
if (isInstallingDocker) {
|
||||
unlistenPromise = listen<InstallProgressEvent>('docker-install-progress', (event) => {
|
||||
const data = event.payload;
|
||||
|
||||
switch (data.event_type) {
|
||||
case 'step_start':
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
currentStep: data.step || 0,
|
||||
totalSteps: data.total_steps || prev.totalSteps,
|
||||
currentStepName: data.step_name || '',
|
||||
progress: data.progress,
|
||||
}));
|
||||
if (data.message) {
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
logs: [...prev.logs.slice(-49), data.message!],
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'log':
|
||||
if (data.log_line) {
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
logs: [...prev.logs.slice(-49), data.log_line!],
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step_complete':
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
progress: data.progress,
|
||||
}));
|
||||
if (data.message) {
|
||||
setDockerInstallProgress(prev => ({
|
||||
...prev,
|
||||
logs: [...prev.logs.slice(-49), data.message!],
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setInstallError(data.message || 'Erro desconhecido');
|
||||
setIsInstallingDocker(false);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
setDockerInstallProgress(prev => ({ ...prev, progress: 100 }));
|
||||
setIsInstallingDocker(false);
|
||||
// Validar instalação após conclusão
|
||||
setTimeout(() => {
|
||||
validateDocker();
|
||||
}, 1000);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (unlistenPromise) {
|
||||
unlistenPromise.then(fn => fn());
|
||||
}
|
||||
};
|
||||
}, [isInstallingDocker]);
|
||||
|
||||
const checkRequirements = async () => {
|
||||
setIsCheckingRequirements(true);
|
||||
try {
|
||||
const reqs = await invoke<SystemRequirements>('check_docker_requirements');
|
||||
setRequirements(reqs);
|
||||
} catch (error) {
|
||||
console.error('Error checking requirements:', error);
|
||||
setRequirements({
|
||||
status: 'fail',
|
||||
os: 'unknown',
|
||||
arch: 'unknown',
|
||||
ram_mb: 0,
|
||||
disk_gb: 0,
|
||||
cpu_cores: 0,
|
||||
issues: ['Erro ao verificar requisitos do sistema'],
|
||||
warnings: [],
|
||||
});
|
||||
} finally {
|
||||
setIsCheckingRequirements(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkDocker = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const available = await invoke<boolean>('check_docker_available');
|
||||
setDockerAvailable(available);
|
||||
|
||||
if (available) {
|
||||
const running = await invoke<boolean>('check_docker_running');
|
||||
setDockerRunning(running);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Docker:', error);
|
||||
setDockerAvailable(false);
|
||||
setDockerRunning(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep((prev) => (prev + 1) as Step);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => (prev - 1) as Step);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallDocker = async () => {
|
||||
setIsInstallingDocker(true);
|
||||
setInstallError(null);
|
||||
setDockerInstallProgress({
|
||||
currentStep: 0,
|
||||
totalSteps: 0,
|
||||
progress: 0,
|
||||
currentStepName: '',
|
||||
logs: [],
|
||||
});
|
||||
|
||||
try {
|
||||
await invoke('install_docker');
|
||||
} catch (error) {
|
||||
console.error('Docker installation error:', error);
|
||||
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação do Docker');
|
||||
setIsInstallingDocker(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateDocker = async () => {
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await invoke<ValidationResult>('validate_docker_installation');
|
||||
setValidationResult(result);
|
||||
|
||||
if (result.status === 'pass' || result.status === 'partial') {
|
||||
// Docker validado, atualizar estado
|
||||
setDockerAvailable(true);
|
||||
setDockerRunning(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
setValidationResult({
|
||||
status: 'fail',
|
||||
tests: [{
|
||||
name: 'Validação',
|
||||
status: 'fail',
|
||||
message: error instanceof Error ? error.message : 'Erro ao validar Docker',
|
||||
}],
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
setIsInstalling(true);
|
||||
setInstallError(null);
|
||||
setInstallProgress('Iniciando instalação...');
|
||||
|
||||
try {
|
||||
setInstallProgress('Baixando imagem SearXNG...');
|
||||
await invoke('install_searxng', { url, port: parseInt(port, 10) });
|
||||
|
||||
setInstallProgress('Criando container...');
|
||||
// Installation is handled by install_searxng command
|
||||
|
||||
setInstallProgress('Iniciando serviços...');
|
||||
await invoke('start_searxng');
|
||||
|
||||
setInstallProgress('Instalação concluída!');
|
||||
setTimeout(() => {
|
||||
handleNext();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Installation error:', error);
|
||||
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação');
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
setVerificationStatus('checking');
|
||||
setVerificationError(null);
|
||||
|
||||
try {
|
||||
const isAvailable = await invoke<boolean>('check_searxng_status', { url });
|
||||
|
||||
if (isAvailable) {
|
||||
setVerificationStatus('success');
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 1500);
|
||||
} else {
|
||||
setVerificationStatus('error');
|
||||
setVerificationError('SearXNG não está respondendo. Verifique se o container está rodando.');
|
||||
}
|
||||
} catch (error) {
|
||||
setVerificationStatus('error');
|
||||
setVerificationError(error instanceof Error ? error.message : 'Erro ao verificar SearXNG');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">Verificação de Requisitos do Sistema</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verificando se seu sistema atende aos requisitos para instalação do Docker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isCheckingRequirements ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">Verificando requisitos...</span>
|
||||
</div>
|
||||
) : requirements ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-medium text-lg">Status Geral</span>
|
||||
{requirements.status === 'pass' ? (
|
||||
<Badge className="bg-green-500">Compatível</Badge>
|
||||
) : requirements.status === 'warn' ? (
|
||||
<Badge className="bg-yellow-500">Avisos</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500">Incompatível</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Sistema Operacional</p>
|
||||
<p className="font-medium">{requirements.os} ({requirements.arch})</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">RAM</p>
|
||||
<p className="font-medium">{(requirements.ram_mb / 1024).toFixed(1)} GB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Disco Livre</p>
|
||||
<p className="font-medium">{requirements.disk_gb} GB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">CPU Cores</p>
|
||||
<p className="font-medium">{requirements.cpu_cores}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requirements.kernel_version && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-muted-foreground">Versão do Kernel</p>
|
||||
<p className="font-medium">{requirements.kernel_version}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requirements.issues.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Problemas Encontrados:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
{requirements.issues.map((issue, idx) => (
|
||||
<li key={idx}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{requirements.warnings.length > 0 && (
|
||||
<Alert>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Avisos:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
{requirements.warnings.map((warning, idx) => (
|
||||
<li key={idx}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={checkRequirements} disabled={isCheckingRequirements}>
|
||||
Verificar Novamente
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={requirements.status === 'fail'}
|
||||
className="ml-auto"
|
||||
>
|
||||
Continuar
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">Verificação de Docker</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SearXNG requer Docker para funcionar. Vamos verificar se está instalado e rodando.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isChecking ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">Verificando Docker...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Docker Instalado</span>
|
||||
{dockerAvailable === null ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : dockerAvailable ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
{dockerAvailable === false && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Docker não está instalado. Você precisa instalá-lo primeiro.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dockerAvailable && (
|
||||
<div className="p-4 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Docker Rodando</span>
|
||||
{dockerRunning === null ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : dockerRunning ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
{dockerRunning === false && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Docker está instalado mas não está rodando. Inicie o Docker Desktop.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dockerAvailable === false && (
|
||||
<Alert>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Docker não está instalado. Você pode instalá-lo agora ou manualmente.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleInstallDocker}
|
||||
disabled={isInstallingDocker}
|
||||
>
|
||||
{isInstallingDocker ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Instalando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Instalar Docker Agora
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open('https://www.docker.com/products/docker-desktop', '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Instalar Manualmente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInstallingDocker && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{dockerInstallProgress.currentStepName || 'Instalando Docker...'}</span>
|
||||
<span>{dockerInstallProgress.progress}%</span>
|
||||
</div>
|
||||
<Progress value={dockerInstallProgress.progress} />
|
||||
</div>
|
||||
|
||||
{dockerInstallProgress.logs.length > 0 && (
|
||||
<div className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto">
|
||||
{dockerInstallProgress.logs.map((log, idx) => (
|
||||
<div key={idx}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<h4 className="font-medium">Resultados da Validação:</h4>
|
||||
{validationResult.tests.map((test, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 rounded border bg-muted/30">
|
||||
<span className="text-sm">{test.name}</span>
|
||||
{test.status === 'pass' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
) : test.status === 'warn' ? (
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{validationResult.docker_version && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Docker {validationResult.docker_version} instalado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={checkDocker} disabled={isChecking}>
|
||||
Verificar Novamente
|
||||
</Button>
|
||||
{validationResult && (validationResult.status === 'pass' || validationResult.status === 'partial') && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="ml-auto"
|
||||
>
|
||||
Continuar
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
{!validationResult && dockerAvailable && dockerRunning && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="ml-auto"
|
||||
>
|
||||
Continuar
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
{!dockerAvailable && !isInstallingDocker && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
>
|
||||
Pular Instalação Docker
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">Configuração</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure a URL e porta onde SearXNG será acessível.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="searxng-url">URL do SearXNG</Label>
|
||||
<Input
|
||||
id="searxng-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL completa onde o SearXNG estará acessível
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="searxng-port">Porta</Label>
|
||||
<Input
|
||||
id="searxng-port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="8080"
|
||||
min="1024"
|
||||
max="65535"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Porta que será usada pelo container (padrão: 8080)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 p-4 rounded-lg border bg-muted/30">
|
||||
<Checkbox
|
||||
id="auto-start"
|
||||
checked={autoStart}
|
||||
onCheckedChange={(checked) => setAutoStart(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="auto-start" className="cursor-pointer">
|
||||
Iniciar automaticamente com o OllaHub
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="ml-auto">
|
||||
Continuar
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">Instalação do SearXNG</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Instalando e configurando SearXNG. Isso pode levar alguns minutos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isInstalling ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{installProgress}</p>
|
||||
<div className="mt-2 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary animate-pulse" style={{ width: '60%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : installError ? (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-medium mb-2">Erro durante instalação</p>
|
||||
<p className="text-sm">{installError}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleInstall}
|
||||
className="mt-3"
|
||||
>
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Instalação concluída!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SearXNG foi instalado com sucesso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={handleBack} disabled={isInstalling}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
{!isInstalling && !installError && (
|
||||
<Button onClick={handleNext} className="ml-auto">
|
||||
Continuar
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
{isInstalling && (
|
||||
<Button variant="outline" onClick={onCancel} className="ml-auto">
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 5:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">Verificação</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verificando se SearXNG está funcionando corretamente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{verificationStatus === null && (
|
||||
<div className="p-4 rounded-lg border bg-muted/30 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Clique em "Verificar" para testar a conexão com SearXNG.
|
||||
</p>
|
||||
<Button onClick={handleVerify} size="lg">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Verificar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verificationStatus === 'checking' && (
|
||||
<div className="p-4 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
<span>Verificando conexão...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verificationStatus === 'success' && (
|
||||
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">SearXNG está disponível!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A conexão foi estabelecida com sucesso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verificationStatus === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-medium mb-2">Falha na verificação</p>
|
||||
<p className="text-sm mb-3">{verificationError || 'SearXNG não está respondendo'}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleVerify}>
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke('start_searxng');
|
||||
setTimeout(handleVerify, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error starting SearXNG:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Iniciar Container
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={handleBack} disabled={verificationStatus === 'checking'}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
{verificationStatus === 'success' && (
|
||||
<Button onClick={onComplete} className="ml-auto">
|
||||
Concluir
|
||||
<CheckCircle2 className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<Card className="w-full max-w-2xl relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onCancel}
|
||||
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
|
||||
aria-label="Fechar"
|
||||
disabled={isInstalling || isInstallingDocker}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<CardHeader>
|
||||
<CardTitle>SearXNG - Instalação</CardTitle>
|
||||
<CardDescription>
|
||||
Passo {currentStep + 1} de 6
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{renderStep()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle2, Shield, Infinity, Globe, Settings, ArrowRight, X, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SearxngOnboardingProps {
|
||||
onInstall: () => void;
|
||||
onSkip: () => void;
|
||||
onLearnMore?: () => void;
|
||||
}
|
||||
|
||||
export function SearxngOnboarding({ onInstall, onSkip, onLearnMore }: SearxngOnboardingProps) {
|
||||
const benefits = [
|
||||
{
|
||||
icon: Infinity,
|
||||
title: 'Sem limites de queries',
|
||||
description: 'Enquanto Brave API oferece 2000 queries/mês, SearXNG é ilimitado',
|
||||
color: 'text-green-500',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Privacidade total',
|
||||
description: 'Sem rastreamento, sem cookies, sem logs. Seus dados ficam privados',
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Agrega múltiplos motores',
|
||||
description: 'Combina resultados de Google, Bing, DuckDuckGo e outros em uma única busca',
|
||||
color: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Controle total',
|
||||
description: 'Configure motores, filtros e personalizações conforme sua necessidade',
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-y-auto relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSkip}
|
||||
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<CardHeader className="text-center space-y-4 pb-6">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Globe className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl sm:text-3xl">SearXNG Self-Hosted</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Busca Ilimitada e Privada
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Por que SearXNG? */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
Por que SearXNG?
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{benefits.map((benefit, idx) => {
|
||||
const Icon = benefit.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 rounded-lg border border-muted bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={cn('w-5 h-5 mt-0.5 flex-shrink-0', benefit.color)} />
|
||||
<div className="space-y-1 flex-1">
|
||||
<h4 className="font-medium text-sm">{benefit.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">{benefit.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Como Funciona */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-blue-500" />
|
||||
Como Funciona
|
||||
</h3>
|
||||
<div className="p-4 rounded-lg border border-muted bg-muted/30">
|
||||
<div className="flex items-center justify-center gap-2 sm:gap-4 flex-wrap">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-4 py-2 rounded-md bg-blue-500/10 border border-blue-500/20 text-sm font-medium">
|
||||
Brave API
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Tier 1</span>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground" />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-4 py-2 rounded-md bg-green-500/10 border border-green-500/20 text-sm font-medium">
|
||||
SearXNG
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Tier 2</span>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground" />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-4 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20 text-sm font-medium">
|
||||
DuckDuckGo
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Tier 3</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-4 text-center">
|
||||
SearXNG é usado como fallback quando Brave API esgota sua quota mensal (2000 queries).
|
||||
É completamente opcional e não afeta o uso normal do sistema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requisitos */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-orange-500" />
|
||||
Requisitos
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Docker instalado e rodando</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O SearXNG roda em um container Docker. Se não tiver Docker, você pode instalá-lo durante o processo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">~200MB de RAM</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O container SearXNG consome aproximadamente 200MB de memória RAM.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Porta 8080 disponível</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Por padrão, SearXNG usa a porta 8080. Você pode configurar uma porta diferente se necessário.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ações */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t">
|
||||
<Button onClick={onInstall} className="flex-1" size="lg">
|
||||
<ArrowRight className="w-4 h-4 mr-2" />
|
||||
Instalar Agora
|
||||
</Button>
|
||||
{onLearnMore && (
|
||||
<Button onClick={onLearnMore} variant="outline" className="flex-1" size="lg">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Saiba Mais
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSkip} variant="ghost" className="flex-1" size="lg">
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Pular por Enquanto
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,12 @@ export function TitleBar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-0"
|
||||
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-[9999] shrink-0 fixed top-0 left-0 right-0"
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
WebkitAppRegion: 'drag'
|
||||
} as React.CSSProperties & { WebkitAppRegion?: string }}
|
||||
>
|
||||
{/* Área de arrasto com logo/título */}
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertDescription }
|
||||
@@ -38,9 +38,14 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed top-8 left-0 right-0 bottom-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
// Garantir que o overlay não bloqueie eventos na área do TitleBar (topo 32px)
|
||||
clipPath: 'inset(0 0 0 0)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -60,9 +65,10 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[calc(50%+16px)] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useOllamaHealth } from '@/contexts/ollama-health-context';
|
||||
import { useSettingsStore } from '@/store/settings-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, RefreshCw, Power } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export function ServiceInterruptionOverlay() {
|
||||
const { isOllamaRunning, isChecking, checkNow } = useOllamaHealth();
|
||||
const isSetupCompleted = useSettingsStore((s) => s.isSetupCompleted);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const tryStartOllama = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await invoke<boolean>('auto_start_ollama');
|
||||
await checkNow();
|
||||
} catch {}
|
||||
setStarting(false);
|
||||
};
|
||||
|
||||
if (isOllamaRunning || !isSetupCompleted) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full border rounded-lg bg-zinc-900 text-white p-6 shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-400" />
|
||||
<h2 className="text-lg font-semibold">Conexão com Ollama Perdida</h2>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 mb-4">
|
||||
O serviço Ollama não está respondendo. Verifique se ele está aberto.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{isChecking ? (
|
||||
<span className="text-xs text-zinc-400 flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Tentando reconectar...
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={checkNow}>
|
||||
<RefreshCw className="w-4 h-4" /> Verificar novamente
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={tryStartOllama} disabled={starting} className="flex-1">
|
||||
{starting ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Iniciando Ollama...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="w-4 h-4" /> Tentar abrir Ollama
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={checkNow}>
|
||||
Testar novamente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
Reference in new issue
Block a user