From cc20ceac6424fd1e8df0c797991e92e8cd828a99 Mon Sep 17 00:00:00 2001 From: Jon Wingender Date: Tue, 28 Jul 2026 11:23:35 -0500 Subject: [PATCH] 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. --- README.md | 37 +++++++++++ bin/launch_nexus_hidden.vbs | 15 +++++ bin/nexus_window.py | 65 ++++++++++++++---- install-windows.ps1 | 10 ++- interface/web/src/App.jsx | 117 ++++++++++++++++++--------------- interface/web/src/Chatbot.jsx | 41 +++++++++++- interface/web/src/Settings.jsx | 75 ++++++++++++++++++++- launch_nexus.ps1 | 56 +++++++++------- management/ncp.py | 45 +++++++++---- synapse/frontend_manager.py | 109 ++++++++++++++++++++++++++++++ synapse/main.py | 29 +++++++- 11 files changed, 490 insertions(+), 109 deletions(-) create mode 100644 bin/launch_nexus_hidden.vbs create mode 100644 synapse/frontend_manager.py diff --git a/README.md b/README.md index 49b70d4..6bcad02 100644 --- a/README.md +++ b/README.md @@ -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. + ---
NexusOS · local AI, self-hosted on GitNexus
diff --git a/bin/launch_nexus_hidden.vbs b/bin/launch_nexus_hidden.vbs new file mode 100644 index 0000000..bc64780 --- /dev/null +++ b/bin/launch_nexus_hidden.vbs @@ -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 diff --git a/bin/nexus_window.py b/bin/nexus_window.py index 9769549..9ad520f 100644 --- a/bin/nexus_window.py +++ b/bin/nexus_window.py @@ -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 +#

is the hook _set_status() rewrites as boot moves through its stages. LOADING_HTML = """ NexusOS

-

Loading Nexus core services

-

Starting the memory service and backend...

+

Starting NexusOS

+

Starting memory service...

""" FAILED_HTML = LOADING_HTML.replace( "
", "" ).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})") diff --git a/install-windows.ps1 b/install-windows.ps1 index f524f77..d96f7da 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -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" diff --git a/interface/web/src/App.jsx b/interface/web/src/App.jsx index 25996d7..082c16b 100644 --- a/interface/web/src/App.jsx +++ b/interface/web/src/App.jsx @@ -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 (
{/* Sidebar */}
)} + {lastStats && ( { + 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 ( -
+

Settings

{/* Auto model routing */} @@ -480,7 +515,42 @@ export function Settings() {
- {/* Icon Branding */} + {/* Vite Dev Server */} +
+

Vite Dev Server

+

+ Hot-reload server for editing the frontend directly (interface/web). Not + needed for normal use — this backend already serves the built UI. +

+
+ + + {viteRunning === null ? "Checking…" : viteRunning ? "Running at :5173" : "Stopped"} + + {viteError && {viteError}} +
+
+ + {/* Icon Branding — Linux-only: composites icons onto XFCE/GTK app tiles, + which don't exist on Windows/macOS. */} + {platform === "linux" && (

App Icon Branding

@@ -638,6 +708,7 @@ export function Settings() { )}

+ )} {/* Actions */}
diff --git a/launch_nexus.ps1 b/launch_nexus.ps1 index 6f9f865..ff0feea 100644 --- a/launch_nexus.ps1 +++ b/launch_nexus.ps1 @@ -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. diff --git a/management/ncp.py b/management/ncp.py index e96835a..6be5bc5 100644 --- a/management/ncp.py +++ b/management/ncp.py @@ -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() diff --git a/synapse/frontend_manager.py b/synapse/frontend_manager.py new file mode 100644 index 0000000..6c55299 --- /dev/null +++ b/synapse/frontend_manager.py @@ -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"} diff --git a/synapse/main.py b/synapse/main.py index 4e8577c..9472a19 100644 --- a/synapse/main.py +++ b/synapse/main.py @@ -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) # -------------------------