diff --git a/bin/sync.py b/bin/sync.py index 6e60441..4f509c2 100644 --- a/bin/sync.py +++ b/bin/sync.py @@ -241,7 +241,9 @@ def cmd_restore(args) -> int: if not args.no_desktop: linux_stage("restore-linux.sh", "desktop") print("\nRestore complete. Nexus is ready to start.") - print("Note: Ollama models are not in the backup - pull them with `ollama pull `.") + print("Note: Ollama models are not in the backup. Open the Models tab -> Required") + print("and pull the two models NexusOS depends on (memory curator + embeddings)") + print("before anything else, then pick a hardware-fit chat model.") return 0 diff --git a/install-windows.ps1 b/install-windows.ps1 index 6679ee7..f524f77 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -321,12 +321,13 @@ else { Write-Warn "Could not persist default model - pick it at the top of the c # an 8B model). The Models tab detects VRAM/RAM and marks which models fit, so # the user pulls the right ones there instead of us guessing several GB. Write-Step "Skipping model download (pick hardware-appropriate models in the app)" -Write-Host " No models were downloaded. Open NexusOS -> Models: it detects your" -ForegroundColor DarkGray -Write-Host " VRAM/RAM and flags which models fit (green = GPU, yellow = CPU/RAM)." -ForegroundColor DarkGray -Write-Host " Pull at least:" -ForegroundColor DarkGray -Write-Host " - $EmbedModel (required for recall / document search)" -ForegroundColor Gray -Write-Host " - a chat model the Models tab marks as fitting your GPU (or $ChatModel on a big one)" -ForegroundColor Gray -Write-Host " - $MemModel for the memory curator (optional)" -ForegroundColor Gray +Write-Host " No models were downloaded. Open NexusOS -> Models. The Required tab" -ForegroundColor DarkGray +Write-Host " lists the two models NexusOS itself depends on and gates the rest" -ForegroundColor DarkGray +Write-Host " of the catalog until both are installed:" -ForegroundColor DarkGray +Write-Host " - $MemModel (required - memory curator: extracts facts, titles chats)" -ForegroundColor Gray +Write-Host " - $EmbedModel (required - semantic recall of past conversations)" -ForegroundColor Gray +Write-Host " After that, the Recommended tab detects your VRAM/RAM and flags which" -ForegroundColor DarkGray +Write-Host " chat models fit (green = GPU, yellow = CPU/RAM), e.g. $ChatModel." -ForegroundColor DarkGray # -- Make Ollama manual-start (NexusOS owns the lifecycle) ---------------------- Write-Step "Setting Ollama to manual start" diff --git a/interface/web/src/Models.jsx b/interface/web/src/Models.jsx index e1fba5e..7957031 100644 --- a/interface/web/src/Models.jsx +++ b/interface/web/src/Models.jsx @@ -1,14 +1,101 @@ -import { useCallback, useEffect, useState } from "react"; +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", +}; + +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([]); + 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 { @@ -28,16 +115,21 @@ export function Models({ onPullStateChange }) { 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(); - // Embedding models (nomic-embed-text) are infrastructure โ€” hide them. - setModels((data.models || []).filter((m) => !m.name.toLowerCase().includes("embed"))); + 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); } @@ -51,6 +143,7 @@ export function Models({ onPullStateChange }) { } setPulling(true); + setPullingName(name); onPullStateChange?.(true); setError(""); setPullProgress(`Pulling ${name}โ€ฆ`); @@ -100,6 +193,7 @@ export function Models({ onPullStateChange }) { setError(`Error pulling model: ${err.message}`); } finally { setPulling(false); + setPullingName(""); onPullStateChange?.(false); } }; @@ -130,12 +224,21 @@ export function Models({ onPullStateChange }) { return () => clearInterval(interval); }, [loadModels]); - const installedNames = new Set(models.map(m => m.name.toLowerCase())); - const FIT = { - gpu: { label: "๐ŸŸข fits GPU", color: "#8aff8a" }, - ram: { label: "๐ŸŸก runs on RAM (CPU)", color: "#e8c65a" }, - no: { label: "๐Ÿ”ด too big", color: "#ff8a80" }, - }; + const installedNames = useMemo( + () => new Set(allInstalled.map(m => m.name.toLowerCase())), + [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(m.name.toLowerCase())); + const requiredSatisfied = recommended != null && requiredMissing.length === 0; return (
@@ -161,159 +264,238 @@ export function Models({ onPullStateChange }) { padding: "1rem", display: "flex", flexDirection: "column", - overflowY: "auto", - overflowX: "hidden" + minHeight: 0, }}> - {/* Pull Model Section */} -
-

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" - }} - /> - - + {/* 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 ( + + ); + })}
- {pullProgress && ( -
- {pullProgress} -
- )} -
- - {/* Recommended for your hardware */} - {recommended && ( -
-

Recommended for your hardware

-
+ {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"}
-
- {recommended.models.map((m) => { - const installed = installedNames.has(m.name.toLowerCase()); - const fit = FIT[m.fit] || FIT.no; - return ( -
-
- {m.name} - ยท {m.params} ยท {m.size_gb} GB ยท {m.role} -
{m.note}
-
- {fit.label} - {installed - ? โœ“ installed - : } -
- ); - })} -
+ )} + +
+ + {pullProgress && ( +
+ {pullProgress}
)} - {/* Local Models List */} -
-

- Available Models ({models.length}) -

- {models.length === 0 ? ( -
- {loading ? "Loading models..." : "No models available. Pull a model to get started."} -
+ {/* 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.map((model) => ( -
+ {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={{ - padding: "0.9rem", - background: "#1b1b1b", - borderRadius: "8px", - border: "1px solid #2a2a2a", - display: "flex", - justifyContent: "space-between", - alignItems: "center" + flex: 1, + padding: "0.6rem 0.75rem", + background: "#0a0a0a", + color: "#eee", + border: "1px solid #333", + borderRadius: "6px", + fontSize: "0.9rem" + }} + /> + -
- ))} + {pulling ? "Pulling..." : "Pull"} + +
)}
diff --git a/requirements-base.txt b/requirements-base.txt index f2aea3e..017cf19 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -45,6 +45,8 @@ python-docx sqlite-vec # Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device. faster-whisper +# Email client: IMAP read (SMTP send is stdlib). Pure-Python, no native deps. +imap-tools # Documentation Support markdown-it-py @@ -52,3 +54,6 @@ MarkupSafe mdurl Pygments Jinja2 + +# Testing (bin/check.sh is the release gate; it shells out to pytest) +pytest diff --git a/synapse/hardware.py b/synapse/hardware.py index a2aa83c..db119d1 100644 --- a/synapse/hardware.py +++ b/synapse/hardware.py @@ -11,16 +11,21 @@ import subprocess from typing import Any, Dict, List, Optional # Curated local-friendly models with their Q4 on-disk sizes (GB) and role. -# Sizes are approximate default-quant download sizes. +# Sizes are approximate default-quant download sizes. `required: True` marks +# the two models NexusOS itself depends on (memory curator + embeddings) โ€” +# see DEFAULT_MEMORY_MODEL / DEFAULT_EMBED_MODEL in nexus_config.py, the +# single source of truth these two entries must stay in sync with. The +# Models page surfaces required models first and gates the rest behind them. CATALOG: List[Dict[str, Any]] = [ + {"name": "mistral:latest", "size_gb": 4.1, "params": "7B", "role": "memory", "note": "Memory curator: extracts facts, titles conversations", "required": True}, + {"name": "nomic-embed-text","size_gb": 0.27,"params": "โ€”", "role": "embeddings", "note": "Powers semantic recall of past conversations", "required": True}, {"name": "gemma2:2b", "size_gb": 1.6, "params": "2B", "role": "chat", "note": "Smallest; fast on any GPU"}, {"name": "llama3.2:3b", "size_gb": 2.0, "params": "3B", "role": "chat", "note": "Small Llama, fits 4GB GPU"}, {"name": "qwen3:4b", "size_gb": 2.5, "params": "4B", "role": "chat", "note": "Reasoning (Think toggle); great on a 4GB card"}, {"name": "phi3:mini", "size_gb": 2.2, "params": "3.8B", "role": "chat", "note": "Strong for its size"}, - {"name": "mistral:latest", "size_gb": 4.1, "params": "7B", "role": "chat/memory","note": "Good curator; runs on CPU/8GB+ RAM"}, {"name": "qwen2.5:7b", "size_gb": 4.7, "params": "7B", "role": "chat", "note": "Capable 7B"}, {"name": "llama3.1:8b", "size_gb": 4.7, "params": "8B", "role": "chat", "note": "Higher quality; CPU or 6GB+ GPU"}, - {"name": "nomic-embed-text","size_gb": 0.27,"params": "โ€”", "role": "embeddings", "note": "Required for recall / document RAG"}, + {"name": "qwen2.5:14b", "size_gb": 9.0, "params": "14B", "role": "chat", "note": "High-end; needs a 12-16GB GPU or 24GB+ RAM"}, ]