Files
OllaHub/components/model/ModelDropdown.tsx
T
Evandro Fonseca Junior a8bb7cc22d feat(ollama): enhance model pull with improved error handling, persistence, and UI
- Add catalog metadata lookup for better error messages with hints (e.g., for missing manifests or tags)
- Persist downloaded model references in saved remote models for future reuse
- Replace searchRemoteModels import with getSavedRemoteModels in search route
- Enhance pull progress UI to display download bytes, digest, and fallback message when data is unavailable
- Improve error payload with model details, URL, and tags count to guide users toward valid tags or models
2025-10-29 19:42:24 -03:00

476 lines
19 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import {
ChevronDown,
Cpu,
CircuitBoard,
Search,
Filter,
Zap,
HardDrive,
Clock,
TrendingUp,
X,
Download,
Trash2,
Cloud,
CheckCircle,
} from "lucide-react";
import type { ModelInfo } from "@/lib/models";
interface ModelDropdownProps {
models: ModelInfo[];
selectedModel: string | null;
onSelectModel: (modelId: string) => void;
onDeleteModel?: (modelId: string) => void;
onPullModel?: (modelName: string) => void;
disabled?: boolean;
}
export function ModelDropdown({
models,
selectedModel,
onSelectModel,
onDeleteModel,
onPullModel,
disabled = false,
}: ModelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [filterDevice, setFilterDevice] = useState<"all" | "CPU" | "GPU">(
"all"
);
const [showRemote, setShowRemote] = useState(false);
const [remoteModels, setRemoteModels] = useState<any[]>([]);
const [isSearchingRemote, setIsSearchingRemote] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
null
);
const dropdownRef = useRef<HTMLDivElement>(null);
// Buscar modelos remotos
const searchRemoteModels = async (query: string) => {
setIsSearchingRemote(true);
try {
const response = await fetch(
`/api/ollama/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
if (data.success) {
setRemoteModels(data.models);
}
} catch (error) {
console.error("Erro ao buscar modelos remotos:", error);
} finally {
setIsSearchingRemote(false);
}
};
// Deletar modelo
const handleDeleteModel = async (modelId: string) => {
try {
const response = await fetch("/api/ollama/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelName: modelId }),
});
if (response.ok) {
onDeleteModel?.(modelId);
setShowDeleteConfirm(null);
}
} catch (error) {
console.error("Erro ao deletar modelo:", error);
}
};
// Baixar modelo remoto
const handlePullModel = (modelName: string) => {
onPullModel?.(modelName);
};
// Filtrar modelos
const filteredModels = models.filter((model) => {
const matchesSearch = model.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesDevice =
filterDevice === "all" || model.device === filterDevice;
return matchesSearch && matchesDevice;
});
// Categorizar modelos por performance
const categorizeModel = (model: ModelInfo) => {
if (model.sizeGB <= 3) return "compact";
if (model.sizeGB <= 7) return "balanced";
return "powerful";
};
const getPerformanceColor = (model: ModelInfo) => {
const category = categorizeModel(model);
switch (category) {
case "compact":
return "text-green-600 bg-green-50 border-green-200";
case "balanced":
return "text-blue-600 bg-blue-50 border-blue-200";
case "powerful":
return "text-purple-600 bg-purple-50 border-purple-200";
default:
return "text-gray-600 bg-gray-50 border-gray-200";
}
};
const getPerformanceIcon = (model: ModelInfo) => {
const category = categorizeModel(model);
switch (category) {
case "compact":
return <Zap className="h-3 w-3" />;
case "balanced":
return <TrendingUp className="h-3 w-3" />;
case "powerful":
return <CircuitBoard className="h-3 w-3" />;
default:
return <Cpu className="h-3 w-3" />;
}
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchTerm("");
setFilterDevice("all");
setShowRemote(false);
setRemoteModels([]);
setShowDeleteConfirm(null);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Buscar modelos remotos quando necessário
useEffect(() => {
if (showRemote && searchTerm.length > 2) {
const timeoutId = setTimeout(() => {
searchRemoteModels(searchTerm);
}, 500);
return () => clearTimeout(timeoutId);
}
}, [showRemote, searchTerm]);
const selectedModelData = models.find((m) => m.id === selectedModel);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
className="flex w-full items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
>
<div className="flex items-center gap-2">
{selectedModelData ? (
<>
<span className="truncate font-medium">
{selectedModelData.name}
</span>
<div className="flex items-center gap-1">
<span
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
selectedModelData
)}`}
>
{getPerformanceIcon(selectedModelData)}
{categorizeModel(selectedModelData)}
</span>
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs">
{selectedModelData.device === "GPU" ? (
<CircuitBoard className="h-3 w-3" />
) : (
<Cpu className="h-3 w-3" />
)}
{selectedModelData.device}
</span>
</div>
</>
) : (
<span className="text-[var(--foreground)]/60">
Select a model...
</span>
)}
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
{isOpen && (
<div className="absolute top-full z-50 mt-1 w-full min-w-[400px] rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-xl">
{/* Header com busca e filtros */}
<div className="border-b border-[var(--border)] p-3">
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground)]/40" />
<input
type="text"
placeholder="Search models..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] pl-8 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--foreground)]/40 hover:text-[var(--foreground)]/60"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="flex items-center gap-1">
<Filter className="h-4 w-4 text-[var(--foreground)]/40" />
<select
value={filterDevice}
onChange={(e) =>
setFilterDevice(e.target.value as "all" | "CPU" | "GPU")
}
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--accent)]"
>
<option value="all">All Devices</option>
<option value="CPU">CPU Only</option>
<option value="GPU">GPU Only</option>
</select>
{/* Instalar por referência */}
{onPullModel && (
<button
onClick={() => handlePullModel("")}
className="rounded-md bg-[var(--accent)] text-white px-2 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
title="Instalar por referência (ex.: owner/model:tag)"
>
Install by ref
</button>
)}
</div>
</div>
{/* Toggle para busca remota */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setShowRemote(!showRemote)}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
showRemote
? "bg-[var(--accent)] text-white"
: "bg-[var(--surface)] text-[var(--foreground)]/60 hover:bg-[var(--surface)]/80"
}`}
>
<Cloud className="h-3 w-3" />
Remote Search
</button>
<div className="text-xs text-[var(--foreground)]/60">
{showRemote
? `${remoteModels.length} remote`
: `${filteredModels.length} local`}{" "}
model
{(showRemote
? remoteModels.length
: filteredModels.length) !== 1
? "s"
: ""}{" "}
found
</div>
</div>
</div>
</div>
{/* Lista de modelos com scroll customizado */}
<div className="max-h-80 overflow-y-auto custom-scrollbar">
{showRemote ? (
// Lista de modelos remotos
<>
{isSearchingRemote ? (
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--accent)] mx-auto mb-2"></div>
Searching remote models...
</div>
) : remoteModels.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
<Cloud className="h-8 w-8 mx-auto mb-2 opacity-40" />
No remote models found
</div>
) : (
<div className="p-2">
{remoteModels.map((model) => (
<div
key={model.name}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 mb-2"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{model.name}
</span>
<span className="flex items-center gap-1 rounded-md bg-blue-50 text-blue-600 border border-blue-200 px-2 py-0.5 text-xs">
<Cloud className="h-3 w-3" />
Remote
</span>
{model.installed && (
<span className="flex items-center gap-1 rounded-md bg-green-50 text-green-600 border border-green-200 px-2 py-0.5 text-xs">
<CheckCircle className="h-3 w-3" />
Installed
</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-[var(--foreground)]/50">
Available for download
</div>
<button
onClick={() => handlePullModel(model.name)}
disabled={model.installed}
className="flex items-center gap-1 rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="h-3 w-3" />
{model.installed ? "Installed" : "Download"}
</button>
</div>
</div>
))}
</div>
)}
</>
) : (
// Lista de modelos locais
<>
{filteredModels.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
<Search className="h-8 w-8 mx-auto mb-2 opacity-40" />
No models found
</div>
) : (
<div className="p-2">
{filteredModels.map((model) => (
<div
key={model.id}
className={`w-full rounded-lg border p-3 mb-2 transition-all hover:shadow-md ${
selectedModel === model.id
? "border-[var(--accent)] bg-[var(--accent)]/5"
: "border-[var(--border)] bg-[var(--background)] hover:bg-[var(--surface)]"
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{model.name}
</span>
<span
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
model
)}`}
>
{getPerformanceIcon(model)}
{categorizeModel(model)}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{model.sizeGB} GB
</span>
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
{model.quantization}
</span>
<span className="flex items-center gap-1">
{model.device === "GPU" ? (
<CircuitBoard className="h-3 w-3" />
) : (
<Cpu className="h-3 w-3" />
)}
{model.device}
</span>
</div>
</div>
</div>
{/* Informações técnicas e ações */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-xs text-[var(--foreground)]/50">
{model.estCpuUsagePct && (
<div className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
<span>{model.estCpuUsagePct}% CPU</span>
</div>
)}
{model.estVramGB && model.estVramGB > 0 && (
<div className="flex items-center gap-1">
<CircuitBoard className="h-3 w-3" />
<span>{model.estVramGB} GB VRAM</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onSelectModel(model.id)}
className="rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
>
Select
</button>
{onDeleteModel && (
<button
onClick={() => setShowDeleteConfirm(model.id)}
className="rounded-md bg-red-50 text-red-600 border border-red-200 px-3 py-1 text-xs hover:bg-red-100"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Confirmação de exclusão */}
{showDeleteConfirm === model.id && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-md">
<div className="text-xs text-red-700 mb-2">
Are you sure you want to delete this model?
</div>
<div className="flex gap-2">
<button
onClick={() => handleDeleteModel(model.id)}
className="rounded-md bg-red-600 text-white px-2 py-1 text-xs hover:bg-red-700"
>
Delete
</button>
<button
onClick={() => setShowDeleteConfirm(null)}
className="rounded-md bg-gray-200 text-gray-700 px-2 py-1 text-xs hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
</div>
)}
</div>
);
}