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:
@@ -153,6 +153,43 @@ Promethean/ Python venv (gitignored, built by the installer)
|
||||
| Frontend API base URL | `interface/web/src/config.js` |
|
||||
| Python deps | `requirements-base.txt` + amd/nvidia GPU overlay; `requirements-windows.txt` = standalone CPU runtime |
|
||||
|
||||
## Roadmap
|
||||
|
||||
Rough plan, not promises. Ordered by how soon and how much it touches.
|
||||
|
||||
### Soon
|
||||
- **Inference knobs in Settings** — expose temperature, context length, and
|
||||
`num_gpu` in the UI. The backend already passes options to Ollama; there's no
|
||||
UI for them yet, and small-VRAM boxes need `num_gpu=0` today via env only.
|
||||
- **Chat controls** — stop generation, regenerate last reply, edit-and-resend.
|
||||
History editing exists; live regeneration doesn't.
|
||||
- **Per-playbook model** — playbooks set the persona but not the model; let a
|
||||
playbook pin its own chat model (Settings already splits chat vs. memory model).
|
||||
|
||||
### Eventually
|
||||
- **Document ingest / RAG** — the embedding stack (nomic-embed-text,
|
||||
`message_vectors`, cosine recall) already retrieves past conversations. Extend
|
||||
it to uploaded files and notes so chats can cite your own documents.
|
||||
- **Vision chat** — attach images to a message and route to a multimodal Ollama
|
||||
model. The stream path is text-only today.
|
||||
- **Real vector index** — recall does a brute-force cosine scan over JSON blobs
|
||||
in SQLite. Fine at current scale; swap in a proper index (e.g. sqlite-vec)
|
||||
before the DB grows.
|
||||
|
||||
### Far future
|
||||
- **macOS support** — POSIX process control already works; needs a `sync.py`
|
||||
platform fork, ROCm-free requirements, and an installer. Waiting on a Mac.
|
||||
- **Voice I/O** — local speech-to-text in, text-to-speech out.
|
||||
- **Fine-tuning loop** — conversations already export as ShareGPT; close the
|
||||
loop into a local fine-tune. The ML stack was stripped from the runtime venv
|
||||
but the requirements files restore it.
|
||||
- **Tool-using playbooks** — function calling so a playbook can take actions,
|
||||
not just shape the prompt.
|
||||
- **Machine learning & gameplay** — the original goal: an agent that learns to
|
||||
play games alongside me (it started as "teach it to play Lego Star Wars").
|
||||
Screen capture in, controller/input out, trained by playing. The whole reason
|
||||
this project exists.
|
||||
|
||||
---
|
||||
|
||||
<div align="center"><sub>NexusOS · local AI, self-hosted on <a href="https://git.enderofwings.com/enderofwings/NexusOS">GitNexus</a></sub></div>
|
||||
|
||||
@@ -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
@@ -10,19 +10,27 @@ The window opens IMMEDIATELY on a loading page and navigates to the app once the
|
||||
backend answers, rather than waiting up to 40s with nothing on screen and then
|
||||
appearing. One window the whole time: nothing pops up and vanishes, and there is
|
||||
no moment where the user is left wondering whether the command did anything.
|
||||
The loading page's status line is updated live (memory -> backend -> ready) via
|
||||
evaluate_js, instead of sitting on one static sentence for the whole wait - the
|
||||
point is a user watching the window can tell what stage it's stuck on, without
|
||||
needing to go read a terminal or a log file to find out.
|
||||
|
||||
Falls back to the default browser if pywebview/WebView2 is unavailable, staying
|
||||
alive so the launcher doesn't tear the services down underneath it.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
URL = "http://localhost:8000"
|
||||
MEMORY_URL = "http://localhost:8001/"
|
||||
ICON_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "NexusOS.ico")
|
||||
|
||||
# Inline so it renders with no server and no asset files - the whole point is
|
||||
# that it shows before anything is listening. Colours match the app's dark UI so
|
||||
# the swap to the real page is not a flash of a different-looking window.
|
||||
# the swap to the real page is not a flash of a different-looking window. The
|
||||
# <p id> is the hook _set_status() rewrites as boot moves through its stages.
|
||||
LOADING_HTML = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>NexusOS</title><style>
|
||||
html,body{height:100%;margin:0}
|
||||
@@ -38,27 +46,57 @@ LOADING_HTML = """<!doctype html>
|
||||
</style></head><body>
|
||||
<div class="box">
|
||||
<div class="ring"></div>
|
||||
<h1>Loading Nexus core services</h1>
|
||||
<p>Starting the memory service and backend...</p>
|
||||
<h1>Starting NexusOS</h1>
|
||||
<p id="status">Starting memory service...</p>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
FAILED_HTML = LOADING_HTML.replace(
|
||||
"<div class=\"ring\"></div>", ""
|
||||
).replace(
|
||||
"Loading Nexus core services", "Backend did not start"
|
||||
"Starting NexusOS", "Backend did not start"
|
||||
).replace(
|
||||
"Starting the memory service and backend...",
|
||||
"Nothing answered on :8000 after 40s. Check: ncp logs -b"
|
||||
'id="status">Starting memory service...',
|
||||
'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_backend(timeout: float = 40.0) -> bool:
|
||||
"""Poll /status until the backend answers, so the window never loads before
|
||||
the server is up (which shows a localhost error the webview won't retry)."""
|
||||
def _set_status(win, text: str) -> None:
|
||||
# Best-effort: the window can be mid-teardown (user closed it while this
|
||||
# background thread was still polling), and evaluate_js on a dead window
|
||||
# raises - that's not a reason to kill the polling loop.
|
||||
try:
|
||||
win.evaluate_js(f"document.getElementById('status').textContent = {text!r}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _http_ok(url: str, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for_backend(win, timeout: float = 40.0) -> bool:
|
||||
"""Poll memory, then the backend, updating the on-screen status as each
|
||||
comes up - so the window never loads before the server is up (which shows
|
||||
a localhost error the webview won't retry), and never sits on one sentence
|
||||
while actually moving through two separate services."""
|
||||
status_url = URL.rstrip("/") + "/status"
|
||||
deadline = time.time() + timeout
|
||||
|
||||
memory_ready = _http_ok(MEMORY_URL)
|
||||
if memory_ready:
|
||||
_set_status(win, "Starting backend...")
|
||||
|
||||
while time.time() < deadline:
|
||||
if not memory_ready:
|
||||
memory_ready = _http_ok(MEMORY_URL)
|
||||
if memory_ready:
|
||||
_set_status(win, "Starting backend...")
|
||||
|
||||
try:
|
||||
# 5s, not 2: /status probes Ollama, and a wedged Ollama made it
|
||||
# slower than a 2s ceiling - the backend was up and answering 200
|
||||
@@ -66,9 +104,11 @@ def _wait_for_backend(timeout: float = 40.0) -> bool:
|
||||
# The server side is fixed too; this is the margin.
|
||||
with urllib.request.urlopen(status_url, timeout=5) as r:
|
||||
if r.status == 200:
|
||||
_set_status(win, "Opening Nexus...")
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
@@ -91,14 +131,15 @@ def main() -> int:
|
||||
"""Runs once the GUI loop is up, so the spinner is already on screen
|
||||
while we wait. load_url replaces the loading page in place - there is
|
||||
never a second window to close."""
|
||||
if _wait_for_backend():
|
||||
if _wait_for_backend(win):
|
||||
win.load_url(URL)
|
||||
else:
|
||||
print("[nexus] backend not reachable on :8000 after 40s.",
|
||||
file=sys.stderr)
|
||||
win.load_html(FAILED_HTML)
|
||||
|
||||
webview.start(_swap_in_app, window) # blocks until the window is closed
|
||||
icon = ICON_PATH if os.path.isfile(ICON_PATH) else None
|
||||
webview.start(_swap_in_app, window, icon=icon) # blocks until the window is closed
|
||||
return 0
|
||||
except Exception as e: # no WebView2 runtime / backend failure
|
||||
return _browser_fallback(f"native window failed ({e})")
|
||||
|
||||
+7
-3
@@ -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"
|
||||
|
||||
+46
-33
@@ -155,20 +155,20 @@ function App() {
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ key: "chatbot", label: "💬 Chat" },
|
||||
{ key: "playbook", label: "📖 Playbooks" },
|
||||
{ key: "models", label: "🤖 Models", badge: isModelPulling },
|
||||
{ key: "memory", label: "🧠 Memory" },
|
||||
{ key: "documents", label: "📄 Documents" },
|
||||
{ key: "logs", label: "📜 Logs" },
|
||||
{ key: "settings", label: "⚙️ Settings" },
|
||||
{ key: "chatbot", icon: "💬", label: "Chat" },
|
||||
{ key: "playbook", icon: "📖", label: "Playbooks" },
|
||||
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
|
||||
{ key: "memory", icon: "🧠", label: "Memory" },
|
||||
{ key: "documents", icon: "📄", label: "Documents" },
|
||||
{ key: "logs", icon: "📜", label: "Logs" },
|
||||
{ key: "settings", icon: "⚙️", label: "Settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
|
||||
{/* Sidebar */}
|
||||
<aside style={{
|
||||
width: "260px",
|
||||
width: "220px",
|
||||
flexShrink: 0,
|
||||
background: "#0a0a0a",
|
||||
borderRight: "1px solid #333",
|
||||
@@ -178,18 +178,18 @@ function App() {
|
||||
}}>
|
||||
{/* Top cell: brand + nav */}
|
||||
<div style={{
|
||||
padding: "1.25rem 1rem 1rem",
|
||||
padding: "0.75rem 0.6rem 0.65rem",
|
||||
borderBottom: "1px solid #1f1f1f",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
gap: "0.4rem",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<img src="/n small.png" alt="Logo" style={{ width: "48px", height: "48px", objectFit: "contain", borderRadius: "8px" }} />
|
||||
<h1 style={{ fontSize: "1rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
|
||||
<img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
|
||||
<h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
|
||||
{version && (
|
||||
<span style={{ fontSize: "0.7rem", color: "#666", marginTop: "-0.5rem", letterSpacing: "0.02em" }}>v{version}</span>
|
||||
<span style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
|
||||
)}
|
||||
|
||||
{/* Status Indicator */}
|
||||
@@ -243,13 +243,13 @@ function App() {
|
||||
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
padding: "0.4rem 0.6rem",
|
||||
background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15",
|
||||
color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a",
|
||||
border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"),
|
||||
borderRadius: "8px",
|
||||
borderRadius: "6px",
|
||||
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.78rem",
|
||||
opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
@@ -260,31 +260,37 @@ function App() {
|
||||
: ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"}
|
||||
</button>
|
||||
|
||||
<nav style={{ display: "flex", flexDirection: "column", gap: "0.4rem", width: "100%", marginTop: "0.25rem" }}>
|
||||
{navItems.map(item => (
|
||||
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
|
||||
{navItems.map(item => {
|
||||
const isActive = currentPage === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setCurrentPage(item.key)}
|
||||
style={{
|
||||
padding: "0.65rem 0.85rem",
|
||||
background: currentPage === item.key ? "#007acc" : "#161616",
|
||||
color: "#fff",
|
||||
border: "1px solid " + (currentPage === item.key ? "#0099ff" : "#2a2a2a"),
|
||||
borderRadius: "8px",
|
||||
padding: "0.32rem 0.5rem",
|
||||
background: isActive ? "#1c2733" : "transparent",
|
||||
color: isActive ? "#fff" : "#bbb",
|
||||
border: "none",
|
||||
borderRadius: "5px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontSize: "0.8rem",
|
||||
textAlign: "left",
|
||||
transition: "all 0.15s",
|
||||
transition: "background 0.12s, color 0.12s",
|
||||
display: "flex",
|
||||
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 && (
|
||||
<span style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
background: "#28a745",
|
||||
flexShrink: 0,
|
||||
@@ -292,7 +298,8 @@ function App() {
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -302,7 +309,7 @@ function App() {
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "0.75rem 0.75rem 0.75rem",
|
||||
padding: "0.6rem 0.6rem 0.6rem",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem", flexShrink: 0 }}>
|
||||
<span style={{ fontSize: "0.75rem", color: "#888", textTransform: "uppercase", letterSpacing: "0.05em" }}>Chats</span>
|
||||
@@ -470,13 +477,19 @@ function App() {
|
||||
}}>
|
||||
<Models onPullStateChange={setIsModelPulling} />
|
||||
</div>
|
||||
{currentPage === "chatbot" && (
|
||||
{/* Chatbot stays mounted so an in-flight reply survives page navigation */}
|
||||
<div style={{
|
||||
display: currentPage === "chatbot" ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<Chatbot
|
||||
conversationId={activeConversationId}
|
||||
setConversationId={setActiveConversationId}
|
||||
onConversationChanged={() => loadConversations(search)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentPage === "playbook" && <Playbook />}
|
||||
{currentPage === "memory" && <Memory />}
|
||||
{currentPage === "documents" && <Documents />}
|
||||
|
||||
@@ -10,6 +10,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
const [modelList, setModelList] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
|
||||
const [autoModel, setAutoModel] = useState(null);
|
||||
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [copiedIdx, setCopiedIdx] = useState(null);
|
||||
const [lastStats, setLastStats] = useState(null);
|
||||
@@ -138,7 +139,10 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
]).then(([models, settings]) => {
|
||||
if (models?.models) setModelList(models.models);
|
||||
if (models?.selected) setAutoModel(models.selected);
|
||||
if (settings) setSelectedModel(settings.model || "");
|
||||
if (settings) {
|
||||
setSelectedModel(settings.model || "");
|
||||
setThink(!!settings.think);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -163,6 +167,18 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
} catch { /* persisting the model choice is best-effort */ }
|
||||
};
|
||||
|
||||
const toggleThink = async () => {
|
||||
const next = !think;
|
||||
setThink(next);
|
||||
try {
|
||||
await fetch(`${API_BASE}/settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ think: next }),
|
||||
});
|
||||
} catch { /* persisting the toggle is best-effort */ }
|
||||
};
|
||||
|
||||
const startNewChat = () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
setInput("");
|
||||
@@ -202,6 +218,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
history,
|
||||
think,
|
||||
...(imgs && imgs.length ? { images: imgs } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
@@ -522,6 +539,28 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleThink}
|
||||
title={think ? "Extended thinking is on — click to turn off" : "Extended thinking is off — click to turn on"}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
color: think ? "#c4b5fd" : "#888",
|
||||
background: think ? "#1e1a2e" : "#1a1a1a",
|
||||
border: "1px solid " + (think ? "#7c3aed" : "#2a2a2a"),
|
||||
borderRadius: "4px",
|
||||
padding: "0.15rem 0.5rem",
|
||||
cursor: "pointer",
|
||||
letterSpacing: "0.03em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.3rem",
|
||||
}}
|
||||
>
|
||||
🧠 Think
|
||||
<span style={{ fontSize: "0.6rem", color: think ? "#8aff8a" : "#555" }}>
|
||||
{think ? "on" : "off"}
|
||||
</span>
|
||||
</button>
|
||||
{lastStats && (
|
||||
<span style={{
|
||||
fontSize: "0.7rem",
|
||||
|
||||
@@ -33,6 +33,34 @@ export function Settings() {
|
||||
const [brandQueue, setBrandQueue] = useState([]);
|
||||
const [isBranding, setIsBranding] = useState(false);
|
||||
const [modelList, setModelList] = useState([]);
|
||||
const [platform, setPlatform] = useState("");
|
||||
|
||||
// Vite dev server toggle
|
||||
const [viteRunning, setViteRunning] = useState(null);
|
||||
const [viteBusy, setViteBusy] = useState(false);
|
||||
const [viteError, setViteError] = useState("");
|
||||
|
||||
const loadViteStatus = () => {
|
||||
fetch(`${API_BASE}/frontend/status`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setViteRunning(!!d.running); })
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const toggleVite = async () => {
|
||||
setViteBusy(true);
|
||||
setViteError("");
|
||||
try {
|
||||
const action = viteRunning ? "stop" : "start";
|
||||
const r = await fetch(`${API_BASE}/frontend/${action}`, { method: "POST" });
|
||||
const d = await r.json().catch(() => null);
|
||||
if (d && d.status === "error") setViteError(d.detail || "Failed to start Vite");
|
||||
} catch (e) {
|
||||
setViteError(e.message || "Failed");
|
||||
}
|
||||
setViteBusy(false);
|
||||
loadViteStatus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null)
|
||||
@@ -46,6 +74,13 @@ export function Settings() {
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setIconApps(d.apps || []); })
|
||||
.catch(() => {});
|
||||
|
||||
fetch(`${API_BASE}/status`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setPlatform(d.platform || ""); })
|
||||
.catch(() => {});
|
||||
|
||||
loadViteStatus();
|
||||
}, []);
|
||||
|
||||
const update = (key, value) => setForm(f => ({ ...f, [key]: value }));
|
||||
@@ -167,7 +202,7 @@ export function Settings() {
|
||||
const displayQueue = isBranding ? brandQueue : selectedApps;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "720px" }}>
|
||||
<div style={{ width: "100%" }}>
|
||||
<h2 style={{ margin: "0 0 1.25rem", fontSize: "1.1rem", color: "#eee" }}>Settings</h2>
|
||||
|
||||
{/* Auto model routing */}
|
||||
@@ -480,7 +515,42 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Branding */}
|
||||
{/* Vite Dev Server */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Vite Dev Server</h3>
|
||||
<p style={{ margin: "0 0 1rem", fontSize: "0.75rem", color: "#555" }}>
|
||||
Hot-reload server for editing the frontend directly (interface/web). Not
|
||||
needed for normal use — this backend already serves the built UI.
|
||||
</p>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={toggleVite}
|
||||
disabled={viteBusy || viteRunning === null}
|
||||
style={{
|
||||
padding: "0.6rem 1.25rem",
|
||||
background: viteRunning ? "#2a1a1a" : "#152a15",
|
||||
color: viteRunning ? "#ff8a80" : "#8aff8a",
|
||||
border: "1px solid " + (viteRunning ? "#5a2a2a" : "#2a5a2a"),
|
||||
borderRadius: "8px",
|
||||
cursor: (viteBusy || viteRunning === null) ? "not-allowed" : "pointer",
|
||||
fontSize: "0.88rem",
|
||||
fontWeight: 500,
|
||||
opacity: (viteBusy || viteRunning === null) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{viteBusy ? (viteRunning ? "Stopping…" : "Starting…")
|
||||
: viteRunning ? "⏹ Stop Vite" : "▶ Start Vite"}
|
||||
</button>
|
||||
<span style={{ fontSize: "0.82rem", color: viteRunning ? "#8aff8a" : "#666" }}>
|
||||
{viteRunning === null ? "Checking…" : viteRunning ? "Running at :5173" : "Stopped"}
|
||||
</span>
|
||||
{viteError && <span style={{ color: "#f44336", fontSize: "0.85rem" }}>{viteError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Branding — Linux-only: composites icons onto XFCE/GTK app tiles,
|
||||
which don't exist on Windows/macOS. */}
|
||||
{platform === "linux" && (
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>App Icon Branding</h3>
|
||||
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
|
||||
@@ -638,6 +708,7 @@ export function Settings() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
|
||||
+32
-24
@@ -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.
|
||||
|
||||
+30
-13
@@ -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" 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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
# -------------------------
|
||||
|
||||
Reference in New Issue
Block a user