forked from enderofwings/NexusOS
Ported from downstream development. Four independent defects.
1. The memory dump was unrestorable. iterdump() serializes sqlite_vec virtual
tables as a raw INSERT INTO sqlite_master(...) followed by inserts into a
table the replaying connection cannot see, so replaying memory.db.sql died
on "no such table: vec_messages" and left ZERO tables behind. dump_db() now
loads the vec0 extension and filters the derived vec tables out of the
iterdump stream, matched on each statement's target table rather than as a
substring - a chat message whose text mentions vec_messages is an
INSERT INTO "messages" and has to survive.
compare() reported an unreadable dump as "diverged", which read like a real
verdict and made both guards refuse backup AND restore, locking the machine
out of syncing in either direction. Unreadable is now its own verdict.
_extra() compared updated_at against a "" default, but the column is REAL,
so the comparison raises TypeError on the first conversation the other side
lacks - exactly the case it counts. It tests membership first now. The
direction test declared updated_at TEXT, which is why this survived: the
test compared str to str while the field compared str to float.
2. The memory curator invented facts. It attributed the ASSISTANT's words to
the user, wrote absence claims read off the existing-memory block, and added
judgements ("favorite") the user never used. The prompt now scopes the USER
line as the only source, and two deterministic guards drop absence claims
and facts whose distinctive tokens appear nowhere in the user's message -
prompt wording alone did not hold on a 7B curator.
3. _best_vulkan_device scored Mesa's llvmpipe above an integrated GPU, pinning
Ollama to a software rasterizer advertising 31 GiB of "VRAM" - CPU inference
with Vulkan overhead on top. Software rasterizers are dropped.
4. Models.jsx compared catalog names to installed names literally, but Ollama
resolves a bare name to ":latest", so an untagged entry (nomic-embed-text)
read as missing forever and the Required gate never opened. Chatbot.jsx
fetched the model list once on mount although App keeps the page mounted
behind display:none, so a newly pulled model never appeared in the picker
until a full browser reload.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
512 lines
18 KiB
React
512 lines
18 KiB
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",
|
|
};
|
|
|
|
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 (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.5rem",
|
|
padding: "0.85rem",
|
|
background: locked ? "#131313" : "#0f0f0f",
|
|
border: `1px solid ${model.required ? "#3a5a3a" : "#262626"}`,
|
|
borderRadius: "10px",
|
|
opacity: locked ? 0.45 : 1,
|
|
transition: "opacity 0.15s ease",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "0.5rem" }}>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ color: "#eee", fontSize: "0.9rem", fontWeight: 500, wordBreak: "break-word" }}>
|
|
{model.name}
|
|
</div>
|
|
<div style={{ color: "#666", fontSize: "0.75rem" }}>
|
|
{model.params} · {model.size_gb} GB · {model.role}
|
|
</div>
|
|
</div>
|
|
{model.required && (
|
|
<span style={{
|
|
fontSize: "0.65rem", color: "#8aff8a", border: "1px solid #3a5a3a",
|
|
borderRadius: "999px", padding: "0.1rem 0.5rem", whiteSpace: "nowrap",
|
|
}}>
|
|
required
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div style={{ color: "#888", fontSize: "0.75rem", flexGrow: 1 }}>{model.note}</div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem" }}>
|
|
<span style={{ color: fit.color, fontSize: "0.72rem", whiteSpace: "nowrap" }}>{fit.label}</span>
|
|
{installed ? (
|
|
<span style={{ color: "#8aff8a", fontSize: "0.75rem", whiteSpace: "nowrap" }}>✓ installed</span>
|
|
) : (
|
|
<button
|
|
onClick={() => onPull(model.name)}
|
|
disabled={pulling || locked}
|
|
title={locked ? "Install the required models first" : undefined}
|
|
style={{
|
|
padding: "0.3rem 0.7rem",
|
|
background: pulling || locked ? "#555" : "#28a745",
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: "6px",
|
|
cursor: pulling || locked ? "not-allowed" : "pointer",
|
|
fontSize: "0.75rem",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{isPullingThis ? "Pulling..." : "Pull"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
|
|
{error && (
|
|
<div style={{
|
|
padding: "0.75rem 1rem",
|
|
background: "#3d2d2d",
|
|
border: "1px solid #ff6b6b",
|
|
borderRadius: "8px",
|
|
color: "#ff8888",
|
|
marginBottom: "1rem",
|
|
fontSize: "0.9rem"
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{
|
|
flexGrow: 1,
|
|
background: "#161616",
|
|
border: "1px solid #333",
|
|
borderRadius: "12px",
|
|
padding: "1rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
minHeight: 0,
|
|
}}>
|
|
{/* Header: hardware summary + tabs */}
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
|
|
<div style={{ display: "flex", gap: "0.35rem" }}>
|
|
{TABS.map(t => {
|
|
const active = tab === t.key;
|
|
const badge = t.key === "required" && recommended
|
|
? (requiredSatisfied ? "✓" : requiredMissing.length)
|
|
: t.key === "installed" ? models.length : null;
|
|
return (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => setTab(t.key)}
|
|
style={{
|
|
padding: "0.45rem 0.9rem",
|
|
background: active ? "#28a745" : "transparent",
|
|
color: active ? "#fff" : "#aaa",
|
|
border: `1px solid ${active ? "#28a745" : "#333"}`,
|
|
borderRadius: "999px",
|
|
cursor: "pointer",
|
|
fontSize: "0.82rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.4rem",
|
|
}}
|
|
>
|
|
{t.label}
|
|
{badge !== null && badge !== undefined && (
|
|
<span style={{
|
|
fontSize: "0.68rem",
|
|
color: active ? "#fff" : (t.key === "required" && !requiredSatisfied ? "#ff8a80" : "#888"),
|
|
}}>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{recommended && (
|
|
<div style={{ fontSize: "0.78rem", color: "#777" }}>
|
|
{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>
|
|
)}
|
|
<button
|
|
onClick={loadModels}
|
|
disabled={loading}
|
|
style={{
|
|
padding: "0.45rem 0.9rem",
|
|
background: "#007acc",
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: "6px",
|
|
cursor: loading ? "not-allowed" : "pointer",
|
|
opacity: loading ? 0.6 : 1,
|
|
fontSize: "0.82rem",
|
|
}}
|
|
>
|
|
{loading ? "Refreshing..." : "Refresh"}
|
|
</button>
|
|
</div>
|
|
|
|
{pullProgress && (
|
|
<div style={{ marginBottom: "0.85rem", fontSize: "0.85rem", color: "#999" }}>
|
|
{pullProgress}
|
|
</div>
|
|
)}
|
|
|
|
{/* Scrollable tab content */}
|
|
<div style={{ flexGrow: 1, overflowY: "auto", overflowX: "hidden", paddingRight: "0.25rem" }}>
|
|
{!recommended ? (
|
|
<div style={{ padding: "2rem", textAlign: "center", color: "#666" }}>Loading catalog...</div>
|
|
) : tab === "required" ? (
|
|
<>
|
|
<div style={{ marginBottom: "0.85rem", fontSize: "0.85rem", color: requiredSatisfied ? "#8aff8a" : "#e8c65a" }}>
|
|
{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."}
|
|
</div>
|
|
<div style={GRID_STYLE}>
|
|
{requiredModels.map(m => (
|
|
<ModelCard
|
|
key={m.name}
|
|
model={m}
|
|
installed={installedNames.has(withTag(m.name))}
|
|
pulling={pulling}
|
|
pullingName={pullingName}
|
|
locked={false}
|
|
onPull={pullModel}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : tab === "recommended" ? (
|
|
<>
|
|
{!requiredSatisfied && (
|
|
<div style={{ marginBottom: "0.85rem", fontSize: "0.85rem", color: "#e8c65a" }}>
|
|
Dimmed until the Required tab is complete — install those first.
|
|
</div>
|
|
)}
|
|
<div style={GRID_STYLE}>
|
|
{otherModels.map(m => (
|
|
<ModelCard
|
|
key={m.name}
|
|
model={m}
|
|
installed={installedNames.has(withTag(m.name))}
|
|
pulling={pulling}
|
|
pullingName={pullingName}
|
|
locked={!requiredSatisfied}
|
|
onPull={pullModel}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{models.length === 0 ? (
|
|
<div style={{ padding: "2rem", textAlign: "center", color: "#666" }}>
|
|
{loading ? "Loading models..." : "No models installed yet. Start with the Required tab."}
|
|
</div>
|
|
) : (
|
|
<div style={GRID_STYLE}>
|
|
{models.map((model) => (
|
|
<div
|
|
key={model.name}
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.5rem",
|
|
padding: "0.85rem",
|
|
background: "#1b1b1b",
|
|
borderRadius: "10px",
|
|
border: "1px solid #2a2a2a",
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 500, color: "#ddd", wordBreak: "break-word" }}>{model.name}</div>
|
|
<div style={{ fontSize: "0.8rem", color: "#aaa" }}>
|
|
{(model.size / (1024 * 1024 * 1024)).toFixed(2)} GB
|
|
</div>
|
|
{model.modified_at && (
|
|
<div style={{ fontSize: "0.78rem", color: "#888" }}>
|
|
Modified: {new Date(model.modified_at).toLocaleDateString()}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => deleteModel(model.name)}
|
|
style={{
|
|
alignSelf: "flex-start",
|
|
padding: "0.4rem 0.9rem",
|
|
background: "#d63031",
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: "6px",
|
|
cursor: "pointer",
|
|
fontSize: "0.8rem",
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Manual pull — collapsed by default */}
|
|
<div style={{ marginTop: "0.85rem", paddingTop: "0.75rem", borderTop: "1px solid #333" }}>
|
|
{!manualOpen ? (
|
|
<button
|
|
onClick={() => setManualOpen(true)}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "#777",
|
|
fontSize: "0.82rem",
|
|
cursor: "pointer",
|
|
padding: 0,
|
|
}}
|
|
>
|
|
Pull a different model...
|
|
</button>
|
|
) : (
|
|
<div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
|
|
<h3 style={{ margin: 0, fontSize: "0.85rem", color: "#bbb" }}>Pull model from registry</h3>
|
|
<button
|
|
onClick={() => setManualOpen(false)}
|
|
style={{ background: "none", border: "none", color: "#666", fontSize: "0.78rem", cursor: "pointer" }}
|
|
>
|
|
Collapse
|
|
</button>
|
|
</div>
|
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g., llama3, mistral, gemma"
|
|
value={modelName}
|
|
onChange={(e) => 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"
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => pullModel()}
|
|
disabled={pulling}
|
|
style={{
|
|
padding: "0.6rem 1.25rem",
|
|
background: pulling ? "#666" : "#28a745",
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: "6px",
|
|
cursor: pulling ? "not-allowed" : "pointer",
|
|
fontSize: "0.9rem",
|
|
whiteSpace: "nowrap"
|
|
}}
|
|
>
|
|
{pulling ? "Pulling..." : "Pull"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|