forked from enderofwings/NexusOS
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
214ce07d1f | ||
|
|
93d78c0ad3 |
@@ -37,6 +37,17 @@ desktop shortcut runs and still works directly.
|
|||||||
```
|
```
|
||||||
This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
|
This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
|
||||||
|
|
||||||
|
**macOS (community-supported):**
|
||||||
|
```bash
|
||||||
|
./install-macos.sh # one-time: Homebrew packages + venv + web build, via bin/sync.py
|
||||||
|
./launch_nexus.sh # same script as Linux - it's plain bash, no Linux-only calls
|
||||||
|
```
|
||||||
|
No bundled Ollama binary (Linux x86-64 only) and no XFCE desktop branding — both
|
||||||
|
already no-op on macOS via `bin/sync.py`'s `linux_stage()`. Ollama is instead the
|
||||||
|
Homebrew-installed native binary, picked up automatically because
|
||||||
|
`OllamaManager` falls back to `ollama` on PATH when the bundled binary is
|
||||||
|
absent; that gets full Metal GPU acceleration with no extra config.
|
||||||
|
|
||||||
**Individual services via CLI:**
|
**Individual services via CLI:**
|
||||||
```bash
|
```bash
|
||||||
# From nexus-core/ with Promethean venv active:
|
# From nexus-core/ with Promethean venv active:
|
||||||
@@ -50,28 +61,25 @@ uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload
|
|||||||
cd interface/web && npm run dev
|
cd interface/web && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
**Management CLI** (`nexus` / `ncp`) — start/stop services with PID tracking, plus
|
**Management CLI** (`ncp`) — start/stop services with PID tracking, plus terminal
|
||||||
terminal access to the same features as the web UI (REST API on `:8000`):
|
access to the same features as the web UI (all via the REST API on `:8000`):
|
||||||
```bash
|
```bash
|
||||||
./management/nexus-cli.sh start # starts backend + frontend
|
./management/nexus-cli.sh start # starts backend + frontend
|
||||||
./management/nexus-cli.sh stop
|
./management/nexus-cli.sh stop
|
||||||
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
|
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
|
||||||
|
|
||||||
# Interactive TUI (Hermes/OpenClaw-style; needs pip install 'nexusos-ai[tui]'):
|
# Feature commands (dispatch to nexusos_cli/nexus_api.py — httpx, no TUI):
|
||||||
nexus # bare command opens the Textual chat TUI
|
ncp chat "<message>" # stream a reply (POST /chat/stream)
|
||||||
nexus tui # same, explicit
|
ncp memory list|add <text>|rm <id>
|
||||||
|
ncp playbook list|show <id> # first playbook (*) is the active system prompt
|
||||||
# Feature one-shots (dispatch to nexusos_cli/nexus_api.py — httpx):
|
ncp history [query] # recent conversations
|
||||||
nexus chat send "<message>" # stream a reply (POST /chat/stream)
|
|
||||||
nexus memory list|add <text>|rm <id>
|
|
||||||
nexus playbook list|show <id> # first playbook (*) is the active system prompt
|
|
||||||
nexus history [query] # recent conversations
|
|
||||||
nexus monitor # ASCII status dashboard (no prompt)
|
|
||||||
```
|
```
|
||||||
The interactive TUI lives in `nexusos_cli/tui_app.py` (Textual, optional extra).
|
The old curses TUIs (`nexus-chat.py`, `nexus-playbook.py`) were removed in favor of
|
||||||
One-shot subcommands and `nexus monitor` remain for scripts. The CLI package is
|
these API-backed subcommands. The CLI covers chat, memory, playbooks, and history;
|
||||||
`nexusos_cli/` (what the wheel ships); `management/` keeps desktop-only pieces —
|
the web UI and control panel expose the remaining management features.
|
||||||
shell wrappers, Tk control panel, XFCE panel wiring.
|
The CLI itself lives in `nexusos_cli/` (that is what the wheel ships and what
|
||||||
|
`nexus`/`ncp`/`nexusos` dispatch to); `management/` keeps the desktop-only
|
||||||
|
pieces — the shell wrappers, the Tk control panel, and the XFCE panel wiring.
|
||||||
`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via
|
`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via
|
||||||
`bin/panel/nexus-popup.py`) stays.
|
`bin/panel/nexus-popup.py`) stays.
|
||||||
|
|
||||||
@@ -95,7 +103,7 @@ cd interface/web && npm run build
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Python venv
|
### Python venv
|
||||||
All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, or CPU/Windows requirements from the host and installs that alone by default — fast, no multi-GB downloads.
|
All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). macOS uses `requirements-base.txt` without an overlay because Ollama handles inference outside the venv. `bin/sync.py` (`requirements()`) selects the appropriate requirements for the host and installs that alone by default — fast, no multi-GB downloads.
|
||||||
|
|
||||||
`requirements-ml.txt` is a separate, **opt-in** overlay for local ML inference (transformers/accelerate/bitsandbytes + torch/torchaudio/torchvision) — nothing in `synapse/` imports any of it; Ollama does all inference over HTTP. Only pull it in for local model work outside Ollama: `pip install -r requirements-amd.txt -r requirements-ml.txt` (or `-nvidia`, or alone for CPU-only torch). Not installed by `bin/sync.py`/the installers.
|
`requirements-ml.txt` is a separate, **opt-in** overlay for local ML inference (transformers/accelerate/bitsandbytes + torch/torchaudio/torchvision) — nothing in `synapse/` imports any of it; Ollama does all inference over HTTP. Only pull it in for local model work outside Ollama: `pip install -r requirements-amd.txt -r requirements-ml.txt` (or `-nvidia`, or alone for CPU-only torch). Not installed by `bin/sync.py`/the installers.
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ ncp web
|
|||||||
Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one
|
Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one
|
||||||
GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA).
|
GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA).
|
||||||
`requirements-windows.txt` is the standalone CPU-only runtime (no base overlay).
|
`requirements-windows.txt` is the standalone CPU-only runtime (no base overlay).
|
||||||
`bin/sync.py` picks the right one for the host.
|
macOS uses `requirements-base.txt` directly, no overlay — see the macOS section
|
||||||
|
below. `bin/sync.py` picks the right one for the host.
|
||||||
|
|
||||||
`./install.sh` is also the update path — re-run it any time to pull the latest
|
`./install.sh` is also the update path — re-run it any time to pull the latest
|
||||||
and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme
|
and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme
|
||||||
@@ -172,10 +173,38 @@ The app opens at `:8000`; click **Start AI** to launch Ollama. The installer
|
|||||||
uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama
|
uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama
|
||||||
does all inference over HTTP).
|
does all inference over HTTP).
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
Community-supported — no bundled Ollama binary or XFCE desktop branding (that
|
||||||
|
stage is Linux/XFCE-only and already skips itself here), but the
|
||||||
|
backend/frontend/Ollama stack itself runs natively, no VM or container needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install Homebrew first if you don't have it: https://brew.sh
|
||||||
|
|
||||||
|
# 2. Build everything: Homebrew packages (Python, Node, git, Ollama), venv,
|
||||||
|
# web UI, memory DB.
|
||||||
|
./install-macos.sh
|
||||||
|
|
||||||
|
# 3. Launch (memory :8001, backend :8000 — backend also serves the built UI)
|
||||||
|
./launch_nexus.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ollama here is the Homebrew-installed native binary, not the Linux-only bundled
|
||||||
|
one — `OllamaManager` already falls back to `ollama` on PATH when
|
||||||
|
`ollama/bin/ollama` doesn't exist, so **Start AI** in the sidebar (or `ollama
|
||||||
|
serve` in a terminal) uses it with full Metal GPU acceleration automatically,
|
||||||
|
no configuration needed.
|
||||||
|
|
||||||
|
`./install-macos.sh` is also the update path, same idea as Linux — re-run it
|
||||||
|
any time to pull the latest and rebuild; `--check` dry-runs it. It's a thin
|
||||||
|
wrapper over `bin/sync.py restore`, the same code Linux and `ncp restore`
|
||||||
|
(any platform) run.
|
||||||
|
|
||||||
### Individual services
|
### Individual services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux
|
# Linux / macOS
|
||||||
source Promethean/bin/activate
|
source Promethean/bin/activate
|
||||||
|
|
||||||
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
|
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
|
||||||
@@ -199,7 +228,7 @@ Nexus's dependencies out of the system Python. To add a package, activate it
|
|||||||
and `pip install` as usual:
|
and `pip install` as usual:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux
|
# Linux / macOS
|
||||||
source Promethean/bin/activate
|
source Promethean/bin/activate
|
||||||
pip install <package>
|
pip install <package>
|
||||||
```
|
```
|
||||||
@@ -274,7 +303,9 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
|
|||||||
- **Filesystem paths** — `synapse/nexus_config.py`
|
- **Filesystem paths** — `synapse/nexus_config.py`
|
||||||
- **Frontend API base URL** — `interface/web/src/config.js`
|
- **Frontend API base URL** — `interface/web/src/config.js`
|
||||||
- **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay;
|
- **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay;
|
||||||
`requirements-windows.txt` = standalone CPU runtime
|
`requirements-windows.txt` = standalone CPU runtime; macOS uses
|
||||||
|
`requirements-base.txt` with no overlay (Ollama, not this venv, does
|
||||||
|
inference — natively, with Metal)
|
||||||
|
|
||||||
## Issues and feature requests
|
## Issues and feature requests
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== shell parse =="
|
echo "== shell parse =="
|
||||||
for f in scripts/install-termux.sh launch_nexus.sh management/nexus-cli.sh; do
|
for f in scripts/install-termux.sh install-macos.sh launch_nexus.sh management/nexus-cli.sh; do
|
||||||
[ -f "$f" ] && { bash -n "$f" || fail=1; }
|
[ -f "$f" ] && { bash -n "$f" || fail=1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -66,9 +66,11 @@ def ensure_exec_bits() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def linux_stage(script: str, *args) -> None:
|
def linux_stage(script: str, *args) -> None:
|
||||||
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt,
|
"""Run one of the Linux-only bash stages. A no-op on Windows and macOS,
|
||||||
xfconf, plank and the rest have nothing to act on."""
|
where apt, xfconf, plank and the rest have nothing to act on. os.name is
|
||||||
if os.name == "nt":
|
'posix' on both Linux and macOS, so the Windows-only os.name check alone
|
||||||
|
doesn't exclude macOS - needs the explicit darwin check too."""
|
||||||
|
if os.name == "nt" or sys.platform == "darwin":
|
||||||
return
|
return
|
||||||
path = ROOT / "bin" / script
|
path = ROOT / "bin" / script
|
||||||
bash = shutil.which("bash")
|
bash = shutil.which("bash")
|
||||||
@@ -109,6 +111,13 @@ def requirements() -> str:
|
|||||||
"""Pick the PyTorch overlay for this host."""
|
"""Pick the PyTorch overlay for this host."""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
return "requirements-windows.txt" # CPU / pure-Python, right for native Windows
|
return "requirements-windows.txt" # CPU / pure-Python, right for native Windows
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
# No ROCm/CUDA overlay applies here, and none is needed: Ollama does
|
||||||
|
# all inference over HTTP (see requirements-ml.txt), and on macOS
|
||||||
|
# that's a natively-installed, Metal-accelerated Ollama binary
|
||||||
|
# (OllamaManager falls back to it on PATH - see synapse/ollama_manager.py),
|
||||||
|
# entirely outside this venv.
|
||||||
|
return "requirements-base.txt"
|
||||||
if shutil.which("nvidia-smi"):
|
if shutil.which("nvidia-smi"):
|
||||||
return "requirements-nvidia.txt"
|
return "requirements-nvidia.txt"
|
||||||
lspci = shutil.which("lspci")
|
lspci = shutil.which("lspci")
|
||||||
|
|||||||
@@ -37,14 +37,11 @@ and seed playbooks. Extras keep platform-sensitive dependencies optional:
|
|||||||
- `desktop`: desktop process support and Windows pywebview
|
- `desktop`: desktop process support and Windows pywebview
|
||||||
- `search`: DuckDuckGo web search for chat
|
- `search`: DuckDuckGo web search for chat
|
||||||
- `mail`: IMAP mail reading
|
- `mail`: IMAP mail reading
|
||||||
- `tui`: Textual interactive chat UI (`nexus` with no subcommand)
|
|
||||||
- `all`: every optional capability at once
|
- `all`: every optional capability at once
|
||||||
|
|
||||||
## Common commands
|
## Common commands
|
||||||
|
|
||||||
```text
|
```text
|
||||||
nexus Interactive chat TUI (needs nexusos-ai[tui])
|
|
||||||
nexus tui Same as bare nexus
|
|
||||||
nexus init Create writable state and seed playbooks
|
nexus init Create writable state and seed playbooks
|
||||||
nexus doctor [--fix] [--json] Diagnose the install and provider
|
nexus doctor [--fix] [--json] Diagnose the install and provider
|
||||||
nexus paths [--json] Show package, state, and asset locations
|
nexus paths [--json] Show package, state, and asset locations
|
||||||
|
|||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# NexusOS installer, macOS. One painless command:
|
||||||
|
#
|
||||||
|
# git clone <repo> nexus-core && cd nexus-core && ./install-macos.sh
|
||||||
|
#
|
||||||
|
# Mirrors install.sh's philosophy: every portable step - git pull, venv, pip
|
||||||
|
# with the right overlay, npm build - lives in bin/sync.py, shared with Linux
|
||||||
|
# and Windows. This script only does what sync.py can't do for itself on a
|
||||||
|
# bare Mac: install the Homebrew packages needed before Python even exists to
|
||||||
|
# run sync.py with. Re-run any time to update; --check dry-runs it.
|
||||||
|
#
|
||||||
|
# Ollama itself is *not* fetched here - bin/fetch-ollama.sh only ships a Linux
|
||||||
|
# x86-64 binary, and linux_stage() in bin/sync.py already no-ops on macOS, so
|
||||||
|
# that stage is skipped entirely. The Homebrew `ollama` installed below is
|
||||||
|
# picked up automatically instead: synapse/ollama_manager.py prefers the
|
||||||
|
# bundled Linux binary and falls back to whatever `ollama` it finds on PATH,
|
||||||
|
# which on macOS is this one - with full Metal GPU acceleration, no flags
|
||||||
|
# needed. The XFCE desktop branding (theme/panel/splash) is Linux-only and
|
||||||
|
# already gated off macOS the same way; nothing to install for it here.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if ! command -v brew >/dev/null; then
|
||||||
|
echo "Homebrew is required (it installs Python/Node/git/Ollama)." >&2
|
||||||
|
echo "Install it, then re-run this script: https://brew.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing/checking system packages (python@3.12, node, git, ollama)..."
|
||||||
|
brew install python@3.12 node git ollama
|
||||||
|
|
||||||
|
py="$(brew --prefix python@3.12)/bin/python3.12"
|
||||||
|
if [ ! -x "$py" ]; then
|
||||||
|
echo "python3.12 not found at $py after brew install - check 'brew doctor'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prefer the venv interpreter once it exists, same as install.sh; the brewed
|
||||||
|
# interpreter above is only the bootstrap case on a fresh clone. sync.py is
|
||||||
|
# stdlib-only either way.
|
||||||
|
[ -x "Promethean/bin/python" ] && py="Promethean/bin/python"
|
||||||
|
|
||||||
|
exec "$py" bin/sync.py restore "$@"
|
||||||
+3
-36
@@ -398,24 +398,6 @@ def cmd_monitor(args) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def cmd_tui(args) -> int:
|
|
||||||
"""Interactive Hermes/OpenClaw-style chat TUI (requires nexusos-ai[tui])."""
|
|
||||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
||||||
print(
|
|
||||||
"The TUI needs a terminal. Use: nexus chat send \"…\"\n"
|
|
||||||
"Or run `nexus` in an interactive shell.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
try:
|
|
||||||
from .tui_app import run_tui
|
|
||||||
except ImportError as exc:
|
|
||||||
print(str(exc), file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
api = getattr(args, "api_url", None) or settings.api_url
|
|
||||||
return run_tui(api_url=api)
|
|
||||||
|
|
||||||
|
|
||||||
def _target_flag(target: str | None):
|
def _target_flag(target: str | None):
|
||||||
return {
|
return {
|
||||||
"memory": "--memory",
|
"memory": "--memory",
|
||||||
@@ -740,20 +722,10 @@ def _port(value: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
|
||||||
prog="nexus",
|
|
||||||
description=(
|
|
||||||
"NexusOS local AI runtime and API client. "
|
|
||||||
"With no subcommand, opens the interactive TUI (needs nexusos-ai[tui])."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
|
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
|
||||||
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
|
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
|
||||||
# Bare `nexus` → TUI. Subcommands remain for scripts and one-shots.
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
sub = parser.add_subparsers(dest="command", required=False)
|
|
||||||
|
|
||||||
p = sub.add_parser("tui", help="interactive chat TUI (default when no subcommand)")
|
|
||||||
p.set_defaults(fn=cmd_tui)
|
|
||||||
|
|
||||||
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
|
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
|
||||||
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
|
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
|
||||||
@@ -855,12 +827,7 @@ def _normalize_legacy_argv(argv) -> list[str]:
|
|||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
argv = _normalize_legacy_argv(argv)
|
args = parser.parse_args(_normalize_legacy_argv(argv))
|
||||||
args = parser.parse_args(argv)
|
|
||||||
if not getattr(args, "command", None):
|
|
||||||
# Bare `nexus` / `ncp` / `nexusos` → interactive TUI.
|
|
||||||
args.command = "tui"
|
|
||||||
args.fn = cmd_tui
|
|
||||||
if args.command == "config":
|
if args.command == "config":
|
||||||
if args.action in ("get", "unset") and not args.key:
|
if args.action in ("get", "unset") and not args.key:
|
||||||
parser.error(f"config {args.action} requires KEY")
|
parser.error(f"config {args.action} requires KEY")
|
||||||
|
|||||||
@@ -1,505 +0,0 @@
|
|||||||
"""Hermes/OpenClaw-style interactive TUI for NexusOS.
|
|
||||||
|
|
||||||
Optional: needs the ``tui`` extra (Textual). Launched by a bare ``nexus`` when
|
|
||||||
stdin/stdout are a TTY. Classic one-shots (``nexus chat send``, ``nexus monitor``,
|
|
||||||
``nexus status``, …) stay on the argparse tree.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from synapse.nexus_config import settings
|
|
||||||
|
|
||||||
from .monitor import collect_snapshot
|
|
||||||
|
|
||||||
# Between SSE chunks a silent backend must not pin the UI forever. Connect stays
|
|
||||||
# short; the overall stream may run minutes.
|
|
||||||
_STREAM_TIMEOUT = httpx.Timeout(None, connect=5.0, read=120.0, write=30.0, pool=5.0)
|
|
||||||
_APPROVAL_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_textual():
|
|
||||||
try:
|
|
||||||
from textual.app import App
|
|
||||||
from textual.binding import Binding
|
|
||||||
from textual.widgets import Footer, Header, Input, RichLog, Static
|
|
||||||
except ImportError as e: # pragma: no cover - optional extra
|
|
||||||
raise ImportError(
|
|
||||||
"The interactive TUI needs the 'tui' extra — "
|
|
||||||
"pip install 'nexusos-ai[tui]' (or: pip install textual)."
|
|
||||||
) from e
|
|
||||||
return App, Binding, Footer, Header, Input, RichLog, Static
|
|
||||||
|
|
||||||
|
|
||||||
def _escape(text: str) -> str:
|
|
||||||
"""Make model/user text safe for Rich markup widgets.
|
|
||||||
|
|
||||||
Rich's own escape is the only version that round-trips. Escaping every
|
|
||||||
backslash by hand looks equivalent but is not: Rich un-escapes ``\\[`` and
|
|
||||||
never collapses ``\\\\``, so doubling them puts the doubles on screen -
|
|
||||||
every Windows path and regex escape in a reply renders wrong.
|
|
||||||
|
|
||||||
Imported inside the function so the module still loads without the ``tui``
|
|
||||||
extra. Rich is not declared in pyproject: Textual depends on it, so it is
|
|
||||||
present whenever the TUI can run at all, and tests/test_packaging_deps.py
|
|
||||||
lists it in TRANSITIVE for that reason.
|
|
||||||
"""
|
|
||||||
from rich.markup import escape
|
|
||||||
|
|
||||||
return escape(text)
|
|
||||||
|
|
||||||
|
|
||||||
def format_user_line(message: str) -> str:
|
|
||||||
return f"[bold green]you>[/] {_escape(message)}"
|
|
||||||
|
|
||||||
|
|
||||||
def format_assistant_line(text: str) -> str:
|
|
||||||
return f"[bold blue]nexus>[/] {_escape(text)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _deny_tool_request(
|
|
||||||
*,
|
|
||||||
api_url: str,
|
|
||||||
conversation_id: str,
|
|
||||||
payload: str,
|
|
||||||
client_factory=httpx.Client,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Immediately deny a TUI action request and let the stream resume.
|
|
||||||
|
|
||||||
The web client presents an approval dialog, but the TUI does not yet have
|
|
||||||
that interaction. Denying with the stream's capability token preserves the
|
|
||||||
``ask`` safety boundary without leaving the backend waiting for five minutes.
|
|
||||||
"""
|
|
||||||
request = json.loads(payload)
|
|
||||||
token = request.get("token") or ""
|
|
||||||
actions = request.get("actions") or []
|
|
||||||
names = [
|
|
||||||
action.get("name", "")
|
|
||||||
for action in actions
|
|
||||||
if isinstance(action, dict) and action.get("name")
|
|
||||||
]
|
|
||||||
if not token or not names:
|
|
||||||
raise ValueError("invalid tool approval request")
|
|
||||||
body = {
|
|
||||||
"conversation_id": conversation_id,
|
|
||||||
"token": token,
|
|
||||||
"decisions": {name: False for name in names},
|
|
||||||
}
|
|
||||||
with client_factory(base_url=api_url, timeout=_APPROVAL_TIMEOUT) as client:
|
|
||||||
response = client.post("/chat/approve", json=body)
|
|
||||||
response.raise_for_status()
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def _status_line(snap: dict | None = None) -> str:
|
|
||||||
"""Format a snapshot. Pass ``snap`` — do not omit it on the UI thread."""
|
|
||||||
if snap is None:
|
|
||||||
snap = collect_snapshot()
|
|
||||||
svcs = snap.get("services") or {}
|
|
||||||
api = snap.get("api") or {}
|
|
||||||
host = snap.get("host") or {}
|
|
||||||
parts = [f"NexusOS {snap.get('version', '')}"]
|
|
||||||
for key in ("backend", "memory", "provider"):
|
|
||||||
info = svcs.get(key) or {}
|
|
||||||
if key == "provider":
|
|
||||||
up = bool(info.get("reachable"))
|
|
||||||
else:
|
|
||||||
up = bool(info.get("running"))
|
|
||||||
parts.append(f"{key}={'UP' if up else 'DOWN'}")
|
|
||||||
if api.get("online"):
|
|
||||||
parts.append(f"tools={api.get('action_tool_policy') or '—'}")
|
|
||||||
cpu = host.get("cpu_pct")
|
|
||||||
if cpu is not None:
|
|
||||||
parts.append(f"cpu={cpu:.0f}%")
|
|
||||||
chains = snap.get("toolchains") or []
|
|
||||||
ready = [c["lang"] for c in chains if c.get("ready")]
|
|
||||||
if ready:
|
|
||||||
parts.append("run=" + ",".join(ready))
|
|
||||||
return " · ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _compact_status(snap: dict | None = None) -> str:
|
|
||||||
"""One-line strip for the bar under the chat log."""
|
|
||||||
if snap is None:
|
|
||||||
snap = collect_snapshot()
|
|
||||||
host = snap.get("host") or {}
|
|
||||||
api = snap.get("api") or {}
|
|
||||||
recent = snap.get("recent_tools") or []
|
|
||||||
cpu = host.get("cpu_pct")
|
|
||||||
mem = host.get("mem_pct")
|
|
||||||
bits = []
|
|
||||||
if cpu is not None:
|
|
||||||
bits.append(f"cpu {cpu:.0f}%")
|
|
||||||
if mem is not None:
|
|
||||||
bits.append(f"mem {mem:.0f}%")
|
|
||||||
if api.get("online"):
|
|
||||||
bits.append(
|
|
||||||
f"memories={api.get('memories') if api.get('memories') is not None else '—'} "
|
|
||||||
f"chats={api.get('conversations') if api.get('conversations') is not None else '—'}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
bits.append("api DOWN — nexus start")
|
|
||||||
if recent:
|
|
||||||
bits.append("recent " + ", ".join(recent[:4]))
|
|
||||||
return " │ ".join(bits)
|
|
||||||
|
|
||||||
|
|
||||||
class NexusTUI:
|
|
||||||
"""Factory so Textual imports stay lazy until run()."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_app(*, api_url: str | None = None):
|
|
||||||
App, Binding, Footer, Header, Input, RichLog, Static = _require_textual()
|
|
||||||
base = (api_url or settings.api_url).rstrip("/")
|
|
||||||
|
|
||||||
class AppImpl(App):
|
|
||||||
CSS = """
|
|
||||||
Screen { layout: vertical; }
|
|
||||||
#status {
|
|
||||||
height: 1;
|
|
||||||
dock: top;
|
|
||||||
background: $boost;
|
|
||||||
color: $text;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
#strip {
|
|
||||||
height: 1;
|
|
||||||
background: $surface;
|
|
||||||
color: $text-muted;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
#log {
|
|
||||||
height: 1fr;
|
|
||||||
border: tall $accent;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
#live {
|
|
||||||
height: auto;
|
|
||||||
max-height: 8;
|
|
||||||
padding: 0 1;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
#prompt { dock: bottom; }
|
|
||||||
"""
|
|
||||||
BINDINGS = [
|
|
||||||
Binding("ctrl+c", "interrupt", "Interrupt", priority=True),
|
|
||||||
Binding("ctrl+d", "quit", "Quit", priority=True),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.api_url = base
|
|
||||||
self.conversation_id: str | None = None
|
|
||||||
self.history: list[dict] = []
|
|
||||||
self._model: str | None = None
|
|
||||||
self._busy = False
|
|
||||||
self._stop_stream = threading.Event()
|
|
||||||
self._stream_cancel: (
|
|
||||||
tuple[asyncio.AbstractEventLoop, asyncio.Task] | None
|
|
||||||
) = None
|
|
||||||
self._status_lock = threading.Lock()
|
|
||||||
self._status_pending = False
|
|
||||||
|
|
||||||
def compose(self):
|
|
||||||
# Placeholders only — never collect_snapshot() on the UI thread.
|
|
||||||
yield Header(show_clock=True)
|
|
||||||
yield Static("NexusOS …", id="status")
|
|
||||||
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
|
|
||||||
yield Static("", id="live")
|
|
||||||
yield Static("collecting status…", id="strip")
|
|
||||||
yield Input(
|
|
||||||
placeholder="Message Nexus… (/help for commands)",
|
|
||||||
id="prompt",
|
|
||||||
)
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.title = "NexusOS"
|
|
||||||
self.sub_title = self.api_url
|
|
||||||
log = self.query_one("#log", RichLog)
|
|
||||||
log.write("[bold]NexusOS[/] interactive TUI")
|
|
||||||
log.write(
|
|
||||||
"Type a message and Enter. "
|
|
||||||
"Slash: /help /status /new /model /quit"
|
|
||||||
)
|
|
||||||
log.write(f"API: {_escape(self.api_url)}")
|
|
||||||
log.write("")
|
|
||||||
self._schedule_status_refresh()
|
|
||||||
self.set_interval(2.0, self._schedule_status_refresh)
|
|
||||||
self.query_one("#prompt", Input).focus()
|
|
||||||
|
|
||||||
def _schedule_status_refresh(self) -> None:
|
|
||||||
"""Kick a worker; never call collect_snapshot on the event loop."""
|
|
||||||
with self._status_lock:
|
|
||||||
if self._status_pending:
|
|
||||||
return
|
|
||||||
self._status_pending = True
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
try:
|
|
||||||
snap = collect_snapshot()
|
|
||||||
self._call_ui(self._apply_status, snap)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
with self._status_lock:
|
|
||||||
self._status_pending = False
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _apply_status(self, snap: dict) -> None:
|
|
||||||
self.query_one("#status", Static).update(_status_line(snap))
|
|
||||||
self.query_one("#strip", Static).update(_compact_status(snap))
|
|
||||||
|
|
||||||
def _call_ui(self, callback, *args) -> None:
|
|
||||||
"""call_from_thread, but never after quit (avoids CancelledError
|
|
||||||
traceback garbling the restored shell)."""
|
|
||||||
if not self.is_running:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self.call_from_thread(callback, *args)
|
|
||||||
except BaseException:
|
|
||||||
# CancelledError is BaseException; also ignore post-exit races.
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _show_error(self, message: str) -> None:
|
|
||||||
"""Write a stream error to the persistent transcript."""
|
|
||||||
self.query_one("#log", RichLog).write(message)
|
|
||||||
|
|
||||||
def _cancel_stream(self) -> None:
|
|
||||||
"""Cancel the task that owns the socket read.
|
|
||||||
|
|
||||||
Closing a synchronous httpx client from the UI thread does not
|
|
||||||
reliably unblock its worker-thread read on macOS. Async task
|
|
||||||
cancellation is delivered to the pending read itself.
|
|
||||||
"""
|
|
||||||
self._stop_stream.set()
|
|
||||||
cancel = self._stream_cancel
|
|
||||||
if cancel is not None:
|
|
||||||
loop, task = cancel
|
|
||||||
loop.call_soon_threadsafe(task.cancel)
|
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
|
||||||
self._cancel_stream()
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
def action_interrupt(self) -> None:
|
|
||||||
if self._busy:
|
|
||||||
self._cancel_stream()
|
|
||||||
self.query_one("#log", RichLog).write(
|
|
||||||
"[yellow]▸ interrupt requested[/]"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
text = (event.value or "").strip()
|
|
||||||
event.input.value = ""
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
if text.startswith("/"):
|
|
||||||
self._handle_slash(text)
|
|
||||||
return
|
|
||||||
if self._busy:
|
|
||||||
self.query_one("#log", RichLog).write(
|
|
||||||
"[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self._start_chat(text)
|
|
||||||
|
|
||||||
def _handle_slash(self, text: str) -> None:
|
|
||||||
log = self.query_one("#log", RichLog)
|
|
||||||
cmd, _, rest = text[1:].partition(" ")
|
|
||||||
cmd = cmd.lower().strip()
|
|
||||||
rest = rest.strip()
|
|
||||||
if cmd in ("q", "quit", "exit"):
|
|
||||||
self.exit()
|
|
||||||
elif cmd in ("h", "help"):
|
|
||||||
log.write(
|
|
||||||
"[bold]/help[/] this list\n"
|
|
||||||
"[bold]/status[/] refresh service strip\n"
|
|
||||||
"[bold]/new[/] fresh conversation\n"
|
|
||||||
"[bold]/model[/] \\[name] pin model for next turns\n"
|
|
||||||
"[bold]/quit[/] leave the TUI\n"
|
|
||||||
"One-shot: [dim]nexus chat send \"…\"[/]"
|
|
||||||
)
|
|
||||||
elif cmd == "status":
|
|
||||||
self._schedule_status_refresh()
|
|
||||||
log.write("[dim]refreshing status…[/]")
|
|
||||||
elif cmd == "new":
|
|
||||||
self.conversation_id = None
|
|
||||||
self.history = []
|
|
||||||
log.write("[bold cyan]— new conversation —[/]")
|
|
||||||
elif cmd == "model":
|
|
||||||
if rest:
|
|
||||||
self._model = rest
|
|
||||||
log.write(f"[dim]model pinned:[/] {_escape(rest)}")
|
|
||||||
else:
|
|
||||||
log.write(
|
|
||||||
f"[dim]model:[/] {_escape(self._model or '(auto)')}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.write(
|
|
||||||
f"[red]unknown command[/] /{_escape(cmd)} — try /help"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _start_chat(self, message: str) -> None:
|
|
||||||
log = self.query_one("#log", RichLog)
|
|
||||||
live = self.query_one("#live", Static)
|
|
||||||
log.write(format_user_line(message))
|
|
||||||
live.update("[bold blue]nexus>[/] [dim]…[/]")
|
|
||||||
self._busy = True
|
|
||||||
self._stop_stream.clear()
|
|
||||||
if not self.conversation_id:
|
|
||||||
self.conversation_id = str(uuid.uuid4())
|
|
||||||
conversation_id = self.conversation_id
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
"message": message,
|
|
||||||
"conversation_id": conversation_id,
|
|
||||||
"history": list(self.history),
|
|
||||||
}
|
|
||||||
if self._model:
|
|
||||||
body["model"] = self._model
|
|
||||||
self.history.append({"role": "user", "content": message})
|
|
||||||
|
|
||||||
async def stream_worker():
|
|
||||||
reply_parts: list[str] = []
|
|
||||||
task = asyncio.current_task()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
if task is None: # pragma: no cover - asyncio guarantees it
|
|
||||||
raise RuntimeError("stream worker has no task")
|
|
||||||
self._stream_cancel = (loop, task)
|
|
||||||
try:
|
|
||||||
if self._stop_stream.is_set():
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
base_url=self.api_url, timeout=_STREAM_TIMEOUT
|
|
||||||
) as client:
|
|
||||||
async with client.stream(
|
|
||||||
"POST", "/chat/stream", json=body
|
|
||||||
) as resp:
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
detail = (await resp.aread()).decode(
|
|
||||||
"utf-8", errors="replace"
|
|
||||||
)[:300]
|
|
||||||
self._call_ui(
|
|
||||||
self._show_error,
|
|
||||||
f"[red]error HTTP {resp.status_code}[/] "
|
|
||||||
f"{_escape(detail)}",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
event = "message"
|
|
||||||
async for line in resp.aiter_lines():
|
|
||||||
if self._stop_stream.is_set():
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
if line == "":
|
|
||||||
event = "message"
|
|
||||||
continue
|
|
||||||
if line.startswith("event:"):
|
|
||||||
event = line[6:].strip()
|
|
||||||
continue
|
|
||||||
if not line.startswith("data:"):
|
|
||||||
continue
|
|
||||||
payload = line[5:].strip()
|
|
||||||
kind = event
|
|
||||||
if kind in ("message", ""):
|
|
||||||
kind = "chunk"
|
|
||||||
payload = json.loads(payload)
|
|
||||||
if kind == "chunk":
|
|
||||||
reply_parts.append(payload)
|
|
||||||
preview = "".join(reply_parts)
|
|
||||||
if len(preview) > 4000:
|
|
||||||
preview = "…" + preview[-4000:]
|
|
||||||
self._call_ui(
|
|
||||||
live.update,
|
|
||||||
format_assistant_line(preview),
|
|
||||||
)
|
|
||||||
elif kind == "tool_request":
|
|
||||||
try:
|
|
||||||
names = _deny_tool_request(
|
|
||||||
api_url=self.api_url,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
shown = ", ".join(names)
|
|
||||||
self._call_ui(
|
|
||||||
log.write,
|
|
||||||
"[yellow]▸ denied action tool "
|
|
||||||
f"{_escape(shown)} — interactive "
|
|
||||||
"approval is not yet available in "
|
|
||||||
"the TUI[/]",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self._call_ui(
|
|
||||||
self._show_error,
|
|
||||||
"[red]tool denial failed:[/] "
|
|
||||||
f"{_escape(str(exc))}",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif kind == "error":
|
|
||||||
try:
|
|
||||||
detail = json.loads(payload).get(
|
|
||||||
"detail", payload
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
detail = payload
|
|
||||||
self._call_ui(
|
|
||||||
self._show_error,
|
|
||||||
f"[red]error:[/] "
|
|
||||||
f"{_escape(str(detail))}",
|
|
||||||
)
|
|
||||||
elif kind == "done":
|
|
||||||
break
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except httpx.ConnectError:
|
|
||||||
self._call_ui(
|
|
||||||
self._show_error,
|
|
||||||
f"[red]Backend not reachable at "
|
|
||||||
f"{_escape(self.api_url)}. Start it: nexus start[/]",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self._call_ui(
|
|
||||||
self._show_error,
|
|
||||||
f"[red]{_escape(type(exc).__name__)}:[/] "
|
|
||||||
f"{_escape(str(exc))}",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if self._stream_cancel == (loop, task):
|
|
||||||
self._stream_cancel = None
|
|
||||||
text = "".join(reply_parts).strip()
|
|
||||||
self._call_ui(self._finish_stream, text)
|
|
||||||
|
|
||||||
threading.Thread(
|
|
||||||
target=lambda: asyncio.run(stream_worker()), daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _finish_stream(self, text: str) -> None:
|
|
||||||
log = self.query_one("#log", RichLog)
|
|
||||||
live = self.query_one("#live", Static)
|
|
||||||
try:
|
|
||||||
if text:
|
|
||||||
log.write(format_assistant_line(text))
|
|
||||||
self.history.append(
|
|
||||||
{"role": "assistant", "content": text}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
# Always clear busy — a MarkupError must not wedge the TUI.
|
|
||||||
live.update("")
|
|
||||||
self._busy = False
|
|
||||||
self._schedule_status_refresh()
|
|
||||||
|
|
||||||
return AppImpl()
|
|
||||||
|
|
||||||
|
|
||||||
def run_tui(*, api_url: str | None = None) -> int:
|
|
||||||
"""Run the Textual app. Returns a process exit code."""
|
|
||||||
app = NexusTUI.build_app(api_url=api_url)
|
|
||||||
app.run()
|
|
||||||
return 0
|
|
||||||
@@ -42,7 +42,6 @@ mail = ["imap-tools>=1.7,<2"]
|
|||||||
# synapse/search.py imports this lazily behind a bare except, so without it
|
# synapse/search.py imports this lazily behind a bare except, so without it
|
||||||
# declared the chat web-search path silently returns nothing.
|
# declared the chat web-search path silently returns nothing.
|
||||||
search = ["duckduckgo-search>=6,<9"]
|
search = ["duckduckgo-search>=6,<9"]
|
||||||
tui = ["textual>=1.0,<3"]
|
|
||||||
desktop = [
|
desktop = [
|
||||||
"psutil>=5.9,<8",
|
"psutil>=5.9,<8",
|
||||||
"pywebview>=5,<7; platform_system == 'Windows'",
|
"pywebview>=5,<7; platform_system == 'Windows'",
|
||||||
@@ -65,7 +64,6 @@ all = [
|
|||||||
"faster-whisper>=1.1,<2",
|
"faster-whisper>=1.1,<2",
|
||||||
"imap-tools>=1.7,<2",
|
"imap-tools>=1.7,<2",
|
||||||
"duckduckgo-search>=6,<9",
|
"duckduckgo-search>=6,<9",
|
||||||
"textual>=1.0,<3",
|
|
||||||
"pywebview>=5,<7; platform_system == 'Windows'",
|
"pywebview>=5,<7; platform_system == 'Windows'",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -31,23 +31,8 @@ def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|||||||
def test_help_exposes_portable_command_tree(tmp_path):
|
def test_help_exposes_portable_command_tree(tmp_path):
|
||||||
result = run_cli(tmp_path, "--help")
|
result = run_cli(tmp_path, "--help")
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor", "tui"):
|
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor"):
|
||||||
assert command in result.stdout
|
assert command in result.stdout
|
||||||
assert "interactive TUI" in result.stdout or "TUI" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_bare_nexus_defaults_to_tui_command():
|
|
||||||
"""No subcommand → TUI entry (Hermes-style). Non-TTY exits 2 without launching."""
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from nexusos_cli.cli import build_parser, cmd_tui
|
|
||||||
|
|
||||||
parser = build_parser()
|
|
||||||
args = parser.parse_args([])
|
|
||||||
assert args.command is None # filled in by main()
|
|
||||||
with mock.patch("sys.stdin.isatty", return_value=False), \
|
|
||||||
mock.patch("sys.stdout.isatty", return_value=False):
|
|
||||||
assert cmd_tui(args) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_cli_spellings_remain_compatible():
|
def test_legacy_cli_spellings_remain_compatible():
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Platform guards for the community-supported native macOS install path."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SPEC = importlib.util.spec_from_file_location("nexus_sync_macos_test", ROOT / "bin" / "sync.py")
|
||||||
|
assert SPEC and SPEC.loader
|
||||||
|
sync = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(sync)
|
||||||
|
|
||||||
|
|
||||||
|
def test_darwin_uses_gpu_agnostic_requirements(monkeypatch):
|
||||||
|
monkeypatch.setattr(sync.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(sync.sys, "platform", "darwin")
|
||||||
|
assert sync.requirements() == "requirements-base.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_provisioning_stages_are_skipped_on_darwin(monkeypatch):
|
||||||
|
monkeypatch.setattr(sync.sys, "platform", "darwin")
|
||||||
|
|
||||||
|
def unexpected_run(*args, **kwargs):
|
||||||
|
raise AssertionError(f"Linux provisioning ran on macOS: {args!r}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sync.subprocess, "run", unexpected_run)
|
||||||
|
sync.linux_stage("restore-linux.sh", "packages")
|
||||||
|
|
||||||
|
|
||||||
|
def test_macos_installer_is_in_the_shell_parse_gate():
|
||||||
|
gate = (ROOT / "bin" / "check.sh").read_text(encoding="utf-8")
|
||||||
|
assert "install-macos.sh" in gate
|
||||||
@@ -29,8 +29,7 @@ DISTRIBUTION_OF = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Provided by another declared distribution rather than named directly.
|
# Provided by another declared distribution rather than named directly.
|
||||||
# rich: Textual depends on it, so the tui extra already pulls it in.
|
TRANSITIVE = {"starlette", "socketio", "engineio"}
|
||||||
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
|
||||||
|
|
||||||
# Modules that ship inside this repo.
|
# Modules that ship inside this repo.
|
||||||
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
|
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
"""TUI helpers and headless App.run_test coverage."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rich.text import Text
|
|
||||||
|
|
||||||
from nexusos_cli.tui_app import (
|
|
||||||
_compact_status,
|
|
||||||
_deny_tool_request,
|
|
||||||
_escape,
|
|
||||||
_status_line,
|
|
||||||
format_assistant_line,
|
|
||||||
format_user_line,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _ApprovalResponse:
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _ApprovalClient:
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.kwargs = kwargs
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def post(self, path, *, json):
|
|
||||||
self.calls.append((path, json, self.kwargs))
|
|
||||||
return _ApprovalResponse()
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_line_mentions_services():
|
|
||||||
snap = {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"services": {
|
|
||||||
"backend": {"running": True},
|
|
||||||
"memory": {"running": False},
|
|
||||||
"provider": {"reachable": True},
|
|
||||||
},
|
|
||||||
"api": {"online": True, "action_tool_policy": "ask"},
|
|
||||||
"host": {"cpu_pct": 10.0},
|
|
||||||
"toolchains": [{"lang": "python", "ready": True}],
|
|
||||||
}
|
|
||||||
line = _status_line(snap)
|
|
||||||
assert "backend=UP" in line
|
|
||||||
assert "memory=DOWN" in line
|
|
||||||
assert "provider=UP" in line
|
|
||||||
assert "tools=ask" in line
|
|
||||||
assert "run=python" in line
|
|
||||||
|
|
||||||
|
|
||||||
def test_compact_status_handles_api_down():
|
|
||||||
snap = {
|
|
||||||
"host": {},
|
|
||||||
"api": {"online": False},
|
|
||||||
"recent_tools": [],
|
|
||||||
}
|
|
||||||
assert "api DOWN" in _compact_status(snap)
|
|
||||||
|
|
||||||
|
|
||||||
def test_escape_preserves_code_brackets_in_display():
|
|
||||||
raw = "idx = arr[i] and rng = [a-z]+"
|
|
||||||
plain = Text.from_markup(format_assistant_line(raw)).plain
|
|
||||||
assert "arr[i]" in plain
|
|
||||||
assert "[a-z]+" in plain
|
|
||||||
# Unescaped markup would drop the bracket contents.
|
|
||||||
assert plain != "nexus> idx = arr and rng = +"
|
|
||||||
|
|
||||||
|
|
||||||
def test_closing_tag_in_model_output_does_not_raise():
|
|
||||||
raw = "close with [/] please"
|
|
||||||
plain = Text.from_markup(format_assistant_line(raw)).plain
|
|
||||||
assert "[/]" in plain
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_line_escapes_markup():
|
|
||||||
plain = Text.from_markup(format_user_line("use [bold] please")).plain
|
|
||||||
assert "[bold]" in plain
|
|
||||||
|
|
||||||
|
|
||||||
def test_finish_stream_markup_does_not_wedge_busy():
|
|
||||||
"""A stray '[/]' used to raise before _busy=False and lock the TUI forever."""
|
|
||||||
pytest.importorskip("textual")
|
|
||||||
from nexusos_cli.tui_app import NexusTUI
|
|
||||||
|
|
||||||
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with app.run_test():
|
|
||||||
app._busy = True
|
|
||||||
app._finish_stream("see [/] and arr[i]")
|
|
||||||
assert app._busy is False
|
|
||||||
assert app.history[-1]["content"] == "see [/] and arr[i]"
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
def test_stream_error_remains_visible_after_finish():
|
|
||||||
pytest.importorskip("textual")
|
|
||||||
from nexusos_cli.tui_app import NexusTUI
|
|
||||||
|
|
||||||
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with app.run_test():
|
|
||||||
app._busy = True
|
|
||||||
app._show_error("[red]Backend not reachable[/]")
|
|
||||||
app._finish_stream("")
|
|
||||||
log = app.query_one("#log")
|
|
||||||
assert any("Backend not reachable" in line.text for line in log.lines)
|
|
||||||
assert app._busy is False
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["ctrl+c", "ctrl+d"])
|
|
||||||
def test_priority_exit_bindings_reach_app_while_prompt_is_focused(key):
|
|
||||||
pytest.importorskip("textual")
|
|
||||||
from nexusos_cli.tui_app import NexusTUI
|
|
||||||
|
|
||||||
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
assert app.is_running
|
|
||||||
await pilot.press(key)
|
|
||||||
await pilot.pause()
|
|
||||||
assert not app.is_running
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_request_is_denied_with_stream_token():
|
|
||||||
_ApprovalClient.calls.clear()
|
|
||||||
names = _deny_tool_request(
|
|
||||||
api_url="http://localhost:8000",
|
|
||||||
conversation_id="conversation-1",
|
|
||||||
payload='{"token":"secret","actions":[{"name":"run_snippet"}]}',
|
|
||||||
client_factory=_ApprovalClient,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert names == ["run_snippet"]
|
|
||||||
path, body, client_kwargs = _ApprovalClient.calls[-1]
|
|
||||||
assert path == "/chat/approve"
|
|
||||||
assert body == {
|
|
||||||
"conversation_id": "conversation-1",
|
|
||||||
"token": "secret",
|
|
||||||
"decisions": {"run_snippet": False},
|
|
||||||
}
|
|
||||||
assert client_kwargs["base_url"] == "http://localhost:8000"
|
|
||||||
|
|
||||||
|
|
||||||
def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
|
|
||||||
pytest.importorskip("textual")
|
|
||||||
import nexusos_cli.tui_app as tui_app
|
|
||||||
|
|
||||||
stream_started = threading.Event()
|
|
||||||
release_stream = threading.Event()
|
|
||||||
denied_for = []
|
|
||||||
|
|
||||||
class _StreamResponse:
|
|
||||||
status_code = 200
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def aiter_lines(self):
|
|
||||||
stream_started.set()
|
|
||||||
await asyncio.to_thread(release_stream.wait, 2)
|
|
||||||
yield "event: tool_request"
|
|
||||||
yield 'data: {"token":"secret","actions":[{"name":"run_snippet"}]}'
|
|
||||||
yield ""
|
|
||||||
yield "event: done"
|
|
||||||
yield "data: {}"
|
|
||||||
|
|
||||||
class _StreamClient:
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def stream(self, *args, **kwargs):
|
|
||||||
return _StreamResponse()
|
|
||||||
|
|
||||||
def _capture_denial(*, conversation_id, **kwargs):
|
|
||||||
denied_for.append(conversation_id)
|
|
||||||
return ["run_snippet"]
|
|
||||||
|
|
||||||
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
|
|
||||||
monkeypatch.setattr(tui_app, "_deny_tool_request", _capture_denial)
|
|
||||||
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with app.run_test():
|
|
||||||
app._start_chat("run it")
|
|
||||||
assert await asyncio.to_thread(stream_started.wait, 2)
|
|
||||||
original_id = app.conversation_id
|
|
||||||
app._handle_slash("/new")
|
|
||||||
assert app.conversation_id is None
|
|
||||||
release_stream.set()
|
|
||||||
for _ in range(200):
|
|
||||||
if not app._busy:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
assert app._busy is False
|
|
||||||
assert denied_for == [original_id]
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
|
|
||||||
pytest.importorskip("textual")
|
|
||||||
import nexusos_cli.tui_app as tui_app
|
|
||||||
|
|
||||||
first_stream_started = threading.Event()
|
|
||||||
|
|
||||||
class _StreamResponse:
|
|
||||||
status_code = 200
|
|
||||||
|
|
||||||
def __init__(self, call_number):
|
|
||||||
self.call_number = call_number
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def aiter_lines(self):
|
|
||||||
if self.call_number == 1:
|
|
||||||
first_stream_started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
yield "data: \"READY\""
|
|
||||||
yield ""
|
|
||||||
yield "event: done"
|
|
||||||
yield "data: {}"
|
|
||||||
|
|
||||||
class _StreamClient:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def stream(self, *args, **kwargs):
|
|
||||||
type(self).calls += 1
|
|
||||||
return _StreamResponse(type(self).calls)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
|
|
||||||
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
|
||||||
|
|
||||||
async def _wait_until_idle():
|
|
||||||
for _ in range(100):
|
|
||||||
if not app._busy:
|
|
||||||
return
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
pytest.fail("stream did not become idle within one second")
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
app._start_chat("first")
|
|
||||||
assert await asyncio.to_thread(first_stream_started.wait, 2)
|
|
||||||
await pilot.press("ctrl+c")
|
|
||||||
await _wait_until_idle()
|
|
||||||
|
|
||||||
log = app.query_one("#log")
|
|
||||||
assert any("interrupt requested" in line.text for line in log.lines)
|
|
||||||
assert not any("ReadTimeout" in line.text for line in log.lines)
|
|
||||||
|
|
||||||
app._start_chat("second")
|
|
||||||
await _wait_until_idle()
|
|
||||||
assert app.history[-1] == {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "READY",
|
|
||||||
}
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
def test_escape_round_trip_helper():
|
|
||||||
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")
|
|
||||||
Reference in New Issue
Block a user