import { useCallback, useEffect, useRef, useState } from "react"; import { Chatbot } from "./Chatbot"; import { Playbook } from "./Playbook"; import { Models } from "./Models"; import { Settings } from "./Settings"; import { Memory } from "./Memory"; import { Projects } from "./Projects"; import { Logs } from "./Logs"; import { MODULES } from "./modules/registry"; import { API_BASE } from "./config"; function timeAgo(epochSeconds) { const diff = Math.floor(Date.now() / 1000) - epochSeconds; if (diff < 60) return "just now"; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; return new Date(epochSeconds * 1000).toLocaleDateString(); } function App() { const [currentPage, setCurrentPage] = useState("chatbot"); const [status, setStatus] = useState("Checking Synapse..."); const [version, setVersion] = useState(""); const [ollamaStatus, setOllamaStatus] = useState("checking"); const [ollamaBusy, setOllamaBusy] = useState(false); const [showStatusTooltip, setShowStatusTooltip] = useState(false); const [showModulesMenu, setShowModulesMenu] = useState(false); const [update, setUpdate] = useState(null); const [updateBusy, setUpdateBusy] = useState(false); const [updating, setUpdating] = useState(false); const [activeConversationId, setActiveConversationId] = useState(() => crypto.randomUUID()); const [isModelPulling, setIsModelPulling] = useState(false); const [conversations, setConversations] = useState([]); const [search, setSearch] = useState(""); const [hoveredConvId, setHoveredConvId] = useState(null); const searchDebounce = useRef(null); const loadStatus = () => { fetch(`${API_BASE}/status`) .then(r => r.json()) .then(d => { setStatus(d.status ?? "Unknown"); setVersion(d.version ?? ""); setOllamaStatus(d.ollama ?? "unavailable"); }) .catch(() => { setStatus("Offline"); setOllamaStatus("unavailable"); }); }; // Update check — git fetch on the backend, so it is manual (and once at startup), // not polled with loadStatus. const checkUpdate = async () => { setUpdateBusy(true); try { const r = await fetch(`${API_BASE}/update/check`); setUpdate(await r.json()); } catch { setUpdate({ error: "Backend unreachable" }); } setUpdateBusy(false); }; // Install the update: the backend spawns `ncp upgrade` detached and then gets // stopped by it, so this request is the last one this build answers. The // overlay effect below waits for the new build to come back. const applyUpdate = async () => { if (!confirm(`Install v${update?.remote_version} (${update?.behind} commit(s))?\n\n` + "NexusOS will pull, rebuild and restart. This takes a few minutes and " + "the page reloads itself when the new build is up.")) return; try { const r = await fetch(`${API_BASE}/update/apply`, { method: "POST" }); const d = await r.json(); if (!d.started) { alert(`Update could not start: ${d.error || "unknown error"}`); return; } setUpdating(true); } catch { alert("Update could not start: backend unreachable."); } }; // Manual AI control — Ollama does not auto-start with the app. const toggleOllama = async () => { const action = ollamaStatus === "running" ? "stop" : "start"; setOllamaBusy(true); try { await fetch(`${API_BASE}/ollama/${action}`, { method: "POST" }); } catch (e) { console.error("Ollama toggle failed:", e); } setOllamaBusy(false); loadStatus(); }; const loadConversations = useCallback(async (q = "") => { try { const url = q.trim() ? `${API_BASE}/conversations?q=${encodeURIComponent(q.trim())}` : `${API_BASE}/conversations`; const response = await fetch(url); if (response.ok) { const data = await response.json(); setConversations(data.conversations || []); } } catch (error) { console.error("Failed to load conversations:", error); } }, []); useEffect(() => { loadStatus(); const interval = setInterval(loadStatus, 5000); return () => clearInterval(interval); }, []); useEffect(() => { checkUpdate(); }, []); // Reload only after the backend has actually gone away and come back - it // stays up for a few seconds after /update/apply returns, so a naive // "poll until online" would reload the OLD build immediately. useEffect(() => { if (!updating) return; let wentDown = false; const t = setInterval(async () => { try { const r = await fetch(`${API_BASE}/status`, { cache: "no-store" }); if (!r.ok) throw new Error("not ok"); if (wentDown) window.location.reload(); } catch { wentDown = true; } }, 3000); return () => clearInterval(t); }, [updating]); useEffect(() => { let cancelled = false; fetch(`${API_BASE}/conversations`) .then(r => r.ok ? r.json() : null) .then(data => { if (cancelled || !data) return; setConversations(data.conversations || []); }) .catch(err => console.error("Failed to load conversations:", err)); return () => { cancelled = true; }; }, []); const handleSearch = (e) => { const q = e.target.value; setSearch(q); clearTimeout(searchDebounce.current); searchDebounce.current = setTimeout(() => loadConversations(q), 300); }; const selectConversation = (id) => { setActiveConversationId(id); setCurrentPage("chatbot"); }; const startNewChat = () => { setActiveConversationId(crypto.randomUUID()); setCurrentPage("chatbot"); }; const renameConversation = async (e, conv) => { e.stopPropagation(); const current = conv.title || conv.preview || ""; const next = window.prompt("Rename conversation:", current); if (next == null) return; const title = next.trim(); if (!title || title === current) return; try { const response = await fetch(`${API_BASE}/conversations/${conv.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); if (response.ok) loadConversations(search); } catch (error) { console.error("Failed to rename conversation:", error); } }; // Download ShareGPT JSONL — one conversation if convId given, else all. const exportConversations = (e, convId) => { if (e) e.stopPropagation(); const url = convId ? `${API_BASE}/conversations/export?conversation_id=${encodeURIComponent(convId)}` : `${API_BASE}/conversations/export`; window.open(url, "_blank"); }; const deleteConversation = async (e, conversationId) => { e.stopPropagation(); if (!window.confirm("Delete this conversation?")) return; try { const response = await fetch(`${API_BASE}/conversations/${conversationId}`, { method: "DELETE" }); if (response.ok) { if (activeConversationId === conversationId) { setActiveConversationId(crypto.randomUUID()); } loadConversations(search); } } catch (error) { console.error("Failed to delete conversation:", error); } }; // Bulk delete of everything currently listed (respects the search filter). const clearConversations = async () => { const n = conversations.length; if (!n) return; const what = search ? `${n} matching conversation${n > 1 ? "s" : ""}` : `all ${n} conversation${n > 1 ? "s" : ""}`; if (!window.confirm(`Delete ${what}? This cannot be undone.`)) return; const ids = conversations.map(c => c.id); await Promise.all( ids.map(id => fetch(`${API_BASE}/conversations/${id}`, { method: "DELETE" }).catch(err => console.error("Failed to delete conversation:", err))) ); if (ids.includes(activeConversationId)) setActiveConversationId(crypto.randomUUID()); loadConversations(search); }; const navItems = [ { key: "chatbot", icon: "💬", label: "Chat" }, { key: "playbook", icon: "📖", label: "Playbooks" }, { key: "models", icon: "🤖", label: "Models", badge: isModelPulling }, { key: "memory", icon: "🧠", label: "Memory" }, { key: "projects", icon: "📁", label: "Projects" }, { key: "modules", icon: "📦", label: "Modules", isModulesMenu: true }, { key: "logs", icon: "📜", label: "Logs" }, { key: "settings", icon: "⚙️", label: "Settings" }, ]; return (
{updating && (

Updating NexusOS…

Pulling, rebuilding and restarting. This page reloads itself when the new build is up — a few minutes is normal.

Progress is logged to runtime/logs/update.log

)} {/* Sidebar */} {/* Main Content */}
{/* Models stays mounted so active downloads survive page navigation */}
{/* Chatbot stays mounted so an in-flight reply survives page navigation */}
loadConversations(search)} />
{currentPage === "playbook" && } {currentPage === "memory" && } {currentPage === "projects" && } {MODULES.map(m => currentPage === m.key && )} {currentPage === "logs" && } {currentPage === "settings" && }
); } export default App;