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
+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)
# -------------------------