import { useCallback, useEffect, useMemo, useState } from "react"; import { API_BASE } from "./config"; const FIT = { gpu: { label: "๐ŸŸข fits GPU", color: "#8aff8a" }, ram: { label: "๐ŸŸก runs on RAM (CPU)", color: "#e8c65a" }, no: { label: "๐Ÿ”ด too big", color: "#ff8a80" }, }; const TABS = [ { key: "required", label: "Required" }, { key: "recommended", label: "Recommended" }, { key: "installed", label: "Installed" }, ]; const GRID_STYLE = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem", alignItems: "stretch", }; const withTag = (n) => (n.includes(":") ? n : `${n}:latest`).toLowerCase(); function ModelCard({ model, installed, pulling, pullingName, locked, onPull }) { const fit = FIT[model.fit] || FIT.no; const isPullingThis = pulling && pullingName === model.name; return (
{model.name}
{model.params} ยท {model.size_gb} GB ยท {model.role}
{model.required && ( required )}
{model.note}
{fit.label} {installed ? ( โœ“ installed ) : ( )}
); } export function Models({ onPullStateChange }) { const [models, setModels] = useState([]); // installed, chat-facing (embed models hidden) const [allInstalled, setAllInstalled] = useState([]); // installed, unfiltered (includes embed) const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [pulling, setPulling] = useState(false); const [pullingName, setPullingName] = useState(""); const [modelName, setModelName] = useState(""); const [pullProgress, setPullProgress] = useState(""); const [recommended, setRecommended] = useState(null); // {hardware, models} const [tab, setTab] = useState("required"); const [manualOpen, setManualOpen] = useState(false); const checkOllamaStatus = useCallback(async () => { try { const response = await fetch(`${API_BASE}/ollama/status`); const data = await response.json(); return data.status === "running"; } catch { return false; } }, []); const loadModels = useCallback(async () => { setLoading(true); setError(""); try { const isRunning = await checkOllamaStatus(); if (!isRunning) { setError("Ollama service is not running"); setModels([]); setAllInstalled([]); return; } const response = await fetch(`${API_BASE}/models/details`); if (!response.ok) throw new Error("Failed to fetch models"); const data = await response.json(); const all = data.models || []; setAllInstalled(all); // Embedding models (nomic-embed-text) are infrastructure โ€” hide from the // Installed list, but they still count via allInstalled for the Required tab. setModels(all.filter((m) => !m.name.toLowerCase().includes("embed"))); } catch (err) { setError(`Error loading models: ${err.message}`); setModels([]); setAllInstalled([]); } finally { setLoading(false); } }, [checkOllamaStatus]); const pullModel = async (nameArg) => { const name = (typeof nameArg === "string" ? nameArg : modelName).trim(); if (!name) { setError("Please enter a model name"); return; } setPulling(true); setPullingName(name); onPullStateChange?.(true); setError(""); setPullProgress(`Pulling ${name}โ€ฆ`); try { const response = await fetch(`${API_BASE}/models/pull`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); if (!response.ok) throw new Error("Failed to pull model"); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split("\n"); for (const line of lines) { if (line.trim()) { try { const json = JSON.parse(line); if (json.error) { setError(`Pull error: ${json.error}`); } else if (json.status) { setPullProgress(json.completed && json.total ? `${json.status} โ€” ${Math.round((json.completed / json.total) * 100)}%` : json.status ); } } catch { // ignore incomplete JSON lines } } } } setModelName(""); setPullProgress(""); await loadModels(); } catch (err) { setError(`Error pulling model: ${err.message}`); } finally { setPulling(false); setPullingName(""); onPullStateChange?.(false); } }; const deleteModel = async (modelId) => { if (!confirm(`Are you sure you want to delete ${modelId}?`)) return; setError(""); try { const response = await fetch(`${API_BASE}/models/${encodeURIComponent(modelId)}`, { method: "DELETE", }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(text || response.statusText); } await loadModels(); } catch (err) { setError(`Error deleting model: ${err.message}`); } }; useEffect(() => { loadModels(); fetch(`${API_BASE}/models/recommended`).then(r => r.ok ? r.json() : null).then(setRecommended).catch(() => {}); const interval = setInterval(loadModels, 30000); return () => clearInterval(interval); }, [loadModels]); // Ollama treats a bare name as ":latest" โ€” you pull "nomic-embed-text" and it // comes back installed as "nomic-embed-text:latest". Comparing raw names left // any untagged catalog entry permanently "missing", which locked the Required // gate shut no matter how many times it was pulled. const installedNames = useMemo( () => new Set(allInstalled.map(m => withTag(m.name))), [allInstalled] ); const requiredModels = useMemo( () => (recommended?.models ?? []).filter(m => m.required), [recommended] ); const otherModels = useMemo( () => (recommended?.models ?? []).filter(m => !m.required), [recommended] ); const requiredMissing = requiredModels.filter(m => !installedNames.has(withTag(m.name))); const requiredSatisfied = recommended != null && requiredMissing.length === 0; return (
{error && (
{error}
)}
{/* Header: hardware summary + tabs */}
{TABS.map(t => { const active = tab === t.key; const badge = t.key === "required" && recommended ? (requiredSatisfied ? "โœ“" : requiredMissing.length) : t.key === "installed" ? models.length : null; return ( ); })}
{recommended && (
{recommended.hardware.ram_gb ? `${recommended.hardware.ram_gb} GB RAM` : "RAM unknown"} {" ยท "} {recommended.hardware.vram_gb ? `${recommended.hardware.vram_gb} GB GPU` : "GPU VRAM unknown โ€” sized by RAM"}
)}
{pullProgress && (
{pullProgress}
)} {/* Scrollable tab content */}
{!recommended ? (
Loading catalog...
) : tab === "required" ? ( <>
{requiredSatisfied ? "All required models are installed. Everything else is unlocked on the Recommended tab." : "NexusOS needs both of these to fully work (memory + recall). Install them before pulling anything else."}
{requiredModels.map(m => ( ))}
) : tab === "recommended" ? ( <> {!requiredSatisfied && (
Dimmed until the Required tab is complete โ€” install those first.
)}
{otherModels.map(m => ( ))}
) : ( <> {models.length === 0 ? (
{loading ? "Loading models..." : "No models installed yet. Start with the Required tab."}
) : (
{models.map((model) => (
{model.name}
{(model.size / (1024 * 1024 * 1024)).toFixed(2)} GB
{model.modified_at && (
Modified: {new Date(model.modified_at).toLocaleDateString()}
)}
))}
)} )}
{/* Manual pull โ€” collapsed by default */}
{!manualOpen ? ( ) : (

Pull model from registry

setModelName(e.target.value)} onKeyPress={(e) => e.key === "Enter" && !pulling && pullModel()} disabled={pulling} style={{ flex: 1, padding: "0.6rem 0.75rem", background: "#0a0a0a", color: "#eee", border: "1px solid #333", borderRadius: "6px", fontSize: "0.9rem" }} />
)}
); }