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` | | Frontend API base URL | `interface/web/src/config.js` |
| Python deps | `requirements-base.txt` + amd/nvidia GPU overlay; `requirements-windows.txt` = standalone CPU runtime | | 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> <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 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 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. 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 Falls back to the default browser if pywebview/WebView2 is unavailable, staying
alive so the launcher doesn't tear the services down underneath it. alive so the launcher doesn't tear the services down underneath it.
""" """
import os
import sys import sys
import time import time
import urllib.request import urllib.request
URL = "http://localhost:8000" 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 # 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 # 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> LOADING_HTML = """<!doctype html>
<html><head><meta charset="utf-8"><title>NexusOS</title><style> <html><head><meta charset="utf-8"><title>NexusOS</title><style>
html,body{height:100%;margin:0} html,body{height:100%;margin:0}
@@ -38,27 +46,57 @@ LOADING_HTML = """<!doctype html>
</style></head><body> </style></head><body>
<div class="box"> <div class="box">
<div class="ring"></div> <div class="ring"></div>
<h1>Loading Nexus core services</h1> <h1>Starting NexusOS</h1>
<p>Starting the memory service and backend...</p> <p id="status">Starting memory service...</p>
</div> </div>
</body></html>""" </body></html>"""
FAILED_HTML = LOADING_HTML.replace( FAILED_HTML = LOADING_HTML.replace(
"<div class=\"ring\"></div>", "" "<div class=\"ring\"></div>", ""
).replace( ).replace(
"Loading Nexus core services", "Backend did not start" "Starting NexusOS", "Backend did not start"
).replace( ).replace(
"Starting the memory service and backend...", 'id="status">Starting memory service...',
"Nothing answered on :8000 after 40s. Check: ncp logs -b" 'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
) )
def _wait_for_backend(timeout: float = 40.0) -> bool: def _set_status(win, text: str) -> None:
"""Poll /status until the backend answers, so the window never loads before # Best-effort: the window can be mid-teardown (user closed it while this
the server is up (which shows a localhost error the webview won't retry).""" # 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" status_url = URL.rstrip("/") + "/status"
deadline = time.time() + timeout deadline = time.time() + timeout
memory_ready = _http_ok(MEMORY_URL)
if memory_ready:
_set_status(win, "Starting backend...")
while time.time() < deadline: while time.time() < deadline:
if not memory_ready:
memory_ready = _http_ok(MEMORY_URL)
if memory_ready:
_set_status(win, "Starting backend...")
try: try:
# 5s, not 2: /status probes Ollama, and a wedged Ollama made it # 5s, not 2: /status probes Ollama, and a wedged Ollama made it
# slower than a 2s ceiling - the backend was up and answering 200 # 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. # The server side is fixed too; this is the margin.
with urllib.request.urlopen(status_url, timeout=5) as r: with urllib.request.urlopen(status_url, timeout=5) as r:
if r.status == 200: if r.status == 200:
_set_status(win, "Opening Nexus...")
return True return True
except Exception: except Exception:
time.sleep(1) pass
time.sleep(0.5)
return False return False
@@ -91,14 +131,15 @@ def main() -> int:
"""Runs once the GUI loop is up, so the spinner is already on screen """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 while we wait. load_url replaces the loading page in place - there is
never a second window to close.""" never a second window to close."""
if _wait_for_backend(): if _wait_for_backend(win):
win.load_url(URL) win.load_url(URL)
else: else:
print("[nexus] backend not reachable on :8000 after 40s.", print("[nexus] backend not reachable on :8000 after 40s.",
file=sys.stderr) file=sys.stderr)
win.load_html(FAILED_HTML) 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 return 0
except Exception as e: # no WebView2 runtime / backend failure except Exception as e: # no WebView2 runtime / backend failure
return _browser_fallback(f"native window failed ({e})") return _browser_fallback(f"native window failed ({e})")
+7 -3
View File
@@ -217,12 +217,16 @@ if ($Shadowed) {
# -- Desktop shortcut ---------------------------------------------------------- # -- Desktop shortcut ----------------------------------------------------------
Write-Step "Creating desktop shortcut" Write-Step "Creating desktop shortcut"
try { 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" $LnkPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "NexusOS.lnk"
$ws = New-Object -ComObject WScript.Shell $ws = New-Object -ComObject WScript.Shell
$lnk = $ws.CreateShortcut($LnkPath) $lnk = $ws.CreateShortcut($LnkPath)
$lnk.TargetPath = "powershell.exe" $lnk.TargetPath = "$env:WINDIR\System32\wscript.exe"
$lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Launcher`"" $lnk.Arguments = "`"$Launcher`""
$lnk.WorkingDirectory = $RepoRoot $lnk.WorkingDirectory = $RepoRoot
$lnk.Description = "Launch NexusOS" $lnk.Description = "Launch NexusOS"
$Ico = Join-Path $RepoRoot "assets\NexusOS.ico" $Ico = Join-Path $RepoRoot "assets\NexusOS.ico"
+46 -33
View File
@@ -155,20 +155,20 @@ function App() {
}; };
const navItems = [ const navItems = [
{ key: "chatbot", label: "💬 Chat" }, { key: "chatbot", icon: "💬", label: "Chat" },
{ key: "playbook", label: "📖 Playbooks" }, { key: "playbook", icon: "📖", label: "Playbooks" },
{ key: "models", label: "🤖 Models", badge: isModelPulling }, { key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
{ key: "memory", label: "🧠 Memory" }, { key: "memory", icon: "🧠", label: "Memory" },
{ key: "documents", label: "📄 Documents" }, { key: "documents", icon: "📄", label: "Documents" },
{ key: "logs", label: "📜 Logs" }, { key: "logs", icon: "📜", label: "Logs" },
{ key: "settings", label: "⚙️ Settings" }, { key: "settings", icon: "⚙️", label: "Settings" },
]; ];
return ( return (
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}> <div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
{/* Sidebar */} {/* Sidebar */}
<aside style={{ <aside style={{
width: "260px", width: "220px",
flexShrink: 0, flexShrink: 0,
background: "#0a0a0a", background: "#0a0a0a",
borderRight: "1px solid #333", borderRight: "1px solid #333",
@@ -178,18 +178,18 @@ function App() {
}}> }}>
{/* Top cell: brand + nav */} {/* Top cell: brand + nav */}
<div style={{ <div style={{
padding: "1.25rem 1rem 1rem", padding: "0.75rem 0.6rem 0.65rem",
borderBottom: "1px solid #1f1f1f", borderBottom: "1px solid #1f1f1f",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
gap: "0.75rem", gap: "0.4rem",
flexShrink: 0, flexShrink: 0,
}}> }}>
<img src="/n small.png" alt="Logo" style={{ width: "48px", height: "48px", objectFit: "contain", borderRadius: "8px" }} /> <img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
<h1 style={{ fontSize: "1rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1> <h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
{version && ( {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 */} {/* Status Indicator */}
@@ -243,13 +243,13 @@ function App() {
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"} title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"}
style={{ style={{
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.4rem 0.6rem",
background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15", background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15",
color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a", color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a",
border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"), border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"),
borderRadius: "8px", borderRadius: "6px",
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer", cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
fontSize: "0.8rem", fontSize: "0.78rem",
opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1, opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1,
}} }}
> >
@@ -260,31 +260,37 @@ function App() {
: ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"} : ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"}
</button> </button>
<nav style={{ display: "flex", flexDirection: "column", gap: "0.4rem", width: "100%", marginTop: "0.25rem" }}> <nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
{navItems.map(item => ( {navItems.map(item => {
const isActive = currentPage === item.key;
return (
<button <button
key={item.key} key={item.key}
onClick={() => setCurrentPage(item.key)} onClick={() => setCurrentPage(item.key)}
style={{ style={{
padding: "0.65rem 0.85rem", padding: "0.32rem 0.5rem",
background: currentPage === item.key ? "#007acc" : "#161616", background: isActive ? "#1c2733" : "transparent",
color: "#fff", color: isActive ? "#fff" : "#bbb",
border: "1px solid " + (currentPage === item.key ? "#0099ff" : "#2a2a2a"), border: "none",
borderRadius: "8px", borderRadius: "5px",
cursor: "pointer", cursor: "pointer",
fontSize: "0.85rem", fontSize: "0.8rem",
textAlign: "left", textAlign: "left",
transition: "all 0.15s", transition: "background 0.12s, color 0.12s",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", gap: "0.45rem",
width: "100%",
}} }}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
> >
{item.label} <span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{item.icon}</span>
<span style={{ flexGrow: 1 }}>{item.label}</span>
{item.badge && ( {item.badge && (
<span style={{ <span style={{
width: "8px", width: "6px",
height: "8px", height: "6px",
borderRadius: "50%", borderRadius: "50%",
background: "#28a745", background: "#28a745",
flexShrink: 0, flexShrink: 0,
@@ -292,7 +298,8 @@ function App() {
}} /> }} />
)} )}
</button> </button>
))} );
})}
</nav> </nav>
</div> </div>
@@ -302,7 +309,7 @@ function App() {
minHeight: 0, minHeight: 0,
display: "flex", display: "flex",
flexDirection: "column", 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 }}> <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> <span style={{ fontSize: "0.75rem", color: "#888", textTransform: "uppercase", letterSpacing: "0.05em" }}>Chats</span>
@@ -470,13 +477,19 @@ function App() {
}}> }}>
<Models onPullStateChange={setIsModelPulling} /> <Models onPullStateChange={setIsModelPulling} />
</div> </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 <Chatbot
conversationId={activeConversationId} conversationId={activeConversationId}
setConversationId={setActiveConversationId} setConversationId={setActiveConversationId}
onConversationChanged={() => loadConversations(search)} onConversationChanged={() => loadConversations(search)}
/> />
)} </div>
{currentPage === "playbook" && <Playbook />} {currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />} {currentPage === "memory" && <Memory />}
{currentPage === "documents" && <Documents />} {currentPage === "documents" && <Documents />}
+40 -1
View File
@@ -10,6 +10,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const [modelList, setModelList] = useState([]); const [modelList, setModelList] = useState([]);
const [selectedModel, setSelectedModel] = useState(""); // "" = auto const [selectedModel, setSelectedModel] = useState(""); // "" = auto
const [autoModel, setAutoModel] = useState(null); const [autoModel, setAutoModel] = useState(null);
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const [copiedIdx, setCopiedIdx] = useState(null); const [copiedIdx, setCopiedIdx] = useState(null);
const [lastStats, setLastStats] = useState(null); const [lastStats, setLastStats] = useState(null);
@@ -138,7 +139,10 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
]).then(([models, settings]) => { ]).then(([models, settings]) => {
if (models?.models) setModelList(models.models); if (models?.models) setModelList(models.models);
if (models?.selected) setAutoModel(models.selected); if (models?.selected) setAutoModel(models.selected);
if (settings) setSelectedModel(settings.model || ""); if (settings) {
setSelectedModel(settings.model || "");
setThink(!!settings.think);
}
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
@@ -163,6 +167,18 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
} catch { /* persisting the model choice is best-effort */ } } 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 = () => { const startNewChat = () => {
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
setInput(""); setInput("");
@@ -202,6 +218,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
message, message,
conversation_id: conversationId, conversation_id: conversationId,
history, history,
think,
...(imgs && imgs.length ? { images: imgs } : {}), ...(imgs && imgs.length ? { images: imgs } : {}),
}), }),
signal: controller.signal, signal: controller.signal,
@@ -522,6 +539,28 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
</div> </div>
)} )}
</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 && ( {lastStats && (
<span style={{ <span style={{
fontSize: "0.7rem", fontSize: "0.7rem",
+73 -2
View File
@@ -33,6 +33,34 @@ export function Settings() {
const [brandQueue, setBrandQueue] = useState([]); const [brandQueue, setBrandQueue] = useState([]);
const [isBranding, setIsBranding] = useState(false); const [isBranding, setIsBranding] = useState(false);
const [modelList, setModelList] = useState([]); 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(() => { useEffect(() => {
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null) 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(r => r.ok ? r.json() : null)
.then(d => { if (d) setIconApps(d.apps || []); }) .then(d => { if (d) setIconApps(d.apps || []); })
.catch(() => {}); .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 })); const update = (key, value) => setForm(f => ({ ...f, [key]: value }));
@@ -167,7 +202,7 @@ export function Settings() {
const displayQueue = isBranding ? brandQueue : selectedApps; const displayQueue = isBranding ? brandQueue : selectedApps;
return ( return (
<div style={{ maxWidth: "720px" }}> <div style={{ width: "100%" }}>
<h2 style={{ margin: "0 0 1.25rem", fontSize: "1.1rem", color: "#eee" }}>Settings</h2> <h2 style={{ margin: "0 0 1.25rem", fontSize: "1.1rem", color: "#eee" }}>Settings</h2>
{/* Auto model routing */} {/* Auto model routing */}
@@ -480,7 +515,42 @@ export function Settings() {
</div> </div>
</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}> <div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>App Icon Branding</h3> <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" }}> <p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
@@ -638,6 +708,7 @@ export function Settings() {
)} )}
</div> </div>
</div> </div>
)}
{/* Actions */} {/* Actions */}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}> <div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
+32 -24
View File
@@ -1,16 +1,21 @@
# NexusOS launcher for Windows (native, no Vite). # NexusOS launcher for Windows (native, no Vite).
# #
# Single-process app: the backend on :8000 serves the built web UI itself, so # 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 # this starts only the memory service + backend, opening a native app window
# app window. The AI (Ollama) does NOT auto-start - turn it on from the UI's # (bin\nexus_window.py, pywebview/WebView2) immediately alongside them with its
# Start AI button. Closing the app window stops the services. # 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) # Right-click -> Run with PowerShell (or: powershell -File launch_nexus.ps1)
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest's progress bar adds real latency for no benefit here
$Root = $PSScriptRoot $Root = $PSScriptRoot
$Py = Join-Path $Root "Promethean\Scripts\python.exe" $Py = Join-Path $Root "Promethean\Scripts\python.exe"
if (-not (Test-Path $Py)) { $Py = "python" } # fall back to PATH 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" $Runtime = Join-Path $Root "runtime"
$Logs = Join-Path $Runtime "logs" $Logs = Join-Path $Runtime "logs"
@@ -22,38 +27,41 @@ function Start-Svc($log, $argList) {
-RedirectStandardOutput $log -RedirectStandardError "$log.err" -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 Write-Host "Starting NexusOS..." -ForegroundColor Cyan
# Only start a service if its port is free. If it's already listening (a prior # Only reuse a port if something actually answers there. A bare port-listen
# launch, or started by hand) reuse it instead of spawning a duplicate that # check isn't enough: a wedged process left over from a prior crashed launch
# fails to bind and stalls the readiness wait below. # 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 $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") $memory = Start-Svc (Join-Path $Runtime "memory.log") @("-m","uvicorn","synapse.memory.service:app","--host","127.0.0.1","--port","8001")
} }
$backend = $null $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") $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 # 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 # profile cold-start - IMMEDIATELY, in parallel with memory/backend still
# falls back to the default browser if WebView2 is unavailable. # coming up. bin\nexus_window.py opens on its own loading page and polls
$app = Start-Process -FilePath $Py -ArgumentList @((Join-Path $Root "bin\nexus_window.py")) ` # :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 -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 # 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 # 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. # shortcut runs hidden, where a prompt no one can answer would hang forever.
+30 -13
View File
@@ -202,6 +202,14 @@ def check(svc: Service) -> bool:
return False 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: def wait_for_port(svc: Service, timeout: int = 30) -> bool:
if http_ok(svc.url): if http_ok(svc.url):
# "READY", not "already running": `ncp start` launches memory and backend # "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. # this same command started two seconds ago reads like a stale process.
print(f" {svc.label} READY (:{svc.port})") print(f" {svc.label} READY (:{svc.port})")
return True return True
for _ in range(timeout): for _ in range(int(timeout / _POLL_STEP)):
time.sleep(1) time.sleep(_POLL_STEP)
if http_ok(svc.url): if http_ok(svc.url):
print(f"{svc.label} STARTED") print(f"{svc.label} STARTED")
return True 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: 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}/"): if not http_ok(f"http://localhost:{port}/"):
return True return True
time.sleep(1) time.sleep(_POLL_STEP)
return False return False
@@ -353,24 +361,33 @@ def cmd_start(target) -> None:
elif target in ("--ai", "-a"): elif target in ("--ai", "-a"):
start_ollama() start_ollama()
elif target in (None, "", "all"): elif target in (None, "", "all"):
# Bring the UI up first, then warm Ollama in the background — the model # Bring the UI up first, then kick off Ollama and (on Linux) Vite in the
# loads concurrently and into the first chat instead of blocking boot. # 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() t0 = time.perf_counter()
launch(SERVICES["memory"]) launch(SERVICES["memory"])
launch(SERVICES["backend"]) launch(SERVICES["backend"])
wait_for_port(SERVICES["memory"]) wait_for_port(SERVICES["memory"])
wait_for_port(SERVICES["backend"]) wait_for_port(SERVICES["backend"])
t_services = time.perf_counter() t_services = time.perf_counter()
launch(SERVICES["frontend"])
wait_for_port(SERVICES["frontend"])
t_frontend = time.perf_counter()
start_ollama(background=True) start_ollama(background=True)
t_ollama = time.perf_counter() if not WINDOWS:
launch(SERVICES["frontend"])
t_bg = time.perf_counter()
print("\nBoot timing:") print("\nBoot timing:")
print(f" services (memory+backend) : {t_services - t0:5.1f}s") 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 + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
print(f" ollama kickoff (bg warm) : {t_ollama - t_frontend:5.1f}s") print(f" total to interactive : {t_bg - t0:5.1f}s")
print(f" total to interactive : {t_ollama - t0:5.1f}s")
else: else:
show_help() 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 httpx
import os as _os import os as _os
import platform as _platform
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Body from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware 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 .chat import generate_chat_response, stream_chat_response, _synapse_trace
from . import chat as _chat from . import chat as _chat
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager 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 .playbook_manager import PlaybookManager
from . import tools as _tools from . import tools as _tools
@@ -226,7 +228,7 @@ async def root():
if (ollama is not None and hasattr(ollama, "get_status")) else None) if (ollama is not None and hasattr(ollama, "get_status")) else None)
except Exception: except Exception:
status = None 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(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) # Playbooks List (existing)
# ------------------------- # -------------------------