feat(models): skip install-time pull; hardware-aware picks in Models tab
Windows installer no longer auto-downloads models; points to the Models tab. synapse/hardware.py detects RAM + best-effort VRAM and a curated catalog; GET /models/recommended annotates each model with fit (gpu/ram/no); the Models page shows detected RAM/VRAM with fit badges and per-row Pull buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+10
-27
@@ -305,33 +305,16 @@ Pop-Location
|
|||||||
if ($seedOk) { Write-OK "Default model set to $ChatModel" }
|
if ($seedOk) { Write-OK "Default model set to $ChatModel" }
|
||||||
else { Write-Warn "Could not persist default model - pick it at the top of the chat instead." }
|
else { Write-Warn "Could not persist default model - pick it at the top of the chat instead." }
|
||||||
|
|
||||||
Write-Step "Pulling models ($ChatModel for chat, $MemModel for memory, $EmbedModel for recall)"
|
# No auto-download: the right models depend on the machine (a 4GB GPU can't fit
|
||||||
# A prompt, not "press Ctrl+C to skip": Ctrl+C in PowerShell 5.1 terminates the
|
# an 8B model). The Models tab detects VRAM/RAM and marks which models fit, so
|
||||||
# whole script, so the escape hatch the installer advertised was also the one
|
# the user pulls the right ones there instead of us guessing several GB.
|
||||||
# thing that stopped it finishing - no Ollama cleanup, no summary, no window
|
Write-Step "Skipping model download (pick hardware-appropriate models in the app)"
|
||||||
# close. Answering "n" declines the download and the installer carries on.
|
Write-Host " No models were downloaded. Open NexusOS -> Models: it detects your" -ForegroundColor DarkGray
|
||||||
Write-Host " These are several GB. You can skip and pull them later from the Models tab." -ForegroundColor DarkGray
|
Write-Host " VRAM/RAM and flags which models fit (green = GPU, yellow = CPU/RAM)." -ForegroundColor DarkGray
|
||||||
$pullAnswer = Read-Host " Download them now? [Y/n]"
|
Write-Host " Pull at least:" -ForegroundColor DarkGray
|
||||||
if ($pullAnswer -match '^\s*(n|no)\s*$') {
|
Write-Host " - $EmbedModel (required for recall / document search)" -ForegroundColor Gray
|
||||||
Write-Warn "Model download skipped - get them from the Models tab when you are ready."
|
Write-Host " - a chat model the Models tab marks as fitting your GPU (or $ChatModel on a big one)" -ForegroundColor Gray
|
||||||
} else {
|
Write-Host " - $MemModel for the memory curator (optional)" -ForegroundColor Gray
|
||||||
# No pipe: 'ollama pull' draws a progress bar with cursor control, and piping it
|
|
||||||
# (to Out-Host or anything else) buffers the redraws - the download then shows no
|
|
||||||
# output for minutes and reads as a hang. Let it own the console.
|
|
||||||
# No try/catch either: a native command that exits non-zero does not throw, so
|
|
||||||
# the catch never fired and a failed pull was reported as success.
|
|
||||||
ollama pull $ChatModel
|
|
||||||
if ($LASTEXITCODE -eq 0) { Write-OK "$ChatModel ready (default chat model)" }
|
|
||||||
else { Write-Warn "$ChatModel pull skipped/failed - pull it from the Models tab later." }
|
|
||||||
|
|
||||||
ollama pull $MemModel
|
|
||||||
if ($LASTEXITCODE -eq 0) { Write-OK "$MemModel ready (memory curator)" }
|
|
||||||
else { Write-Warn "$MemModel pull skipped/failed - the memory service will fall back to the chat model." }
|
|
||||||
|
|
||||||
ollama pull $EmbedModel
|
|
||||||
if ($LASTEXITCODE -eq 0) { Write-OK "$EmbedModel ready (conversation recall)" }
|
|
||||||
else { Write-Warn "$EmbedModel pull skipped/failed - recall will fall back to lexical search." }
|
|
||||||
}
|
|
||||||
|
|
||||||
# -- Make Ollama manual-start (NexusOS owns the lifecycle) ----------------------
|
# -- Make Ollama manual-start (NexusOS owns the lifecycle) ----------------------
|
||||||
Write-Step "Setting Ollama to manual start"
|
Write-Step "Setting Ollama to manual start"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function Models({ onPullStateChange }) {
|
|||||||
const [pulling, setPulling] = useState(false);
|
const [pulling, setPulling] = useState(false);
|
||||||
const [modelName, setModelName] = useState("");
|
const [modelName, setModelName] = useState("");
|
||||||
const [pullProgress, setPullProgress] = useState("");
|
const [pullProgress, setPullProgress] = useState("");
|
||||||
|
const [recommended, setRecommended] = useState(null); // {hardware, models}
|
||||||
|
|
||||||
const checkOllamaStatus = useCallback(async () => {
|
const checkOllamaStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -42,8 +43,9 @@ export function Models({ onPullStateChange }) {
|
|||||||
}
|
}
|
||||||
}, [checkOllamaStatus]);
|
}, [checkOllamaStatus]);
|
||||||
|
|
||||||
const pullModel = async () => {
|
const pullModel = async (nameArg) => {
|
||||||
if (!modelName.trim()) {
|
const name = (typeof nameArg === "string" ? nameArg : modelName).trim();
|
||||||
|
if (!name) {
|
||||||
setError("Please enter a model name");
|
setError("Please enter a model name");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,13 +53,13 @@ export function Models({ onPullStateChange }) {
|
|||||||
setPulling(true);
|
setPulling(true);
|
||||||
onPullStateChange?.(true);
|
onPullStateChange?.(true);
|
||||||
setError("");
|
setError("");
|
||||||
setPullProgress("");
|
setPullProgress(`Pulling ${name}…`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/models/pull`, {
|
const response = await fetch(`${API_BASE}/models/pull`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: modelName.trim() }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to pull model");
|
if (!response.ok) throw new Error("Failed to pull model");
|
||||||
@@ -122,11 +124,19 @@ export function Models({ onPullStateChange }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadModels();
|
loadModels();
|
||||||
|
fetch(`${API_BASE}/models/recommended`).then(r => r.ok ? r.json() : null).then(setRecommended).catch(() => {});
|
||||||
const interval = setInterval(loadModels, 30000);
|
const interval = setInterval(loadModels, 30000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadModels]);
|
}, [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" },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -215,6 +225,40 @@ export function Models({ onPullStateChange }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recommended for your hardware */}
|
||||||
|
{recommended && (
|
||||||
|
<div style={{ marginBottom: "1.5rem", paddingBottom: "1rem", borderBottom: "1px solid #333" }}>
|
||||||
|
<h3 style={{ margin: "0 0 0.25rem 0", fontSize: "0.95rem", color: "#bbb" }}>Recommended for your hardware</h3>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#777", marginBottom: "0.75rem" }}>
|
||||||
|
{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"}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
|
||||||
|
{recommended.models.map((m) => {
|
||||||
|
const installed = installedNames.has(m.name.toLowerCase());
|
||||||
|
const fit = FIT[m.fit] || FIT.no;
|
||||||
|
return (
|
||||||
|
<div key={m.name} style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.4rem 0.6rem", background: "#0f0f0f", border: "1px solid #262626", borderRadius: "8px" }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ color: "#eee", fontSize: "0.85rem" }}>{m.name}</span>
|
||||||
|
<span style={{ color: "#666", fontSize: "0.75rem" }}> · {m.params} · {m.size_gb} GB · {m.role}</span>
|
||||||
|
<div style={{ color: "#777", fontSize: "0.72rem" }}>{m.note}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: fit.color, fontSize: "0.75rem", whiteSpace: "nowrap" }}>{fit.label}</span>
|
||||||
|
{installed
|
||||||
|
? <span style={{ color: "#8aff8a", fontSize: "0.75rem", whiteSpace: "nowrap" }}>✓ installed</span>
|
||||||
|
: <button onClick={() => pullModel(m.name)} disabled={pulling}
|
||||||
|
style={{ padding: "0.3rem 0.7rem", background: pulling ? "#555" : "#28a745", color: "#fff", border: "none", borderRadius: "6px", cursor: pulling ? "not-allowed" : "pointer", fontSize: "0.75rem", whiteSpace: "nowrap" }}>
|
||||||
|
Pull
|
||||||
|
</button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Local Models List */}
|
{/* Local Models List */}
|
||||||
<div style={{ flexGrow: 1, overflowY: "auto", paddingRight: "0.5rem" }}>
|
<div style={{ flexGrow: 1, overflowY: "auto", paddingRight: "0.5rem" }}>
|
||||||
<h3 style={{ margin: "0 0 1rem 0", fontSize: "0.95rem", color: "#bbb" }}>
|
<h3 style={{ margin: "0 0 1rem 0", fontSize: "0.95rem", color: "#bbb" }}>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Hardware detection + a curated model catalog for the Models page.
|
||||||
|
|
||||||
|
Detects system RAM (reliable, psutil) and GPU VRAM (best-effort: nvidia-smi,
|
||||||
|
then Linux AMD sysfs). VRAM is None when it can't be read (e.g. AMD/Vulkan on
|
||||||
|
Windows) — the UI then recommends by RAM + model size alone.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
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.
|
||||||
|
CATALOG: List[Dict[str, Any]] = [
|
||||||
|
{"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"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ram_gb() -> Optional[float]:
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
return round(psutil.virtual_memory().total / 1024 ** 3, 1)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vram_gb() -> Optional[float]:
|
||||||
|
# NVIDIA
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True, text=True, timeout=3,
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and r.stdout.strip():
|
||||||
|
mb = max(int(x) for x in r.stdout.split())
|
||||||
|
return round(mb / 1024, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Linux AMD/Intel via DRM sysfs
|
||||||
|
try:
|
||||||
|
best = 0
|
||||||
|
for p in glob.glob("/sys/class/drm/card*/device/mem_info_vram_total"):
|
||||||
|
try:
|
||||||
|
best = max(best, int(open(p).read().strip()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if best:
|
||||||
|
return round(best / 1024 ** 3, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None # unknown (e.g. AMD/Vulkan on Windows) -> recommend by RAM
|
||||||
|
|
||||||
|
|
||||||
|
def detect() -> Dict[str, Any]:
|
||||||
|
return {"ram_gb": _ram_gb(), "vram_gb": _vram_gb()}
|
||||||
|
|
||||||
|
|
||||||
|
def _fit(size_gb: float, vram: Optional[float], ram: Optional[float]) -> str:
|
||||||
|
"""Where a model can run: 'gpu' (fully resident), 'ram' (CPU), or 'no'."""
|
||||||
|
if vram and size_gb + 1.0 <= vram: # ~1GB headroom for KV cache/context
|
||||||
|
return "gpu"
|
||||||
|
if ram and size_gb + 2.0 <= ram: # ~2GB headroom for the OS/app
|
||||||
|
return "ram"
|
||||||
|
return "no"
|
||||||
|
|
||||||
|
|
||||||
|
def recommend() -> Dict[str, Any]:
|
||||||
|
hw = detect()
|
||||||
|
models = [
|
||||||
|
{**m, "fit": _fit(m["size_gb"], hw["vram_gb"], hw["ram_gb"])}
|
||||||
|
for m in CATALOG
|
||||||
|
]
|
||||||
|
return {"hardware": hw, "models": models}
|
||||||
@@ -621,6 +621,13 @@ async def reorder_memory(payload: Dict[str, Any] = Body(...)):
|
|||||||
# -------------------------
|
# -------------------------
|
||||||
# Models
|
# Models
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
@app.get("/models/recommended")
|
||||||
|
async def models_recommended():
|
||||||
|
"""Curated models annotated with whether they fit this machine's VRAM/RAM."""
|
||||||
|
from . import hardware
|
||||||
|
return hardware.recommend()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/models")
|
@app.get("/models")
|
||||||
async def get_models():
|
async def get_models():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ def test_keep_alive_pins_the_model():
|
|||||||
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
|
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_fit_logic():
|
||||||
|
from synapse import hardware
|
||||||
|
assert hardware._fit(2.5, 4.0, 16.0) == "gpu" # 2.5+1 <= 4 -> fits GPU
|
||||||
|
assert hardware._fit(4.7, 4.0, 16.0) == "ram" # too big for 4GB GPU, fits RAM
|
||||||
|
assert hardware._fit(4.7, None, 16.0) == "ram" # VRAM unknown -> RAM
|
||||||
|
assert hardware._fit(40.0, 4.0, 16.0) == "no" # too big everywhere
|
||||||
|
rec = hardware.recommend()
|
||||||
|
assert "hardware" in rec and all("fit" in m for m in rec["models"])
|
||||||
|
|
||||||
|
|
||||||
def test_stt_status_endpoint():
|
def test_stt_status_endpoint():
|
||||||
# Reports whether local Whisper is installed; wiring must respond either way.
|
# Reports whether local Whisper is installed; wiring must respond either way.
|
||||||
resp = TestClient(app).get("/stt/status")
|
resp = TestClient(app).get("/stt/status")
|
||||||
|
|||||||
Reference in New Issue
Block a user