fix(launch): reliable Windows launcher; feat(ui): compact sidebar, Vite toggle, chat Think toggle

Ported from the private repo via bin/publish.sh, plus a manual catch-up
on files that had drifted out of sync before today:

- launch_nexus.ps1: health-check based restart decisions instead of a
  bare port-listen check (a wedged leftover process squatting a port
  used to look "already running" and block the real service from
  starting), a script-path quoting fix for Start-Process, hidden
  console via a wscript.exe wrapper (bin/launch_nexus_hidden.vbs), and
  a taskbar/window icon for the native app window.
- Sidebar: slim icon+text nav rows instead of bulky bordered buttons,
  tighter spacing throughout.
- Settings: full-width layout, a Vite dev-server Start/Stop toggle
  (synapse/frontend_manager.py + /frontend/* endpoints), and the
  Linux-only Icon Branding section now gated on the new /status
  `platform` field instead of always rendering.
- Chatbot: a Think toggle next to the model picker, so extended
  thinking can be flipped without leaving the chat page.
- management/ncp.py: faster start/stop polling (0.25s steps instead of
  1s), Vite no longer blocks `ncp start` on Linux and is skipped
  outright on Windows.

Note: the private repo also has a Mail (IMAP/SMTP) feature; it's
intentionally not included here, so the Mail-only pieces of main.py,
App.jsx, and requirements-windows.txt were left out of this port.
This commit is contained in:
Jon Wingender
2026-07-28 11:23:35 -05:00
parent 63c93346ae
commit cc20ceac64
11 changed files with 490 additions and 109 deletions
+37
View File
@@ -153,6 +153,43 @@ Promethean/ Python venv (gitignored, built by the installer)
| Frontend API base URL | `interface/web/src/config.js` |
| Python deps | `requirements-base.txt` + amd/nvidia GPU overlay; `requirements-windows.txt` = standalone CPU runtime |
## Roadmap
Rough plan, not promises. Ordered by how soon and how much it touches.
### Soon
- **Inference knobs in Settings** — expose temperature, context length, and
`num_gpu` in the UI. The backend already passes options to Ollama; there's no
UI for them yet, and small-VRAM boxes need `num_gpu=0` today via env only.
- **Chat controls** — stop generation, regenerate last reply, edit-and-resend.
History editing exists; live regeneration doesn't.
- **Per-playbook model** — playbooks set the persona but not the model; let a
playbook pin its own chat model (Settings already splits chat vs. memory model).
### Eventually
- **Document ingest / RAG** — the embedding stack (nomic-embed-text,
`message_vectors`, cosine recall) already retrieves past conversations. Extend
it to uploaded files and notes so chats can cite your own documents.
- **Vision chat** — attach images to a message and route to a multimodal Ollama
model. The stream path is text-only today.
- **Real vector index** — recall does a brute-force cosine scan over JSON blobs
in SQLite. Fine at current scale; swap in a proper index (e.g. sqlite-vec)
before the DB grows.
### Far future
- **macOS support** — POSIX process control already works; needs a `sync.py`
platform fork, ROCm-free requirements, and an installer. Waiting on a Mac.
- **Voice I/O** — local speech-to-text in, text-to-speech out.
- **Fine-tuning loop** — conversations already export as ShareGPT; close the
loop into a local fine-tune. The ML stack was stripped from the runtime venv
but the requirements files restore it.
- **Tool-using playbooks** — function calling so a playbook can take actions,
not just shape the prompt.
- **Machine learning & gameplay** — the original goal: an agent that learns to
play games alongside me (it started as "teach it to play Lego Star Wars").
Screen capture in, controller/input out, trained by playing. The whole reason
this project exists.
---
<div align="center"><sub>NexusOS · local AI, self-hosted on <a href="https://git.enderofwings.com/enderofwings/NexusOS">GitNexus</a></sub></div>
+15
View File
@@ -0,0 +1,15 @@
' Truly-hidden entry point for the NexusOS desktop shortcut.
'
' powershell.exe -WindowStyle Hidden is not enough on Windows 11: when Windows
' Terminal is the registered default terminal app, it intercepts console-
' subsystem processes before the hide flag applies, so a "NexusOS" terminal
' tab shows up anyway. wscript.exe is a GUI-subsystem host (unlike its sibling
' cscript.exe), so it never allocates a console in the first place and is not
' a candidate for that hijack - WScript.Shell.Run's own hidden window style
' (0) is then respected for the powershell.exe it launches.
Dim fso, scriptDir, psPath, cmd
Set fso = CreateObject("Scripting.FileSystemObject")
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
psPath = fso.GetParentFolderName(scriptDir) & "\launch_nexus.ps1"
cmd = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & psPath & """"
CreateObject("WScript.Shell").Run cmd, 0, False
+53 -12
View File
@@ -10,19 +10,27 @@ The window opens IMMEDIATELY on a loading page and navigates to the app once the
backend answers, rather than waiting up to 40s with nothing on screen and then
appearing. One window the whole time: nothing pops up and vanishes, and there is
no moment where the user is left wondering whether the command did anything.
The loading page's status line is updated live (memory -> backend -> ready) via
evaluate_js, instead of sitting on one static sentence for the whole wait - the
point is a user watching the window can tell what stage it's stuck on, without
needing to go read a terminal or a log file to find out.
Falls back to the default browser if pywebview/WebView2 is unavailable, staying
alive so the launcher doesn't tear the services down underneath it.
"""
import os
import sys
import time
import urllib.request
URL = "http://localhost:8000"
MEMORY_URL = "http://localhost:8001/"
ICON_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "NexusOS.ico")
# Inline so it renders with no server and no asset files - the whole point is
# that it shows before anything is listening. Colours match the app's dark UI so
# the swap to the real page is not a flash of a different-looking window.
# the swap to the real page is not a flash of a different-looking window. The
# <p id> is the hook _set_status() rewrites as boot moves through its stages.
LOADING_HTML = """<!doctype html>
<html><head><meta charset="utf-8"><title>NexusOS</title><style>
html,body{height:100%;margin:0}
@@ -38,27 +46,57 @@ LOADING_HTML = """<!doctype html>
</style></head><body>
<div class="box">
<div class="ring"></div>
<h1>Loading Nexus core services</h1>
<p>Starting the memory service and backend...</p>
<h1>Starting NexusOS</h1>
<p id="status">Starting memory service...</p>
</div>
</body></html>"""
FAILED_HTML = LOADING_HTML.replace(
"<div class=\"ring\"></div>", ""
).replace(
"Loading Nexus core services", "Backend did not start"
"Starting NexusOS", "Backend did not start"
).replace(
"Starting the memory service and backend...",
"Nothing answered on :8000 after 40s. Check: ncp logs -b"
'id="status">Starting memory service...',
'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
)
def _wait_for_backend(timeout: float = 40.0) -> bool:
"""Poll /status until the backend answers, so the window never loads before
the server is up (which shows a localhost error the webview won't retry)."""
def _set_status(win, text: str) -> None:
# Best-effort: the window can be mid-teardown (user closed it while this
# background thread was still polling), and evaluate_js on a dead window
# raises - that's not a reason to kill the polling loop.
try:
win.evaluate_js(f"document.getElementById('status').textContent = {text!r}")
except Exception:
pass
def _http_ok(url: str, timeout: float = 2.0) -> bool:
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status == 200
except Exception:
return False
def _wait_for_backend(win, timeout: float = 40.0) -> bool:
"""Poll memory, then the backend, updating the on-screen status as each
comes up - so the window never loads before the server is up (which shows
a localhost error the webview won't retry), and never sits on one sentence
while actually moving through two separate services."""
status_url = URL.rstrip("/") + "/status"
deadline = time.time() + timeout
memory_ready = _http_ok(MEMORY_URL)
if memory_ready:
_set_status(win, "Starting backend...")
while time.time() < deadline:
if not memory_ready:
memory_ready = _http_ok(MEMORY_URL)
if memory_ready:
_set_status(win, "Starting backend...")
try:
# 5s, not 2: /status probes Ollama, and a wedged Ollama made it
# slower than a 2s ceiling - the backend was up and answering 200
@@ -66,9 +104,11 @@ def _wait_for_backend(timeout: float = 40.0) -> bool:
# The server side is fixed too; this is the margin.
with urllib.request.urlopen(status_url, timeout=5) as r:
if r.status == 200:
_set_status(win, "Opening Nexus...")
return True
except Exception:
time.sleep(1)
pass
time.sleep(0.5)
return False
@@ -91,14 +131,15 @@ def main() -> int:
"""Runs once the GUI loop is up, so the spinner is already on screen
while we wait. load_url replaces the loading page in place - there is
never a second window to close."""
if _wait_for_backend():
if _wait_for_backend(win):
win.load_url(URL)
else:
print("[nexus] backend not reachable on :8000 after 40s.",
file=sys.stderr)
win.load_html(FAILED_HTML)
webview.start(_swap_in_app, window) # blocks until the window is closed
icon = ICON_PATH if os.path.isfile(ICON_PATH) else None
webview.start(_swap_in_app, window, icon=icon) # blocks until the window is closed
return 0
except Exception as e: # no WebView2 runtime / backend failure
return _browser_fallback(f"native window failed ({e})")
+7 -3
View File
@@ -217,12 +217,16 @@ if ($Shadowed) {
# -- Desktop shortcut ----------------------------------------------------------
Write-Step "Creating desktop shortcut"
try {
$Launcher = Join-Path $RepoRoot "launch_nexus.ps1"
# Target the .vbs wrapper, not powershell.exe directly: on Windows 11 with
# Windows Terminal as the default terminal app, -WindowStyle Hidden on a
# console-subsystem process is often ignored and a terminal tab flashes up
# anyway. wscript.exe never allocates a console at all, sidestepping that.
$Launcher = Join-Path $RepoRoot "bin\launch_nexus_hidden.vbs"
$LnkPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "NexusOS.lnk"
$ws = New-Object -ComObject WScript.Shell
$lnk = $ws.CreateShortcut($LnkPath)
$lnk.TargetPath = "powershell.exe"
$lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Launcher`""
$lnk.TargetPath = "$env:WINDIR\System32\wscript.exe"
$lnk.Arguments = "`"$Launcher`""
$lnk.WorkingDirectory = $RepoRoot
$lnk.Description = "Launch NexusOS"
$Ico = Join-Path $RepoRoot "assets\NexusOS.ico"
+65 -52
View File
@@ -155,20 +155,20 @@ function App() {
};
const navItems = [
{ key: "chatbot", label: "💬 Chat" },
{ key: "playbook", label: "📖 Playbooks" },
{ key: "models", label: "🤖 Models", badge: isModelPulling },
{ key: "memory", label: "🧠 Memory" },
{ key: "documents", label: "📄 Documents" },
{ key: "logs", label: "📜 Logs" },
{ key: "settings", label: "⚙️ Settings" },
{ key: "chatbot", icon: "💬", label: "Chat" },
{ key: "playbook", icon: "📖", label: "Playbooks" },
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
{ key: "memory", icon: "🧠", label: "Memory" },
{ key: "documents", icon: "📄", label: "Documents" },
{ key: "logs", icon: "📜", label: "Logs" },
{ key: "settings", icon: "⚙️", label: "Settings" },
];
return (
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
{/* Sidebar */}
<aside style={{
width: "260px",
width: "220px",
flexShrink: 0,
background: "#0a0a0a",
borderRight: "1px solid #333",
@@ -178,18 +178,18 @@ function App() {
}}>
{/* Top cell: brand + nav */}
<div style={{
padding: "1.25rem 1rem 1rem",
padding: "0.75rem 0.6rem 0.65rem",
borderBottom: "1px solid #1f1f1f",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
gap: "0.4rem",
flexShrink: 0,
}}>
<img src="/n small.png" alt="Logo" style={{ width: "48px", height: "48px", objectFit: "contain", borderRadius: "8px" }} />
<h1 style={{ fontSize: "1rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
<img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
<h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
{version && (
<span style={{ fontSize: "0.7rem", color: "#666", marginTop: "-0.5rem", letterSpacing: "0.02em" }}>v{version}</span>
<span style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
)}
{/* Status Indicator */}
@@ -243,13 +243,13 @@ function App() {
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
padding: "0.4rem 0.6rem",
background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15",
color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a",
border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"),
borderRadius: "8px",
borderRadius: "6px",
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
fontSize: "0.8rem",
fontSize: "0.78rem",
opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1,
}}
>
@@ -260,39 +260,46 @@ function App() {
: ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"}
</button>
<nav style={{ display: "flex", flexDirection: "column", gap: "0.4rem", width: "100%", marginTop: "0.25rem" }}>
{navItems.map(item => (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={{
padding: "0.65rem 0.85rem",
background: currentPage === item.key ? "#007acc" : "#161616",
color: "#fff",
border: "1px solid " + (currentPage === item.key ? "#0099ff" : "#2a2a2a"),
borderRadius: "8px",
cursor: "pointer",
fontSize: "0.85rem",
textAlign: "left",
transition: "all 0.15s",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{item.label}
{item.badge && (
<span style={{
width: "8px",
height: "8px",
borderRadius: "50%",
background: "#28a745",
flexShrink: 0,
animation: "pulse 1.2s ease-in-out infinite",
}} />
)}
</button>
))}
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
{navItems.map(item => {
const isActive = currentPage === item.key;
return (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={{
padding: "0.32rem 0.5rem",
background: isActive ? "#1c2733" : "transparent",
color: isActive ? "#fff" : "#bbb",
border: "none",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{item.icon}</span>
<span style={{ flexGrow: 1 }}>{item.label}</span>
{item.badge && (
<span style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: "#28a745",
flexShrink: 0,
animation: "pulse 1.2s ease-in-out infinite",
}} />
)}
</button>
);
})}
</nav>
</div>
@@ -302,7 +309,7 @@ function App() {
minHeight: 0,
display: "flex",
flexDirection: "column",
padding: "0.75rem 0.75rem 0.75rem",
padding: "0.6rem 0.6rem 0.6rem",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem", flexShrink: 0 }}>
<span style={{ fontSize: "0.75rem", color: "#888", textTransform: "uppercase", letterSpacing: "0.05em" }}>Chats</span>
@@ -470,13 +477,19 @@ function App() {
}}>
<Models onPullStateChange={setIsModelPulling} />
</div>
{currentPage === "chatbot" && (
{/* Chatbot stays mounted so an in-flight reply survives page navigation */}
<div style={{
display: currentPage === "chatbot" ? "flex" : "none",
flexDirection: "column",
flexGrow: 1,
minHeight: 0,
}}>
<Chatbot
conversationId={activeConversationId}
setConversationId={setActiveConversationId}
onConversationChanged={() => loadConversations(search)}
/>
)}
</div>
{currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />}
{currentPage === "documents" && <Documents />}
+40 -1
View File
@@ -10,6 +10,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const [modelList, setModelList] = useState([]);
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
const [autoModel, setAutoModel] = useState(null);
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
const [showPicker, setShowPicker] = useState(false);
const [copiedIdx, setCopiedIdx] = useState(null);
const [lastStats, setLastStats] = useState(null);
@@ -138,7 +139,10 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
]).then(([models, settings]) => {
if (models?.models) setModelList(models.models);
if (models?.selected) setAutoModel(models.selected);
if (settings) setSelectedModel(settings.model || "");
if (settings) {
setSelectedModel(settings.model || "");
setThink(!!settings.think);
}
}).catch(() => {});
}, []);
@@ -163,6 +167,18 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
} catch { /* persisting the model choice is best-effort */ }
};
const toggleThink = async () => {
const next = !think;
setThink(next);
try {
await fetch(`${API_BASE}/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ think: next }),
});
} catch { /* persisting the toggle is best-effort */ }
};
const startNewChat = () => {
if (abortRef.current) abortRef.current.abort();
setInput("");
@@ -202,6 +218,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
message,
conversation_id: conversationId,
history,
think,
...(imgs && imgs.length ? { images: imgs } : {}),
}),
signal: controller.signal,
@@ -522,6 +539,28 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
</div>
)}
</div>
<button
onClick={toggleThink}
title={think ? "Extended thinking is on — click to turn off" : "Extended thinking is off — click to turn on"}
style={{
fontSize: "0.7rem",
color: think ? "#c4b5fd" : "#888",
background: think ? "#1e1a2e" : "#1a1a1a",
border: "1px solid " + (think ? "#7c3aed" : "#2a2a2a"),
borderRadius: "4px",
padding: "0.15rem 0.5rem",
cursor: "pointer",
letterSpacing: "0.03em",
display: "flex",
alignItems: "center",
gap: "0.3rem",
}}
>
🧠 Think
<span style={{ fontSize: "0.6rem", color: think ? "#8aff8a" : "#555" }}>
{think ? "on" : "off"}
</span>
</button>
{lastStats && (
<span style={{
fontSize: "0.7rem",
+73 -2
View File
@@ -33,6 +33,34 @@ export function Settings() {
const [brandQueue, setBrandQueue] = useState([]);
const [isBranding, setIsBranding] = useState(false);
const [modelList, setModelList] = useState([]);
const [platform, setPlatform] = useState("");
// Vite dev server toggle
const [viteRunning, setViteRunning] = useState(null);
const [viteBusy, setViteBusy] = useState(false);
const [viteError, setViteError] = useState("");
const loadViteStatus = () => {
fetch(`${API_BASE}/frontend/status`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setViteRunning(!!d.running); })
.catch(() => {});
};
const toggleVite = async () => {
setViteBusy(true);
setViteError("");
try {
const action = viteRunning ? "stop" : "start";
const r = await fetch(`${API_BASE}/frontend/${action}`, { method: "POST" });
const d = await r.json().catch(() => null);
if (d && d.status === "error") setViteError(d.detail || "Failed to start Vite");
} catch (e) {
setViteError(e.message || "Failed");
}
setViteBusy(false);
loadViteStatus();
};
useEffect(() => {
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null)
@@ -46,6 +74,13 @@ export function Settings() {
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setIconApps(d.apps || []); })
.catch(() => {});
fetch(`${API_BASE}/status`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setPlatform(d.platform || ""); })
.catch(() => {});
loadViteStatus();
}, []);
const update = (key, value) => setForm(f => ({ ...f, [key]: value }));
@@ -167,7 +202,7 @@ export function Settings() {
const displayQueue = isBranding ? brandQueue : selectedApps;
return (
<div style={{ maxWidth: "720px" }}>
<div style={{ width: "100%" }}>
<h2 style={{ margin: "0 0 1.25rem", fontSize: "1.1rem", color: "#eee" }}>Settings</h2>
{/* Auto model routing */}
@@ -480,7 +515,42 @@ export function Settings() {
</div>
</div>
{/* Icon Branding */}
{/* Vite Dev Server */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Vite Dev Server</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.75rem", color: "#555" }}>
Hot-reload server for editing the frontend directly (interface/web). Not
needed for normal use — this backend already serves the built UI.
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap" }}>
<button
onClick={toggleVite}
disabled={viteBusy || viteRunning === null}
style={{
padding: "0.6rem 1.25rem",
background: viteRunning ? "#2a1a1a" : "#152a15",
color: viteRunning ? "#ff8a80" : "#8aff8a",
border: "1px solid " + (viteRunning ? "#5a2a2a" : "#2a5a2a"),
borderRadius: "8px",
cursor: (viteBusy || viteRunning === null) ? "not-allowed" : "pointer",
fontSize: "0.88rem",
fontWeight: 500,
opacity: (viteBusy || viteRunning === null) ? 0.6 : 1,
}}
>
{viteBusy ? (viteRunning ? "Stopping" : "Starting")
: viteRunning ? " Stop Vite" : " Start Vite"}
</button>
<span style={{ fontSize: "0.82rem", color: viteRunning ? "#8aff8a" : "#666" }}>
{viteRunning === null ? "Checking" : viteRunning ? "Running at :5173" : "Stopped"}
</span>
{viteError && <span style={{ color: "#f44336", fontSize: "0.85rem" }}>{viteError}</span>}
</div>
</div>
{/* Icon Branding — Linux-only: composites icons onto XFCE/GTK app tiles,
which don't exist on Windows/macOS. */}
{platform === "linux" && (
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>App Icon Branding</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
@@ -638,6 +708,7 @@ export function Settings() {
)}
</div>
</div>
)}
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
+32 -24
View File
@@ -1,16 +1,21 @@
# NexusOS launcher for Windows (native, no Vite).
#
# Single-process app: the backend on :8000 serves the built web UI itself, so
# this starts only the memory service + backend, then opens the UI as an Edge
# app window. The AI (Ollama) does NOT auto-start - turn it on from the UI's
# Start AI button. Closing the app window stops the services.
# this starts only the memory service + backend, opening a native app window
# (bin\nexus_window.py, pywebview/WebView2) immediately alongside them with its
# own live-updating loading screen. The AI (Ollama) does NOT auto-start - turn
# it on from the UI's Start AI button. Closing the app window stops the
# services.
#
# Right-click -> Run with PowerShell (or: powershell -File launch_nexus.ps1)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest's progress bar adds real latency for no benefit here
$Root = $PSScriptRoot
$Py = Join-Path $Root "Promethean\Scripts\python.exe"
if (-not (Test-Path $Py)) { $Py = "python" } # fall back to PATH
$PyW = Join-Path $Root "Promethean\Scripts\pythonw.exe"
if (-not (Test-Path $PyW)) { $PyW = "pythonw" } # fall back to PATH
$Runtime = Join-Path $Root "runtime"
$Logs = Join-Path $Runtime "logs"
@@ -22,38 +27,41 @@ function Start-Svc($log, $argList) {
-RedirectStandardOutput $log -RedirectStandardError "$log.err"
}
function Test-ServiceUp($url) {
try {
$r = Invoke-WebRequest -Uri $url -TimeoutSec 2 -UseBasicParsing
return $r.StatusCode -eq 200
} catch { return $false }
}
Write-Host "Starting NexusOS..." -ForegroundColor Cyan
# Only start a service if its port is free. If it's already listening (a prior
# launch, or started by hand) reuse it instead of spawning a duplicate that
# fails to bind and stalls the readiness wait below.
# Only reuse a port if something actually answers there. A bare port-listen
# check isn't enough: a wedged process left over from a prior crashed launch
# can be squatting the port without ever responding, and treating that as
# "already running" skips starting a real service - the window then sits on
# its loading screen for the full 40s with no way to tell why.
$memory = $null
if (-not (Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue)) {
if (-not (Test-ServiceUp "http://127.0.0.1:8001/")) {
$memory = Start-Svc (Join-Path $Runtime "memory.log") @("-m","uvicorn","synapse.memory.service:app","--host","127.0.0.1","--port","8001")
}
$backend = $null
if (-not (Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue)) {
if (-not (Test-ServiceUp "http://127.0.0.1:8000/status")) {
$backend = Start-Svc (Join-Path $Runtime "backend.log") @("-m","uvicorn","synapse.main:sio_app","--host","127.0.0.1","--port","8000")
}
# Wait for the backend to answer on :8000.
$ok = $false
foreach ($i in 1..30) {
try {
Invoke-WebRequest -UseBasicParsing -Uri "http://localhost:8000/status" -TimeoutSec 2 | Out-Null
$ok = $true; break
} catch { Start-Sleep -Seconds 1 }
}
if (-not $ok) {
Write-Host "Backend did not come up - check runtime\backend.log" -ForegroundColor Red
}
# Open the UI in a native window (pywebview / WebView2) - no browser, no Edge
# profile cold-start. bin\nexus_window.py blocks until the window is closed and
# falls back to the default browser if WebView2 is unavailable.
$app = Start-Process -FilePath $Py -ArgumentList @((Join-Path $Root "bin\nexus_window.py")) `
# profile cold-start - IMMEDIATELY, in parallel with memory/backend still
# coming up. bin\nexus_window.py opens on its own loading page and polls
# :8001 then :8000 itself, updating that page's status line live as each
# comes up, so the window is on screen from the first instant instead of
# this script blocking silently (in a hidden window) for up to 30s first and
# only then showing anything. It blocks until the window is closed and falls
# back to the default browser if WebView2 is unavailable.
$WindowScript = Join-Path $Root "bin\nexus_window.py"
$app = Start-Process -FilePath $PyW -ArgumentList "`"$WindowScript`"" `
-WorkingDirectory $Root -PassThru
Write-Host "NexusOS is running at http://localhost:8000 (AI is off - start it in the UI)" -ForegroundColor Green
Write-Host "NexusOS window opened; services are starting at http://localhost:8000 (AI is off - start it in the UI)" -ForegroundColor Green
# Block until the app window (or a service we started) exits, so this process
# stays alive to hold and later stop the services. No blind Read-Host: the
# shortcut runs hidden, where a prompt no one can answer would hang forever.
+31 -14
View File
@@ -202,6 +202,14 @@ def check(svc: Service) -> bool:
return False
# Poll granularity for both waiters below. 1s steps used to mean every
# start/stop paid up to a full second of dead latency per service on top of
# however long the process actually took - three services in sequence could
# lose several seconds to nothing but sleep(). 0.25s still amounts to one
# cheap local HTTP HEAD every quarter second, not a busy-loop.
_POLL_STEP = 0.25
def wait_for_port(svc: Service, timeout: int = 30) -> bool:
if http_ok(svc.url):
# "READY", not "already running": `ncp start` launches memory and backend
@@ -210,8 +218,8 @@ def wait_for_port(svc: Service, timeout: int = 30) -> bool:
# this same command started two seconds ago reads like a stale process.
print(f" {svc.label} READY (:{svc.port})")
return True
for _ in range(timeout):
time.sleep(1)
for _ in range(int(timeout / _POLL_STEP)):
time.sleep(_POLL_STEP)
if http_ok(svc.url):
print(f"{svc.label} STARTED")
return True
@@ -220,10 +228,10 @@ def wait_for_port(svc: Service, timeout: int = 30) -> bool:
def wait_for_port_close(port: int, timeout: int = 15) -> bool:
for _ in range(timeout):
for _ in range(int(timeout / _POLL_STEP)):
if not http_ok(f"http://localhost:{port}/"):
return True
time.sleep(1)
time.sleep(_POLL_STEP)
return False
@@ -353,24 +361,33 @@ def cmd_start(target) -> None:
elif target in ("--ai", "-a"):
start_ollama()
elif target in (None, "", "all"):
# Bring the UI up first, then warm Ollama in the background — the model
# loads concurrently and into the first chat instead of blocking boot.
# Bring the UI up first, then kick off Ollama and (on Linux) Vite in the
# background without waiting on either — neither gates the app being
# usable. The backend already serves the built interface/web/dist at
# :8000 on both platforms (single-process design), and that's the URL
# nexus-app.sh/ncp web actually opens; nothing points a user at :5173.
# Vite only exists for whoever is hot-reload-editing the frontend, and
# they'll open :5173 themselves once it's ready - polling for it here
# just delayed "boot done" for a benefit nobody in the critical path
# gets. Skipped outright on Windows, where it's not part of the normal
# workflow at all and is the slow part of `ncp stop` to boot (npm's
# cmd.exe -> node -> esbuild tree doesn't die from a plain terminate()
# and falls through to the ~30s force-kill path). Still available on
# demand via `ncp start --frontend`.
t0 = time.perf_counter()
launch(SERVICES["memory"])
launch(SERVICES["backend"])
wait_for_port(SERVICES["memory"])
wait_for_port(SERVICES["backend"])
t_services = time.perf_counter()
launch(SERVICES["frontend"])
wait_for_port(SERVICES["frontend"])
t_frontend = time.perf_counter()
start_ollama(background=True)
t_ollama = time.perf_counter()
if not WINDOWS:
launch(SERVICES["frontend"])
t_bg = time.perf_counter()
print("\nBoot timing:")
print(f" services (memory+backend) : {t_services - t0:5.1f}s")
print(f" frontend (UI ready) : {t_frontend - t_services:5.1f}s")
print(f" ollama kickoff (bg warm) : {t_ollama - t_frontend:5.1f}s")
print(f" total to interactive : {t_ollama - t0:5.1f}s")
print(f" services (memory+backend) : {t_services - t0:5.1f}s")
print(f" ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
print(f" total to interactive : {t_bg - t0:5.1f}s")
else:
show_help()
+109
View File
@@ -0,0 +1,109 @@
"""Start/stop the Vite dev server from the web UI.
Dev-only convenience: production serves the built interface/web/dist from this
same backend, but hot-reload editing of the frontend needs Vite running
separately. Shares management/ncp.py's PID file (runtime/pids/frontend.pid)
and process patterns so `ncp status` / `ncp stop --frontend` see the same
process regardless of which side started it.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import urllib.request
import psutil
from .nexus_config import PROJECT_ROOT, RUNTIME_DIR
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
PID_FILE = RUNTIME_DIR / "pids" / "frontend.pid"
LOG_FILE = RUNTIME_DIR / "frontend.log"
PORT = 5173
def _npm() -> str | None:
return shutil.which("npm.cmd" if os.name == "nt" else "npm")
def _read_pid() -> int | None:
try:
return int(PID_FILE.read_text().strip())
except (OSError, ValueError):
return None
def _is_ours(pid: int) -> bool:
# On Windows, npm.cmd can't run as a CreateProcess image directly, so the
# OS wraps it as `cmd.exe /c ...npm.cmd run dev ...` - the ".cmd" splits
# "npm" from "run dev", so a literal "npm run dev" substring never matches
# the tracked PID's own cmdline. Loosen to "vite" (matches once the tree
# gets that far) or "npm"+"dev" both present (matches the wrapper hop too).
try:
cmd = " ".join(psutil.Process(pid).cmdline())
except Exception:
return False
return "vite" in cmd or ("npm" in cmd and "dev" in cmd)
def _http_up() -> bool:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1.5) as r:
return r.status == 200
except Exception:
return False
def is_running() -> bool:
"""Mirrors ncp.py's own status check: PID liveness first, then whether the
port actually answers (covers Vite started by another process, or a lost
PID file) - not the stricter pattern match `stop()` uses before killing."""
pid = _read_pid()
if pid is not None and psutil.pid_exists(pid):
return True
return _http_up()
def start() -> dict:
if is_running():
return {"status": "already_running"}
npm = _npm()
if not npm:
return {"status": "error", "detail": "npm not found - install Node.js"}
argv = [npm, "run", "dev", "--", "--host", "0.0.0.0"]
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
LOG_FILE.write_text("")
# CREATE_NO_WINDOW (not DETACHED_PROCESS): a detached child has no console
# of its own, so Windows hands one to any console program it spawns in
# turn - npm.cmd -> node -> vite would each flash a window. CREATE_NO_WINDOW
# gives it a console that's never shown, inherited down the chain.
kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
| getattr(subprocess, "CREATE_NO_WINDOW", 0)}
if os.name == "nt" else {"start_new_session": True})
with open(LOG_FILE, "ab") as log:
proc = subprocess.Popen(argv, cwd=str(FRONTEND_DIR), stdout=log,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
**kwargs)
PID_FILE.write_text(str(proc.pid))
return {"status": "started", "pid": proc.pid}
def stop() -> dict:
pid = _read_pid()
if pid is not None and psutil.pid_exists(pid) and _is_ours(pid):
try:
proc = psutil.Process(pid)
# npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first
# so the parent doesn't outlive its children as an orphaned shell.
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
except Exception:
pass
PID_FILE.unlink(missing_ok=True)
return {"status": "stopped"}
+28 -1
View File
@@ -10,6 +10,7 @@ from uuid import UUID
import httpx
import os as _os
import platform as _platform
from pathlib import Path
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
@@ -19,6 +20,7 @@ from .nexus_config import settings, VERSION, DEFAULT_CHAT_MODEL
from .chat import generate_chat_response, stream_chat_response, _synapse_trace
from . import chat as _chat
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from . import frontend_manager as _frontend_manager
from .playbook_manager import PlaybookManager
from . import tools as _tools
@@ -226,7 +228,7 @@ async def root():
if (ollama is not None and hasattr(ollama, "get_status")) else None)
except Exception:
status = None
return {"status": "online", "version": VERSION, "ollama": status}
return {"status": "online", "version": VERSION, "ollama": status, "platform": _platform.system().lower()}
# -------------------------
@@ -788,6 +790,31 @@ async def ollama_stop_endpoint():
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Vite Dev Server (Settings toggle) - dev-only convenience, not part of the
# single-process production path. Runs via a thread so npm's own startup time
# doesn't block the event loop, matching the Ollama start/stop pattern above.
# -------------------------
@app.get("/frontend/status")
async def frontend_status_endpoint():
return {"running": await _asyncio.to_thread(_frontend_manager.is_running)}
@app.post("/frontend/start")
async def frontend_start_endpoint():
try:
return await _asyncio.to_thread(_frontend_manager.start)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/frontend/stop")
async def frontend_stop_endpoint():
try:
return await _asyncio.to_thread(_frontend_manager.stop)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Playbooks List (existing)
# -------------------------