Arrakis / src /components /ModelStore.tsx
gpt-engineer-app[bot]
Changes
3f73990
Raw
History Blame Contribute Delete
11.2 kB
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
Download,
Trash2,
RefreshCw,
Check,
HardDrive,
Cpu,
AlertCircle,
X
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { useOllama } from "@/hooks/useOllama";
import { cn } from "@/lib/utils";
interface ModelStoreProps {
ollamaUrl: string;
onModelSelect?: (model: string) => void;
selectedModel?: string;
}
export function ModelStore({ ollamaUrl, onModelSelect, selectedModel }: ModelStoreProps) {
const [search, setSearch] = useState("");
const [view, setView] = useState<"library" | "installed">("library");
const {
installedModels,
isConnected,
isLoading,
downloadProgress,
error,
checkConnection,
refreshModels,
pullModel,
deleteModel,
getLibraryModels,
} = useOllama(ollamaUrl);
useEffect(() => {
checkConnection();
}, [checkConnection]);
const libraryModels = getLibraryModels();
const filteredLibrary = libraryModels.filter((m) =>
m.name.toLowerCase().includes(search.toLowerCase()) ||
m.description.toLowerCase().includes(search.toLowerCase()) ||
m.tags.some((t) => t.toLowerCase().includes(search.toLowerCase()))
);
const filteredInstalled = installedModels.filter((m) =>
m.name.toLowerCase().includes(search.toLowerCase())
);
const formatSize = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return `${gb.toFixed(1)} GB`;
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Cpu className="h-5 w-5 text-primary" />
Model Store
</h2>
<div className="flex items-center gap-2">
<div className={cn(
"flex items-center gap-1.5 px-2 py-1 rounded-full text-xs",
isConnected
? "bg-ollama/20 text-ollama"
: "bg-destructive/20 text-destructive"
)}>
<div className={cn(
"w-1.5 h-1.5 rounded-full",
isConnected ? "bg-ollama" : "bg-destructive"
)} />
{isConnected ? "Connected" : "Offline"}
</div>
<Button
size="icon"
variant="ghost"
onClick={refreshModels}
disabled={isLoading}
className="h-8 w-8"
>
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
</Button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search models..."
className="pl-9 bg-input/50 border-border"
/>
</div>
{/* View Toggle */}
<div className="flex gap-1 mt-3 p-1 bg-muted/30 rounded-lg">
<button
onClick={() => setView("library")}
className={cn(
"flex-1 py-1.5 text-sm rounded-md transition-colors",
view === "library"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Library
</button>
<button
onClick={() => setView("installed")}
className={cn(
"flex-1 py-1.5 text-sm rounded-md transition-colors",
view === "installed"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Installed ({installedModels.length})
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="mx-4 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Content */}
<ScrollArea className="flex-1 px-4">
<div className="py-4 space-y-2">
<AnimatePresence mode="popLayout">
{view === "library" ? (
filteredLibrary.map((model) => (
<motion.div
key={model.name}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
"p-3 rounded-xl glass-card hover-lift",
model.isInstalled && "border-ollama/30"
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{model.name}</h3>
{model.isInstalled && (
<Check className="h-4 w-4 text-ollama shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{model.description}
</p>
<div className="flex items-center gap-2 mt-2">
{model.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0">
{tag}
</Badge>
))}
{model.size && (
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{model.size}
</span>
)}
</div>
</div>
<div className="shrink-0">
{downloadProgress[model.name] !== undefined ? (
<div className="w-16">
<Progress value={downloadProgress[model.name]} className="h-1.5" />
<span className="text-[10px] text-muted-foreground">
{downloadProgress[model.name]}%
</span>
</div>
) : model.isInstalled ? (
<Button
size="sm"
variant="outline"
onClick={() => onModelSelect?.(model.name)}
className={cn(
"h-7 text-xs",
selectedModel === model.name && "bg-primary text-primary-foreground"
)}
>
{selectedModel === model.name ? "Active" : "Use"}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => pullModel(model.name)}
disabled={!isConnected}
className="h-7 text-xs gap-1"
>
<Download className="h-3 w-3" />
Pull
</Button>
)}
</div>
</div>
</motion.div>
))
) : (
installedModels.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Cpu className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No models installed</p>
<p className="text-xs mt-1">Browse the library to download models</p>
</div>
) : (
filteredInstalled.map((model) => (
<motion.div
key={model.name}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
"p-3 rounded-xl glass-card hover-lift",
selectedModel === model.name && "border-primary/30"
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{model.name}</h3>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{formatSize(model.size)}
</span>
{model.details?.parameter_size && (
<span>{model.details.parameter_size}</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
onClick={() => onModelSelect?.(model.name)}
className={cn(
"h-7 text-xs",
selectedModel === model.name && "bg-primary text-primary-foreground"
)}
>
{selectedModel === model.name ? "Active" : "Use"}
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => deleteModel(model.name)}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</motion.div>
))
)
)}
</AnimatePresence>
</div>
</ScrollArea>
</div>
);
}