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.
+
---
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 (
@@ -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)
# -------------------------