Author SHA1 Message Date
AthenaandCursor 193e91b234 fix(runtime): improve local service reliability
Close SQLite handles safely on Windows, clean orphaned vectors, normalize Ollama endpoints, and surface model errors without leaking reasoning tags.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 17:32:42 -05:00
AthenaandCursor a6e2ec1136 feat(preview): add sandboxed live code previews
Render validated HTML, SVG, JSX, and TSX fences locally while preserving tool context and preventing explanatory JSON from triggering actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 17:32:17 -05:00
133 changed files with 5168 additions and 8113 deletions
-1
View File
@@ -1 +0,0 @@
*.sh text eol=lf
-74
View File
@@ -1,74 +0,0 @@
name: package
on:
push:
branches: [main, code-preview]
tags: ["v*"]
pull_request:
jobs:
wheel:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: interface/web/package-lock.json
- name: Build and test web UI
working-directory: interface/web
run: |
npm ci
npm run lint
npm test
npm run build
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
- name: Test Python runtime
run: |
python -m pip install -e ".[dev]"
python -m pytest -q tests management
for f in scripts/install-termux.sh bin/check.sh management/nexus-cli.sh; do bash -n "$f"; done
- name: Build wheel and sdist
run: |
python -m build
python -m twine check dist/*
- name: Verify clean wheel install
run: |
python -m venv "$RUNNER_TEMP/nexus-wheel"
"$RUNNER_TEMP/nexus-wheel/bin/python" -m pip install dist/*.whl
cd "$RUNNER_TEMP"
export NEXUS_HOME="$RUNNER_TEMP/nexus-home"
export NEXUS_CONFIG_DIR="$RUNNER_TEMP/nexus-config"
"$RUNNER_TEMP/nexus-wheel/bin/nexus" init --json
"$RUNNER_TEMP/nexus-wheel/bin/nexus" doctor --json
"$RUNNER_TEMP/nexus-wheel/bin/python" -c "from synapse.main import sio_app; assert sio_app"
# The wheel must carry the compiled UI, not just import cleanly.
"$RUNNER_TEMP/nexus-wheel/bin/python" - <<'PY'
from synapse.nexus_config import settings
index = settings.web_dist_dir / "index.html"
assert index.is_file(), f"wheel shipped no web UI at {index}"
PY
- uses: actions/upload-artifact@v4
with:
name: nexusos-python-dist
path: dist/*
- name: Publish tagged release to PyPI
if: startsWith(gitea.ref, 'refs/tags/v')
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: python -m twine upload --non-interactive dist/*
-3
View File
@@ -2,9 +2,6 @@ Promethean/
ollama/
interface/web/node_modules/
interface/web/dist/
/.build-check/
/dist/
/*.egg-info/
runtime/
__pycache__/
*.pyc
+15 -42
View File
@@ -10,43 +10,20 @@ NexusOS is a local AI assistant platform. It runs a Python/FastAPI backend (Syna
NexusOS runs **single-process**: the Synapse backend on port 8000 serves the
built web UI (`interface/web/dist`) itself, so there is no separate Vite server
at runtime. Ollama is not started on backend process startup itself, but `ncp
start`/`start -b` bring it up right after via the `/ollama/start` endpoint; the
sidebar **Start/Stop AI** button and `ncp start --ai` remain for toggling it
independently once the backend is already up.
at runtime. Ollama is started manually (sidebar **Start AI** / `nexus-cli.sh
start --ai`), not on backend startup.
**Windows (recommended):**
```powershell
powershell -ExecutionPolicy Bypass -File .\install-windows.ps1 # one-time native install
ncp web # backend :8000 + UI
```
`ncp` comes from `management\ncp.cmd`, which the installer puts on the machine
PATH — open a NEW shell after installing. `.\launch_nexus.ps1` is what the
desktop shortcut runs and still works directly.
**Linux install / update:**
```bash
./install.sh # wrapper over `python3 bin/sync.py restore`
./install.sh --check # dry run
./install.sh --no-desktop # skip the XFCE wiring (test clones, non-XFCE boxes)
ncp web # memory :8001 + backend :8000 + UI
```
**Linux full stack (dev):**
```bash
./launch_nexus.sh
```
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.
This activates the `Promethean` venv and starts the memory service on port 8001 and 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.
**Individual services via CLI:**
```bash
@@ -54,7 +31,10 @@ absent; that gets full Metal GPU acceleration with no extra config.
source Promethean/bin/activate
# Backend (serves the built UI at :8000 too)
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload
# Memory service
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload
# Frontend DEV server (hot-reload) — only when editing the UI; production is the
# built dist/ served by the backend. Run `npm run build` to refresh dist/.
@@ -68,7 +48,7 @@ access to the same features as the web UI (all via the REST API on `:8000`):
./management/nexus-cli.sh stop
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
# Feature commands (dispatch to nexusos_cli/nexus_api.py — httpx, no TUI):
# Feature commands (dispatch to management/nexus_api.py — httpx, no TUI):
ncp chat "<message>" # stream a reply (POST /chat/stream)
ncp memory list|add <text>|rm <id>
ncp playbook list|show <id> # first playbook (*) is the active system prompt
@@ -77,15 +57,12 @@ ncp history [query] # recent conversations
The old curses TUIs (`nexus-chat.py`, `nexus-playbook.py`) were removed in favor of
these API-backed subcommands. The CLI covers chat, memory, playbooks, and history;
the web UI and control panel expose the remaining management features.
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
`bin/panel/nexus-popup.py`) stays.
**Checks (the release gate):**
```bash
./bin/check.sh # pytest + eslint + frontend tests + .ps1/.sh parse + wheel build
./bin/check.sh # pytest (tests/ + management/) + eslint + .ps1 parse check
```
There is no hosted CI — the remote is self-hosted Gitea with no act_runner — so
this script *is* the gate. Run it before tagging a release.
@@ -103,15 +80,13 @@ cd interface/web && npm run build
## Architecture
### 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). 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.
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, and a thin overlay pins the right PyTorch build for the target`requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, or CPU/Windows requirements from the host.
### Synapse Backend (`synapse/`)
FastAPI app at `synapse/main.py`. Key responsibilities:
- `/chat/stream` — chat with Ollama; streaming uses SSE (the only chat endpoint — the non-stream `/chat` was removed). When a conversation goes idle the stream endpoint hands it to the in-process curator, which extracts persistent facts.
- `/chat/stream` — chat with Ollama; streaming uses SSE (the only chat endpoint — the non-stream `/chat` was removed). After each exchange the stream endpoint calls the Memory Service to auto-extract persistent facts.
- `/playbooks` — CRUD for playbooks stored as YAML files in `data/playbooks/` via `synapse/playbooks/store.py`.
- `/memory` — CRUD for persistent facts, backed by the same SQLite store the curator writes to.
- `/memory` — CRUD for persistent facts (proxies the same SQLite store as the memory service).
- `/models` — lists, pulls, and deletes Ollama models by proxying Ollama's HTTP API.
- `/settings` and `/ollama` — persist runtime settings and control Ollama lifecycle.
- `/conversations` — persists, retrieves, edits, deletes, and exports full chat history from SQLite.
@@ -119,8 +94,8 @@ FastAPI app at `synapse/main.py`. Key responsibilities:
**System prompt assembly** (in `main.py` `chat_stream_endpoint`): the final system prompt is built by layering the active playbook instructions → reference playbook context → persistent memory facts → relevant past conversation snippets retrieved by `store.search_conversations`.
### Memory (`synapse/memory/`)
Runs **in-process** — there is no separate service and no second model. `curator.py` reads the messages a conversation has added since its watermark, `extractor.py` asks the chat model which permanent facts they contain, and `store.py` merges the results into `memory.db`. The backend schedules it when a conversation goes idle (`_pending_extractions` in `main.py`), so a half-said fact is never persisted mid-thought. The old :8001 FastAPI app held a second, smaller model that could not share the GPU with the chat model; the chat model is already resident, so the extra hop bought nothing.
### Memory Service (`synapse/memory/`)
A separate FastAPI app on port 8001. `service.py` exposes `/memories/extract` which calls `extractor.py` — an Ollama prompt that decides whether to persist a new fact from a conversation exchange. The main Synapse backend calls this asynchronously after each streaming response. Both services share the same SQLite database (`synapse/memory/memory.db`).
### Playbook System (`synapse/playbooks/` + `synapse/playbook_manager.py`)
Playbooks are ordered records (title, goal, instructions, tags), each persisted as a `{id}.yaml` file in `data/playbooks/` by `PlaybookFileStore` (the dir is `PLAYBOOK_DIR` in `nexus_config.py`). The **first** playbook by order is the active system prompt; all subsequent playbooks are injected as reference context. `PlaybookManager` is the thin class the backend uses to retrieve them and assemble the system prompt.
@@ -151,5 +126,3 @@ Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory
| Python dependencies (AMD/ROCm) | `requirements-amd.txt` |
| Python dependencies (NVIDIA/CUDA) | `requirements-nvidia.txt` (generated by `bin/gen-nvidia-reqs.py`) |
| Python dependencies (Windows/CPU) | `requirements-windows.txt` |
| Python dependencies (optional local ML/torch) | `requirements-ml.txt` (opt-in, not installed by default) |
+17 -74
View File
@@ -5,10 +5,8 @@
# NexusOS
**A local-first AI assistant platform.** Runs entirely on your machine — a
Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
persistent memory service, and a React frontend. Ollama is local by default;
Termux and container installs can explicitly point at a separately managed
endpoint.
Python/FastAPI backend, a bundled Ollama instance for inference, a persistent
memory service, and a React frontend. No external AI provider is called.
</div>
@@ -17,12 +15,10 @@ endpoint.
## What it is
NexusOS ("Nexus") is a self-hosted assistant you actually own. All inference
runs through **Ollama** on `localhost` by default; conversations, facts, and
settings live in local SQLite. It ships with desktop branding (XFCE theme,
runs through a **locally bundled Ollama** on `localhost`; conversations, facts,
and settings live in local SQLite. It ships with desktop branding (XFCE theme,
icons, boot splash) so it can be run as a full assistant environment on Linux,
not just a web app. A remote Ollama-compatible URL is an explicit configuration
option for lightweight clients; NexusOS never starts or stops that remote
process.
not just a web app.
Chat with vision and voice, persistent memory, tool-using playbooks, document
RAG scoped to Projects, gated action tools, and full model management — see
@@ -92,29 +88,6 @@ NexusOS runs **single-process**: the backend on `:8000` serves the built web UI
itself, so there's no separate frontend server at runtime. Ollama is started
manually from the app (**Start AI** in the sidebar), not at boot.
### Python package and portable CLI
The portable package installs `nexus`, `ncp`, and `nexusos` as equivalent
commands. From a checkout today:
```bash
cd interface/web && npm ci && npm run build && cd ../.. # compile the UI
python -m pip install -e ".[standard]"
nexus init
nexus doctor
nexus serve
```
After a package release, the install becomes `python -m pip install
"nexusos-ai[standard]"`. The wheel includes the compiled web UI and default
playbooks; it keeps writable state outside `site-packages`. See
[the CLI reference](docs/CLI.md) for commands, configuration, and dependency
profiles.
Termux uses the base package with a remote Ollama-compatible provider. Its
bootstrap and the current Android native-wheel gate are documented in
[the Termux guide](docs/TERMUX.md).
### Linux
Nexus was built using an Apple T2 computer running Linux Mint XFCE. The desktop
@@ -126,7 +99,7 @@ Linux distro, but I only support the Ubuntu/Linux Mint package base.
# system packages, Ollama binary and the XFCE desktop wiring.
./install.sh
# 2. Launch (backend :8000 — it also serves the built UI)
# 2. Launch (memory :8001, backend :8000 — backend also serves the built UI)
# The install symlinks ncp into /usr/local/bin (sudo); open a new shell first.
ncp web
```
@@ -134,8 +107,7 @@ ncp web
Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one
GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA).
`requirements-windows.txt` is the standalone CPU-only runtime (no base overlay).
macOS uses `requirements-base.txt` directly, no overlay — see the macOS section
below. `bin/sync.py` picks the right one for the host.
`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
and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme
@@ -173,41 +145,14 @@ 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
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
```bash
# Linux / macOS
# Linux
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 0.0.0.0 --port 8000 --reload # backend (serves the UI too)
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload # memory
# Frontend dev server (hot-reload) — only needed when editing the UI;
# production serves the built dist/ from the backend at :8000.
@@ -218,7 +163,8 @@ cd interface/web && npm run dev
# Windows — needs the RemoteSigned policy from the Quick start step above
Promethean\Scripts\Activate.ps1
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload # backend (serves the UI too)
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload # memory
```
### Promethean
@@ -228,7 +174,7 @@ Nexus's dependencies out of the system Python. To add a package, activate it
and `pip install` as usual:
```bash
# Linux / macOS
# Linux
source Promethean/bin/activate
pip install <package>
```
@@ -270,7 +216,7 @@ ncp help # see complete help tree
|---|---|---|
| **Promethean** (venv) | `Promethean/` | The Python venv all backend code runs in — `source Promethean/bin/activate` (Linux) / `Promethean\Scripts\python.exe` (Windows). Keeps deps out of the system Python. |
| **Synapse** (backend) | `synapse/` | FastAPI app: `/chat/stream` (+ `/chat/approve` for gated tool calls), `/playbooks`, `/memory`, `/models`, `/documents`, `/projects`, `/conversations`, `/stt`, `/logs`, `/settings`, `/ollama`, `/frontend`, `/icons`. Assembles the system prompt: active playbook → reference playbooks → memory facts → relevant past snippets → matching documents → web search results. |
| **Memory** | `synapse/memory/` | In-process, no second service or model: `curator.py` reads a conversation once it goes idle, `extractor.py` asks the chat model what is worth keeping, `store.py` merges it into the SQLite DB. |
| **Memory service** | `synapse/memory/` | Separate FastAPI app (:8001). `/memories/extract` uses an Ollama prompt to decide what to persist. Shares the SQLite DB with the backend. |
| **Documents / RAG** | `synapse/memory/store.py` | PDF/DOCX/TXT/MD ingest, chunked and embedded, retrieved via a sqlite-vec index; scoped per **Project** workspace. |
| **Action tools** | `synapse/tools.py`, `synapse/search.py` | Read-only tools (search memory/history/documents, list models, get time) run automatically; `web_search`, `fetch_url`, and `remember` require per-call approval from the chat UI. |
| **Playbooks** | `synapse/playbooks/` + `data/playbooks/` | Ordered `{id}.yaml` records managed by `PlaybookManager`; each can pin a chat model and a tool list. |
@@ -287,11 +233,10 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
## Layout
- `synapse/` — FastAPI backend + memory curator + playbook/ollama managers
- `synapse/` — FastAPI backend + memory service + playbook/ollama managers
- `modules/` — auto-discovered feature plugins
- `interface/web/` — React + Vite frontend
- `nexusos_cli/` — the portable CLI the wheel ships (`nexus`/`ncp`/`nexusos`)
- `management/` — nexus-cli.sh wrapper, control panel, desktop theme
- `management/` — nexus-cli.sh, ncp API client, control panel, desktop theme
- `bin/` — install, backup/restore, panel + provisioning scripts
- `assets/` — branding: icons, boot splash, XFCE/GTK theme
- `data/playbooks/` — active playbook YAML
@@ -303,9 +248,7 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
- **Filesystem paths** — `synapse/nexus_config.py`
- **Frontend API base URL** — `interface/web/src/config.js`
- **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay;
`requirements-windows.txt` = standalone CPU runtime; macOS uses
`requirements-base.txt` with no overlay (Ollama, not this venv, does
inference — natively, with Metal)
`requirements-windows.txt` = standalone CPU runtime
## Issues and feature requests
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.0.0
@@ -7,7 +7,7 @@ X-KDE-ServiceTypes=org.kde.kwin.decoration
[KWin]
Type=Aurorae
X-KDE-PluginInfo-Author=NexusOS
X-KDE-PluginInfo-Author=Jon
X-KDE-PluginInfo-Email=
X-KDE-PluginInfo-Name=NexusOS
X-KDE-PluginInfo-Version=1.0
+8 -167
View File
@@ -12,10 +12,8 @@ KDE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$KDE/../../.." && pwd)"
NO_SDDM=0
FORCE_PANEL=0
for arg in "$@"; do
[[ "$arg" == "--no-sddm" ]] && NO_SDDM=1
[[ "$arg" == "--panel" ]] && FORCE_PANEL=1
done
echo "NexusOS KDE installer — repo: $REPO"
@@ -26,11 +24,13 @@ mkdir -p \
~/.local/share/plasma/desktoptheme \
~/.local/share/color-schemes \
~/.local/share/konsole \
~/.local/share/kscreenlocker/themes \
~/.config/Kvantum
# ── 2. Symlink theme directories (update automatically on git pull) ──────
ln -sfn "$KDE/aurorae/NexusOS" ~/.local/share/aurorae/themes/NexusOS
ln -sfn "$KDE/plasma/NexusOS" ~/.local/share/plasma/desktoptheme/NexusOS
ln -sfn "$KDE/kscreenlocker/NexusOS" ~/.local/share/kscreenlocker/themes/NexusOS
ln -sfn "$KDE/kvantum/NexusOS" ~/.config/Kvantum/NexusOS
echo " [ok] theme symlinks"
@@ -80,25 +80,10 @@ else
echo " [skip] plasma-apply-desktoptheme not found — apply desktop theme manually"
fi
# ── 8. Lock screen ───────────────────────────────────────────────────────
# Plasma 5.27 draws the lock screen from a look-and-feel package's
# contents/lockscreen, and [Greeter]Theme names that PACKAGE. The old value here
# ("NexusOS") was not a look-and-feel id, so it silently fell back to Breeze.
# The standalone kscreenlocker theme that value referred to targeted an earlier
# kscreenlocker API that 5.27 no longer loads, and has been deleted.
#
# Breeze's lock UI is deliberately kept rather than replaced: a lock screen that
# fails to load is one you cannot get back through. Only the wallpaper is ours,
# which is what carries the brushed-metal look.
# ── 8. kscreenlocker ─────────────────────────────────────────────────────
if command -v kwriteconfig5 &>/dev/null; then
kwriteconfig5 --file kscreenlockerrc --group Greeter --key Theme org.kde.breeze.desktop
LOCK_BG="$KDE/sddm/NexusOS-QML/assets/background.png"
if [[ -f "$LOCK_BG" ]]; then
kwriteconfig5 --file kscreenlockerrc \
--group Greeter --group Wallpaper --group org.kde.image --group General \
--key Image "$LOCK_BG"
echo " [ok] lock screen wallpaper: NexusOS brushed metal"
fi
kwriteconfig5 --file kscreenlockerrc --group Greeter --key Theme NexusOS
echo " [ok] kscreenlocker theme: NexusOS"
fi
# ── 9. GTK apps under Plasma ─────────────────────────────────────────────
@@ -121,7 +106,7 @@ if [[ $NO_SDDM -eq 0 ]]; then
sudo cp -r "$KDE/sddm/NexusOS-QML" /usr/share/sddm/themes/
sudo cp "$REPO/management/sessions/nexusos-kde.desktop" /usr/share/xsessions/
if command -v kwriteconfig5 &>/dev/null; then
sudo kwriteconfig5 --file /etc/sddm.conf.d/nexusos.conf --group Theme --key Current NexusOS-QML
sudo kwriteconfig5 --file /etc/sddm.conf --group Theme --key Current NexusOS-QML
else
# Fallback: write the INI directly
sudo bash -c 'printf "[Theme]\nCurrent=NexusOS-QML\n" > /etc/sddm.conf.d/nexusos.conf'
@@ -140,158 +125,14 @@ if [[ -f "$DESKTOP_SRC" ]]; then
echo " [ok] Promethean Terminal launcher updated"
fi
# ── 11b. Global Theme + wallpaper ────────────────────────────────────────
# Copied, NOT symlinked, unlike the theme dirs above: KPackage silently skips a
# symlinked package directory, so a symlinked look-and-feel never appears in
# `lookandfeeltool -l` or System Settings at all. Aurorae/desktoptheme/Kvantum
# read their directories directly and are fine as symlinks.
LNF_SRC="$KDE/look-and-feel/com.nexusos.desktop"
WALL_SRC="$KDE/wallpaper/NexusOS"
if [[ -d "$LNF_SRC" ]]; then
mkdir -p ~/.local/share/plasma/look-and-feel ~/.local/share/wallpapers
rm -rf ~/.local/share/plasma/look-and-feel/com.nexusos.desktop
cp -rL "$LNF_SRC" ~/.local/share/plasma/look-and-feel/
rm -rf ~/.local/share/wallpapers/NexusOS
cp -rL "$WALL_SRC" ~/.local/share/wallpapers/
echo " [ok] Global Theme + wallpaper packages installed"
if command -v lookandfeeltool &>/dev/null; then
# Verify registration before applying. KPackage reports nothing when it
# rejects a package, so without this check a botched install is silent and
# you only find out by opening System Settings.
if lookandfeeltool -l 2>/dev/null | grep -qx "com.nexusos.desktop"; then
lookandfeeltool -a com.nexusos.desktop 2>/dev/null \
&& echo " [ok] Global Theme applied (splash, colors, decoration)"
else
echo " [WARN] com.nexusos.desktop did not register with KPackage."
echo " Most likely it was symlinked instead of copied -- KPackage"
echo " skips symlinked package directories silently."
fi
fi
if command -v plasma-apply-wallpaperimage &>/dev/null; then
plasma-apply-wallpaperimage ~/.local/share/wallpapers/NexusOS &>/dev/null \
&& echo " [ok] wallpaper applied"
fi
fi
# ── 11c. Panel layout + dock ─────────────────────────────────────────────
# The XFCE panel rebuilt with native Plasma widgets, plus Plank for the dock
# (its config and NexusOS theme already exist under assets/themes/restore-snapshot).
#
# Applied ONCE, guarded by a marker file. This rewrites the panel from scratch,
# so re-running it on every restore would wipe any widget you added since. Pass
# --panel to force it, e.g. after a fresh install or to undo panel experiments.
PANEL_MARKER="$HOME/.config/nexusos-panel-applied"
PANEL_JS="$KDE/panel-layout.js"
if [[ -f "$PANEL_JS" ]] && { [[ ! -f "$PANEL_MARKER" ]] || [[ $FORCE_PANEL -eq 1 ]]; }; then
if qdbus org.kde.plasmashell /PlasmaShell evaluateScript \
"$(sed "s|__NEXUS_ROOT__|$REPO|g" "$PANEL_JS")" &>/dev/null; then
touch "$PANEL_MARKER"
echo " [ok] panel layout applied (top, 36px, Nexus launcher)"
else
echo " [skip] panel layout — plasmashell not running"
fi
elif [[ -f "$PANEL_MARKER" ]]; then
echo " [ok] panel layout already applied (--panel to redo)"
fi
# Plank autostart. Plank is a GTK X11 app and runs fine under Plasma; this is
# the same dock, config and theme the XFCE session used.
if command -v plank &>/dev/null; then
mkdir -p ~/.config/autostart
# Symlink, matching bin/panel/install.sh. cp fails outright when the target is
# already a symlink back to this same file ("are the same file"), which made
# the step look like it had silently done nothing.
ln -sfn "$REPO/management/autostart/plank.desktop" ~/.config/autostart/plank.desktop \
&& echo " [ok] Plank autostart installed"
pgrep -x plank &>/dev/null || { setsid plank </dev/null &>/dev/null & disown; }
fi
# ── 11d. Custom shortcut: region screenshot to clipboard ─────────────────
# Meta+Shift+S -> `xfce4-screenshooter -rc`, the equivalent of the XFCE binding.
# Spectacle is not installed by kde-plasma-desktop, and xfce4-screenshooter
# works fine under Plasma, so this keeps the tool that is already here.
#
# Written as ONE atomic replace rather than a series of kwriteconfig5 calls,
# because khotkeys watches khotkeysrc and rewrites it from its own model on
# every change.
#
# The UUID below is FIXED, not generated. kglobalaccel records the key binding
# in kglobalshortcutsrc against the action's UUID, so a fresh UUID on each run
# cannot take a key that the previous run's UUID already holds: the entry gets
# registered with no shortcut at all, and every re-run leaves another dead
# registration behind. A stable UUID means a re-run re-claims its own binding.
if ! grep -q "^Key=Meta+Shift+S$" ~/.config/khotkeysrc 2>/dev/null; then
python3 - <<'PYEOF'
import pathlib, re
# Fixed so re-runs reclaim the same kglobalaccel registration.
SHORTCUT_UUID = "87a4ba1e-4f01-4a79-9cd1-b73c17d459ea"
p = pathlib.Path.home() / ".config" / "khotkeysrc"
s = p.read_text() if p.exists() else "[Data]\nDataCount=0\n"
# Drop any half-written entry from an earlier failed attempt.
for m in re.findall(r"\[Data_(\d+)\]\n(?:[^\[]*)", s):
blk = re.search(r"\[Data_%s\]\n((?:[^\[]*))" % m, s)
if blk and "Screenshot region to clipboard" in blk.group(1):
s = re.sub(r"\n\[Data_%s[^\]]*\]\n(?:[^\[]*)" % m, "\n", s)
count = int(re.search(r"\[Data\]\nDataCount=(\d+)", s).group(1))
n = count + 1
s = re.sub(r"(\[Data\]\nDataCount=)\d+", r"\g<1>%d" % n, s, count=1)
s = s.rstrip("\n") + """
[Data_%d]
Comment=Select a region and copy it straight to the clipboard
Enabled=true
Name=Screenshot region to clipboard
Type=SIMPLE_ACTION_DATA
[Data_%dActions]
ActionsCount=1
[Data_%dActions0]
CommandURL=xfce4-screenshooter -rc
Type=COMMAND_URL
[Data_%dConditions]
Comment=
ConditionsCount=0
[Data_%dTriggers]
Comment=Simple_action
TriggersCount=1
[Data_%dTriggers0]
Key=Meta+Shift+S
Type=SHORTCUT
Uuid={%s}
""" % (n, n, n, n, n, n, SHORTCUT_UUID)
tmp = p.with_suffix(".nexustmp")
tmp.write_text(s)
tmp.replace(p)
PYEOF
qdbus org.kde.kded5 /modules/khotkeys reread_configuration &>/dev/null \
&& echo " [ok] Meta+Shift+S -> region screenshot to clipboard"
else
echo " [ok] screenshot shortcut already bound"
fi
# ── 12. Apply live changes (if Plasma is running) ────────────────────────
if qdbus org.kde.KWin /KWin reconfigure 2>/dev/null; then
echo " [ok] KWin reconfigured"
# Restart plasmashell to pick up new shell theme.
# setsid + closed stdio is load-bearing, not tidiness: the restarted shell
# outlives this script, and if it inherits our stdout it holds the write end
# of the caller's pipe open forever. `ncp restore` captures output, so a
# plain `kstart5 plasmashell &` hangs the entire restore after the theme is
# already applied -- which looks like a failure and isn't one.
# Restart plasmashell to pick up new shell theme
if command -v kquitapp5 &>/dev/null && command -v kstart5 &>/dev/null; then
kquitapp5 plasmashell 2>/dev/null
sleep 1
setsid kstart5 plasmashell </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
kstart5 plasmashell &
echo " [ok] plasmashell restarted"
fi
fi
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1200" width="1920" height="1200">
<defs>
<linearGradient id="metalBase" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1"/>
<stop offset="35%" style="stop-color:#16213e;stop-opacity:1"/>
<stop offset="65%" style="stop-color:#0f1626;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#0a0e1a;stop-opacity:1"/>
</linearGradient>
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
<stop offset="0%" style="stop-color:transparent;stop-opacity:0"/>
<stop offset="100%" style="stop-color:#000000;stop-opacity:0.6"/>
</radialGradient>
<pattern id="brushLines" x="0" y="0" width="4" height="4" patternUnits="userSpaceOnUse">
<line x1="0" y1="4" x2="4" y2="0" stroke="#ffffff" stroke-width="0.3" opacity="0.03"/>
</pattern>
<pattern id="machineLines" x="0" y="0" width="100" height="3" patternUnits="userSpaceOnUse">
<line x1="0" y1="1.5" x2="100" y2="1.5" stroke="#ffffff" stroke-width="0.2" opacity="0.025"/>
</pattern>
</defs>
<rect width="1920" height="1200" fill="url(#metalBase)"/>
<rect width="1920" height="1200" fill="url(#brushLines)"/>
<rect width="1920" height="1200" fill="url(#machineLines)"/>
<ellipse cx="960" cy="340" rx="1200" ry="280" fill="#ffffff" opacity="0.012"/>
<ellipse cx="960" cy="220" rx="400" ry="170" fill="#00d4ff" opacity="0.015"/>
<rect width="1920" height="1200" fill="url(#vignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
</group>
</kcfg>
@@ -0,0 +1,268 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import org.kde.plasma.core 2.0 as PlasmaCore
/*
* NexusOS kscreenlocker theme.
* Mirrors the SDDM NexusOS-QML aesthetic: logo upper-center,
* clock/date bottom-center, centered password box.
*
* kscreenlocker API differences from SDDM:
* - authenticator.tryUnlock(password) instead of sddm.login(...)
* - authenticator.failed / authenticator.succeeded signals
* - No session selector, no power buttons
* - walletModel, userModel available but not mandatory
*/
Rectangle {
id: root
// kscreenlocker injects these from the surrounding PlasmaShell context
property bool locked: true
readonly property color accentColor: "#8cc63f" // brand_green
readonly property color bgDark: "#1e1526" // base_bg
readonly property color textPrimary: "#f2f2f2"
readonly property color textMuted: "#a8a8a8"
readonly property color fieldBg: "#1f2225" // surface_bg
readonly property color fieldBorder: "#4d3461" // menu_border
readonly property color errorColor: "#da4453"
anchors.fill: parent
color: bgDark
// ── Background image (reuses SDDM background asset) ────────────────
Image {
anchors.fill: parent
source: Qt.resolvedUrl("../../assets/background.svg")
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: false
}
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: 0.30
}
// ── Logo area ───────────────────────────────────────────────────────
Item {
id: logoArea
width: 100
height: 100
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: parent.height * 0.10
Rectangle {
anchors.centerIn: parent
width: parent.width * 1.6
height: parent.height * 1.6
radius: width / 2
color: accentColor
opacity: 0.06
}
Image {
anchors.fill: parent
source: Qt.resolvedUrl("../../assets/logo.png")
sourceSize: Qt.size(200, 200)
smooth: true
}
}
Text {
text: "NexusOS"
color: textPrimary
font.family: "Sans"
font.pixelSize: 26
font.letterSpacing: 6
font.weight: Font.Light
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: logoArea.bottom
anchors.topMargin: 14
}
// ── Password box ────────────────────────────────────────────────────
Rectangle {
id: loginBox
width: 340
height: loginColumn.height + 56
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
color: Qt.rgba(30/255, 21/255, 38/255, 0.80)
radius: 12
border.color: fieldBorder
border.width: 1
SequentialAnimation {
id: shakeAnim
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x - 10; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x + 10; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x - 6; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x + 6; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x; duration: 40 }
}
Column {
id: loginColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 28
spacing: 12
// Username label (read-only; kscreenlocker always locks current session)
Text {
width: parent.width
text: kscreenlocker_userName || userModel.data(userModel.index(0, 0), Qt.DisplayRole) || ""
color: textPrimary
font.pixelSize: 14
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Password field
Rectangle {
width: parent.width
height: 44
color: fieldBg
radius: 6
border.color: passwordInput.activeFocus ? accentColor : fieldBorder
border.width: 1
TextInput {
id: passwordInput
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
verticalAlignment: TextInput.AlignVCenter
color: textPrimary
font.pixelSize: 14
echoMode: TextInput.Password
focus: true
clip: true
Keys.onReturnPressed: authenticator.tryUnlock(passwordInput.text)
Keys.onEnterPressed: authenticator.tryUnlock(passwordInput.text)
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 14
text: "Password"
color: textMuted
font.pixelSize: 14
visible: passwordInput.text.length === 0 && !passwordInput.activeFocus
}
}
// Error message
Text {
id: errorMsg
width: parent.width
text: ""
color: errorColor
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
visible: text !== ""
}
// Unlock button
Rectangle {
id: unlockButton
width: parent.width
height: 44
radius: 6
color: unlockMouse.pressed
? Qt.darker(accentColor, 1.3)
: unlockMouse.containsMouse
? Qt.lighter(accentColor, 1.15)
: accentColor
Behavior on color { ColorAnimation { duration: 150 } }
Text {
anchors.centerIn: parent
text: "UNLOCK"
color: "#0a0a00" // text_on_accent
font.pixelSize: 14
font.letterSpacing: 3
font.weight: Font.DemiBold
}
MouseArea {
id: unlockMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: authenticator.tryUnlock(passwordInput.text)
}
}
}
}
// ── Authenticator connections ───────────────────────────────────────
Connections {
target: authenticator
function onFailed() {
errorMsg.text = "Incorrect password — try again."
passwordInput.text = ""
passwordInput.forceActiveFocus()
shakeAnim.start()
}
function onSucceeded() {
errorMsg.text = ""
}
function onGraceLockedChanged() {}
function onMessage(msg) {}
function onError(err) {
errorMsg.text = err
}
}
// ── Clock / date (bottom) ───────────────────────────────────────────
Column {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: parent.height * 0.05
spacing: 2
Text {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
color: textPrimary
font.family: "Sans"
font.pixelSize: 42
font.weight: Font.Thin
}
Text {
id: dateText
anchors.horizontalCenter: parent.horizontalCenter
color: textMuted
font.family: "Sans"
font.pixelSize: 14
font.letterSpacing: 2
}
Timer {
interval: 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
var d = new Date()
clockText.text = Qt.formatTime(d, "hh:mm")
dateText.text = Qt.formatDate(d, "dddd, MMMM d")
}
}
}
}
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=NexusOS
Comment=NexusOS lock screen — dark purple, lime-green accents
Type=Service
X-KDE-ServiceTypes=org.kde.kscreenlocker.Greeter
[ScreenLocker]
Name=NexusOS
Description=NexusOS lock screen — dark purple, lime-green accents
MainScript=contents/ui/LockScreenUi.qml
@@ -45,30 +45,6 @@ large.icon.size=32
button.icon.size=16
toolbar.icon.size=16
[GeneralColors]
# Palette. Without this section Kvantum falls back to its light default for
# view backgrounds, which rendered item views (e.g. the Add Bluetooth Device
# device list) as light-grey text on white. Values mirror ~/.config/kdeglobals.
window.color=#1e1526
base.color=#1e1526
alt.base.color=#1f2225
button.color=#2a2e32
light.color=#3a3d41
mid.light.color=#2e3236
dark.color=#140d1a
mid.color=#241a2d
highlight.color=#88008f
inactive.highlight.color=#5e0066
text.color=#f2f2f2
window.text.color=#f2f2f2
button.text.color=#f2f2f2
disabled.text.color=#6e7173
tooltip.text.color=#f2f2f2
highlight.text.color=#ffffff
link.color=#b8e373
link.visited.color=#a232a8
progress.indicator.text.color=#f2f2f2
[PanelButtonCommand]
inherits=PanelButtonTool
frame=false
@@ -1,25 +0,0 @@
[kdeglobals][KDE]
widgetStyle=kvantum-dark
[kdeglobals][General]
ColorScheme=NexusOS
[kdeglobals][Icons]
Theme=NexusOS
[plasmarc][Theme]
name=NexusOS
[Wallpaper]
Image=NexusOS
[kwinrc][org.kde.kdecoration2]
library=org.kde.kwin.aurorae
theme=__aurorae__svg__NexusOS
[kscreenlockerrc][Greeter]
Theme=org.kde.breeze.desktop
[KSplash]
Theme=com.nexusos.desktop
Engine=KSplashQML
@@ -1,95 +0,0 @@
/*
NexusOS Plasma startup splash.
Deliberately plain QtQuick -- no PlasmaCore import. ksplashqml runs this
before the session is up, and the fewer modules it has to resolve at that
point the fewer ways it has to fail; sizes are proportional to the screen
instead of Units.gridUnit for the same reason.
ksplashqml drives `stage` from 0 to 6 as the session comes up, which fills
the progress bar. The content is deliberately opaque from the start rather
than faded in on stage 2 the way Breeze's splash does: if that signal never
arrives -- as under `ksplashqml --test` -- a stage-gated splash shows nothing
but a blank coloured screen. Palette matches assets/themes/_palette.py.
*/
import QtQuick 2.15
Rectangle {
id: root
color: "#1e1526"
property int stage
onStageChanged: {
if (stage == 6) {
outroAnimation.running = true;
}
}
// Same brushed-metal ground as the Plymouth, SDDM and lock screens. Raster,
// not the source SVG: QtSvg is Tiny 1.2 and drops the <pattern> textures.
Image {
anchors.fill: parent
source: "images/background.png"
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: false
}
Item {
id: content
anchors.fill: parent
Image {
id: logo
anchors.horizontalCenter: parent.horizontalCenter
y: parent.height * 0.32
source: "images/logo.png"
width: Math.round(parent.width * 0.15)
fillMode: Image.PreserveAspectFit
smooth: true
asynchronous: false
}
Text {
id: brand
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: logo.bottom
anchors.topMargin: Math.round(parent.height * 0.03)
text: "NexusOS"
color: "#f2f2f2"
font.family: "Sans"
font.pixelSize: Math.round(parent.height * 0.026)
font.letterSpacing: 6
font.weight: Font.Light
}
Rectangle {
id: track
anchors.horizontalCenter: parent.horizontalCenter
y: Math.round(parent.height * 0.62)
width: Math.round(parent.width * 0.18)
height: 3
radius: height / 2
color: "#2e3236"
Rectangle {
height: parent.height
radius: parent.radius
color: "#8cc63f"
width: parent.width * Math.min(root.stage / 6, 1)
Behavior on width {
NumberAnimation { duration: 250; easing.type: Easing.InOutQuad }
}
}
}
}
OpacityAnimator {
id: outroAnimation
target: content
from: 1; to: 0
duration: 400
running: false
}
}
@@ -1 +0,0 @@
../../../../../sddm/NexusOS-QML/assets/background.png
@@ -1,22 +0,0 @@
{
"KPlugin": {
"Authors": [
{
"Name": "NexusOS"
}
],
"Category": "",
"Description": "Dark purple NexusOS desktop with lime-green accents",
"Id": "com.nexusos.desktop",
"License": "GPL",
"Name": "NexusOS",
"ServiceTypes": [
"Plasma/LookAndFeel"
],
"Version": "1.0",
"Website": ""
},
"X-Plasma-APIVersion": "2",
"X-Plasma-MainScript": "defaults",
"KPackageStructure": "Plasma/LookAndFeel"
}
-63
View File
@@ -1,63 +0,0 @@
/*
NexusOS Plasma panel layout — the XFCE panel, rebuilt with native widgets.
Applied through Plasma's scripting API rather than by editing
plasma-org.kde.plasma.desktop-appletsrc directly: the file is owned by a
running plasmashell, which rewrites it from memory on exit and would
clobber anything written underneath it. Run with:
qdbus org.kde.plasmashell /PlasmaShell evaluateScript "$(cat panel-layout.js)"
Source layout is assets/themes/restore-snapshot/xfconf-xml/xfce4-panel.xml:
a 36px full-width top panel, Whisker menu at the far left with the Nexus N
as its button, everything else pushed to the right — tray, volume,
bluetooth, network, battery, clock.
The XFCE panel carried no window list; Plank showed running apps. So this
adds no task manager either, and the dock keeps that job.
Widget mapping from the XFCE plugins:
whiskermenu -> org.kde.plasma.kickoff (same N button icon)
separator (expand) -> org.kde.plasma.panelspacer
systray + notify -> org.kde.plasma.systemtray (notifications live in it)
genmon network -> networkmanagement, inside the tray
genmon bluetooth -> bluetooth, inside the tray
pulseaudio -> volume, inside the tray
power-manager -> battery, inside the tray
clock -> org.kde.plasma.digitalclock
genmon nexus -> not a panel widget; the dock keeps nexus-core
*/
var panel = panelById(panelIds[0]);
if (!panel) {
panel = new Panel;
}
panel.location = "top";
panel.height = 36; // xfce4-panel.xml: size=36
panel.alignment = "left";
panel.hiding = "none";
// Start from a known state so re-running is idempotent rather than additive.
var existing = panel.widgetIds;
for (var i = 0; i < existing.length; i++) {
var w = panel.widgetById(existing[i]);
if (w) { w.remove(); }
}
var launcher = panel.addWidget("org.kde.plasma.kickoff");
launcher.currentConfigGroup = ["General"];
// __NEXUS_ROOT__ is substituted by install-plasma.sh so a clone in another
// directory, or another user, gets a working path rather than this box's.
launcher.writeConfig("icon", "__NEXUS_ROOT__/assets/n-small.png");
launcher.writeConfig("favoritesPortedToKAstats", true);
panel.addWidget("org.kde.plasma.panelspacer");
var tray = panel.addWidget("org.kde.plasma.systemtray");
var clock = panel.addWidget("org.kde.plasma.digitalclock");
clock.currentConfigGroup = ["Appearance"];
clock.writeConfig("showDate", true);
panel.reloadConfig();
@@ -6,7 +6,7 @@ Type=Service
[KPackageStructure]
X-KDE-ServiceTypes=Plasma/Theme
X-KDE-PluginInfo-Author=NexusOS
X-KDE-PluginInfo-Author=Jon
X-KDE-PluginInfo-Email=
X-KDE-PluginInfo-Name=NexusOS
X-KDE-PluginInfo-Version=1.0
+2 -11
View File
@@ -31,16 +31,7 @@ Rectangle {
Image {
anchors.fill: parent
// background.png, not the .svg it was rendered from: QtSvg implements
// SVG Tiny 1.2, which has no <pattern>, so the brushed-metal and
// machine-line textures silently drop out ("Could not resolve property:
// #brushLines"). librsvg renders them fine, so the SVG is rasterized at
// 1920x1200 and committed. Regenerate after editing background.svg:
// python3 -c 'import gi; gi.require_version("GdkPixbuf","2.0"); \
// from gi.repository import GdkPixbuf as G; \
// G.Pixbuf.new_from_file_at_scale("assets/background.svg",1920,1200,False) \
// .savev("assets/background.png","png",["compression"],["9"])'
source: "assets/background.png"
source: "assets/background.svg"
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: false
@@ -80,7 +71,7 @@ Rectangle {
Text {
id: brandLabel
text: "Welcome to NexusOS"
text: "NexusOS"
color: textPrimary
font.family: "Sans"
font.pixelSize: 28
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

@@ -1,7 +1,7 @@
[SddmGreeterTheme]
Name=NexusOS-QML
Description=Dark brushed-metal lockscreen theme with flat N logo, upper-center branding, and centered login box.
Author=NexusOS
Author=Jon
Version=1.0
Website=
Screenshot=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

@@ -1,10 +0,0 @@
{
"KPlugin": {
"Authors": [{ "Name": "NexusOS" }],
"Id": "NexusOS",
"License": "GPL",
"Name": "NexusOS",
"Version": "1.0"
},
"X-Plasma-APIVersion": "2"
}
+655 -73
View File
@@ -1,120 +1,702 @@
[Icon Theme]
Name=NexusOS
Comment=NexusOS accent icon theme
# Inherit breeze-dark, not Papirus-Dark. Breeze's icons carry
# ColorScheme-Text / ColorScheme-Highlight classes that Plasma recolours from
# the active colour scheme, so everything this theme does not override picks up
# the NexusOS palette on its own. Papirus hardcodes its blues (#5294e2), which
# no colour scheme can touch — inheriting it left the whole un-themed surface
# permanently blue. hicolor last, as the freedesktop fallback of last resort.
Inherits=breeze-dark,hicolor
# Only directories that actually exist. This list previously declared 124
# sections against 21 real directories, most of them copied from Papirus.
Directories=16x16/apps,16x16/places,16x16/actions,22x22/apps,22x22/places,22x22/actions,24x24/apps,24x24/places,24x24/actions,32x32/apps,32x32/places,32x32/actions,48x48/apps,48x48/places,48x48/actions,64x64/apps,64x64/places,64x64/actions,128x128/apps,128x128/places,128x128/actions
Inherits=Papirus-Dark,hicolor
Directories=16x16/apps,22x22/apps,24x24/apps,32x32/apps,48x48/apps,64x64/apps,128x128/apps,16x16/places,22x22/places,24x24/places,32x32/places,48x48/places,64x64/places,128x128/places,16x16/actions,22x22/actions,24x24/actions,32x32/actions,48x48/actions,64x64/actions,128x128/actions,scalable/status,8x8/emblems,16x16/devices,16x16/emblems,16x16/emotes,16x16/mimetypes,16x16/panel,16x16/status,16x16@2x/actions,16x16@2x/apps,16x16@2x/devices,16x16@2x/emblems,16x16@2x/emotes,16x16@2x/mimetypes,16x16@2x/panel,16x16@2x/places,16x16@2x/status,18x18/actions,18x18@2x/actions,22x22/animations,22x22/devices,22x22/emblems,22x22/emotes,22x22/mimetypes,22x22/panel,22x22/status,22x22@2x/actions,22x22@2x/animations,22x22@2x/apps,22x22@2x/devices,22x22@2x/emblems,22x22@2x/emotes,22x22@2x/mimetypes,22x22@2x/panel,22x22@2x/places,22x22@2x/status,24x24/animations,24x24/devices,24x24/emblems,24x24/emotes,24x24/mimetypes,24x24/panel,24x24/status,24x24@2x/actions,24x24@2x/animations,24x24@2x/apps,24x24@2x/devices,24x24@2x/emblems,24x24@2x/emotes,24x24@2x/mimetypes,24x24@2x/panel,24x24@2x/places,24x24@2x/status,32x32/devices,32x32/emblems,32x32/emotes,32x32/mimetypes,32x32/status,32x32@2x/actions,32x32@2x/apps,32x32@2x/devices,32x32@2x/emblems,32x32@2x/emotes,32x32@2x/mimetypes,32x32@2x/places,32x32@2x/status,42x42/apps,48x48/devices,48x48/emblems,48x48/emotes,48x48/mimetypes,48x48/status,48x48@2x/actions,48x48@2x/apps,48x48@2x/devices,48x48@2x/emblems,48x48@2x/emotes,48x48@2x/mimetypes,48x48@2x/places,48x48@2x/status,64x64/devices,64x64/mimetypes,64x64@2x/apps,64x64@2x/devices,64x64@2x/mimetypes,64x64@2x/places,84x84/apps,96x96/apps,96x96/devices,96x96/mimetypes,96x96/places,128x128/devices,128x128/mimetypes,symbolic/actions,symbolic/apps,symbolic/devices,symbolic/emblems,symbolic/emotes,symbolic/mimetypes,symbolic/places,symbolic/status,symbolic/up-to-32
[16x16/apps]
Context=Applications
Size=16
Type=Fixed
[16x16/places]
Context=Places
Size=16
Type=Fixed
[16x16/actions]
Context=Actions
Size=16
Type=Fixed
[22x22/apps]
Context=Applications
Size=22
Type=Fixed
[22x22/places]
Context=Places
Size=22
Type=Fixed
[22x22/actions]
Context=Actions
Size=22
Type=Fixed
[24x24/apps]
Context=Applications
Size=24
Type=Fixed
[24x24/places]
Context=Places
Size=24
Type=Fixed
[24x24/actions]
Context=Actions
Size=24
Type=Fixed
[32x32/apps]
Context=Applications
Size=32
Type=Fixed
[32x32/places]
Context=Places
Size=32
Type=Fixed
[32x32/actions]
Context=Actions
Size=32
Type=Fixed
[48x48/apps]
Context=Applications
Size=48
Type=Fixed
[48x48/places]
Context=Places
Size=48
Type=Fixed
[48x48/actions]
Context=Actions
Size=48
Type=Fixed
[64x64/apps]
Context=Applications
Size=64
Type=Fixed
[128x128/apps]
Context=Applications
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[16x16/places]
Context=Places
Size=16
Type=Fixed
[22x22/places]
Context=Places
Size=22
Type=Fixed
[24x24/places]
Context=Places
Size=24
Type=Fixed
[32x32/places]
Context=Places
Size=32
Type=Fixed
[48x48/places]
Context=Places
Size=48
Type=Fixed
[64x64/places]
Context=Places
Size=64
Type=Fixed
[64x64/actions]
Context=Actions
Size=64
Type=Fixed
[128x128/apps]
Context=Applications
Size=128
Type=Fixed
[128x128/places]
Context=Places
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[16x16/actions]
Context=Actions
Size=16
Type=Fixed
[22x22/actions]
Context=Actions
Size=22
Type=Fixed
[24x24/actions]
Context=Actions
Size=24
Type=Fixed
[32x32/actions]
Context=Actions
Size=32
Type=Fixed
[48x48/actions]
Context=Actions
Size=48
Type=Fixed
[64x64/actions]
Size=64
Context=Actions
Type=Fixed
[128x128/actions]
Context=Actions
Size=128
Context=Actions
Type=Fixed
[scalable/status]
Size=16
Context=Status
MinSize=8
MaxSize=512
Type=Scalable
[8x8/emblems]
Context=Emblems
Size=8
Type=Fixed
[16x16/devices]
Context=Devices
Size=16
Type=Fixed
[16x16/emblems]
Context=Emblems
Size=16
Type=Fixed
[16x16/emotes]
Context=Emotes
Size=16
Type=Fixed
[16x16/mimetypes]
Context=MimeTypes
Size=16
Type=Fixed
[16x16/panel]
Context=Status
Size=16
Type=Fixed
[16x16/status]
Context=Status
Size=16
Type=Fixed
[16x16@2x/actions]
Context=Actions
Size=16
Scale=2
Type=Fixed
[16x16@2x/apps]
Context=Applications
Size=16
Scale=2
Type=Fixed
[16x16@2x/devices]
Context=Devices
Size=16
Scale=2
Type=Fixed
[16x16@2x/emblems]
Context=Emblems
Size=16
Scale=2
Type=Fixed
[16x16@2x/emotes]
Context=Emotes
Size=16
Scale=2
Type=Fixed
[16x16@2x/mimetypes]
Context=MimeTypes
Size=16
Scale=2
Type=Fixed
[16x16@2x/panel]
Context=Status
Size=16
Scale=2
Type=Fixed
[16x16@2x/places]
Context=Places
Size=16
Scale=2
Type=Fixed
[16x16@2x/status]
Context=Status
Size=16
Scale=2
Type=Fixed
[18x18/actions]
Context=Actions
Size=18
Type=Fixed
[18x18@2x/actions]
Context=Actions
Size=18
Scale=2
Type=Fixed
[22x22/animations]
Context=Animations
Size=22
Type=Fixed
[22x22/devices]
Context=Devices
Size=22
Type=Fixed
[22x22/emblems]
Context=Emblems
Size=22
Type=Fixed
[22x22/emotes]
Context=Emotes
Size=22
Type=Fixed
[22x22/mimetypes]
Context=MimeTypes
Size=22
Type=Fixed
[22x22/panel]
Context=Status
Size=22
Type=Fixed
[22x22/status]
Context=Status
Size=22
Type=Fixed
[22x22@2x/actions]
Context=Actions
Size=22
Scale=2
Type=Fixed
[22x22@2x/animations]
Context=Animations
Size=22
Scale=2
Type=Fixed
[22x22@2x/apps]
Context=Applications
Size=22
Scale=2
Type=Fixed
[22x22@2x/devices]
Context=Devices
Size=22
Scale=2
Type=Fixed
[22x22@2x/emblems]
Context=Emblems
Size=22
Scale=2
Type=Fixed
[22x22@2x/emotes]
Context=Emotes
Size=22
Scale=2
Type=Fixed
[22x22@2x/mimetypes]
Context=MimeTypes
Size=22
Scale=2
Type=Fixed
[22x22@2x/panel]
Context=Status
Size=22
Scale=2
Type=Fixed
[22x22@2x/places]
Context=Places
Size=22
Scale=2
Type=Fixed
[22x22@2x/status]
Context=Status
Size=22
Scale=2
Type=Fixed
[24x24/animations]
Context=Animations
Size=24
Type=Fixed
[24x24/devices]
Context=Devices
Size=24
Type=Fixed
[24x24/emblems]
Context=Emblems
Size=24
Type=Fixed
[24x24/emotes]
Context=Emotes
Size=24
Type=Fixed
[24x24/mimetypes]
Context=MimeTypes
Size=24
Type=Fixed
[24x24/panel]
Context=Status
Size=24
Type=Fixed
[24x24/status]
Context=Status
Size=24
Type=Fixed
[24x24@2x/actions]
Context=Actions
Size=24
Scale=2
Type=Fixed
[24x24@2x/animations]
Context=Animations
Size=24
Scale=2
Type=Fixed
[24x24@2x/apps]
Context=Applications
Size=24
Scale=2
Type=Fixed
[24x24@2x/devices]
Context=Devices
Size=24
Scale=2
Type=Fixed
[24x24@2x/emblems]
Context=Emblems
Size=24
Scale=2
Type=Fixed
[24x24@2x/emotes]
Context=Emotes
Size=24
Scale=2
Type=Fixed
[24x24@2x/mimetypes]
Context=MimeTypes
Size=24
Scale=2
Type=Fixed
[24x24@2x/panel]
Context=Status
Size=24
Scale=2
Type=Fixed
[24x24@2x/places]
Context=Places
Size=24
Scale=2
Type=Fixed
[24x24@2x/status]
Context=Status
Size=24
Scale=2
Type=Fixed
[32x32/devices]
Context=Devices
Size=32
Type=Fixed
[32x32/emblems]
Context=Emblems
Size=32
Type=Fixed
[32x32/emotes]
Context=Emotes
Size=32
Type=Fixed
[32x32/mimetypes]
Context=MimeTypes
Size=32
Type=Fixed
[32x32/status]
Context=Status
Size=32
Type=Fixed
[32x32@2x/actions]
Context=Actions
Size=32
Scale=2
Type=Fixed
[32x32@2x/apps]
Context=Applications
Size=32
Scale=2
Type=Fixed
[32x32@2x/devices]
Context=Devices
Size=32
Scale=2
Type=Fixed
[32x32@2x/emblems]
Context=Emblems
Size=32
Scale=2
Type=Fixed
[32x32@2x/emotes]
Context=Emotes
Size=32
Scale=2
Type=Fixed
[32x32@2x/mimetypes]
Context=MimeTypes
Size=32
Scale=2
Type=Fixed
[32x32@2x/places]
Context=Places
Size=32
Scale=2
Type=Fixed
[32x32@2x/status]
Context=Status
Size=32
Scale=2
Type=Fixed
[42x42/apps]
Context=Applications
Size=42
Type=Fixed
[48x48/devices]
Context=Devices
Size=48
Type=Fixed
[48x48/emblems]
Context=Emblems
Size=48
Type=Fixed
[48x48/emotes]
Context=Emotes
Size=48
Type=Fixed
[48x48/mimetypes]
Context=MimeTypes
Size=48
Type=Fixed
[48x48/status]
Context=Status
Size=48
MinSize=48
MaxSize=512
Type=Scalable
[48x48@2x/actions]
Context=Actions
Size=48
Scale=2
Type=Fixed
[48x48@2x/apps]
Context=Applications
Size=48
Scale=2
Type=Fixed
[48x48@2x/devices]
Context=Devices
Size=48
Scale=2
Type=Fixed
[48x48@2x/emblems]
Context=Emblems
Size=48
Scale=2
Type=Fixed
[48x48@2x/emotes]
Context=Emotes
Size=48
Scale=2
Type=Fixed
[48x48@2x/mimetypes]
Context=MimeTypes
Size=48
Scale=2
Type=Fixed
[48x48@2x/places]
Context=Places
Size=48
Scale=2
Type=Fixed
[48x48@2x/status]
Context=Status
Size=48
MinSize=48
MaxSize=512
Scale=2
Type=Scalable
[64x64/devices]
Context=Devices
Size=64
Type=Fixed
[64x64/mimetypes]
Context=MimeTypes
Size=64
Type=Fixed
[64x64@2x/apps]
Context=Applications
Size=64
Scale=2
Type=Fixed
[64x64@2x/devices]
Context=Devices
Size=64
Scale=2
Type=Fixed
[64x64@2x/mimetypes]
Context=MimeTypes
Size=64
Scale=2
Type=Fixed
[64x64@2x/places]
Context=Places
Size=64
Scale=2
Type=Fixed
[84x84/apps]
Context=Applications
Size=84
Type=Fixed
[96x96/apps]
Context=Applications
Size=96
Type=Fixed
[96x96/devices]
Context=Devices
Size=96
Type=Fixed
[96x96/mimetypes]
Context=MimeTypes
Size=96
Type=Fixed
[96x96/places]
Context=Places
Size=96
Type=Fixed
[128x128/devices]
Context=Devices
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[128x128/mimetypes]
Context=MimeTypes
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[symbolic/actions]
Context=Actions
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/apps]
Context=Applications
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/devices]
Context=Devices
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/emblems]
Context=Emblems
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/emotes]
Context=Emotes
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/mimetypes]
Context=MimeTypes
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/places]
Context=Places
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/status]
Context=Status
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/up-to-32]
Context=Status
Size=16
MinSize=16
MaxSize=32
Type=Scalable
+3 -11
View File
@@ -112,11 +112,8 @@ KDE/
Main.qml # QML login UI — NexusOS purple/green palette
assets/background.svg assets/logo.png
look-and-feel/com.nexusos.desktop/ # Global Theme: selects every component
contents/defaults # what System Settings applies in one click
contents/splash/Splash.qml # startup splash, matches Plymouth/SDDM
wallpaper/NexusOS/ # desktop + lock wallpaper (brushed metal)
kscreenlocker/NexusOS/ # Runtime screen lock (Meta+L in KDE)
contents/ui/LockScreenUi.qml # matches SDDM aesthetic; kscreenlocker API
konsole/
NexusOS.colorscheme # general terminal colors
@@ -149,12 +146,7 @@ cp assets/themes/KDE/konsole/*.colorscheme assets/themes/KDE/konsole/Promethean.
# SDDM palette (already live; installer will redeploy if needed)
```
**Needs KDE session:** Aurorae decoration, Plasma shell theme, Global Theme.
The lock screen is Breeze's, with the NexusOS wallpaper set on it. Plasma 5.27
draws the lock screen from a look-and-feel package's `contents/lockscreen`, and
replacing that QML risks a lock screen you cannot get back through, so only the
background is ours.
**Needs KDE session:** Aurorae decoration, Plasma shell theme, kscreenlocker.
### First boot into Plasma
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 249 KiB

+1 -9
View File
@@ -74,12 +74,7 @@ echo "NexusOS theme — restoring wiring from $REPO"
echo "[1/4] Symlinks"
link "$REPO/assets/themes/NexusOS" "$HOME/.themes/NexusOS"
# Icons go in BOTH places on purpose. ~/.icons is the GTK/XFCE legacy path and
# is all this used to install; Qt/KF5 searches XDG data dirs only, so under
# Plasma the theme was never found and every icon silently fell back to Breeze
# (`kiconfinder5 nexusos-logo` returned nothing at all).
link "$REPO/assets/themes/NexusOS-icons" "$HOME/.icons/NexusOS"
link "$REPO/assets/themes/NexusOS-icons" "$HOME/.local/share/icons/NexusOS"
link "$REPO/assets/themes/gtk3-user-overrides.css" "$HOME/.config/gtk-3.0/gtk.css"
echo "[2/4] xfconf (xsettings + xfwm4)"
@@ -103,10 +98,7 @@ echo "[4/4] Icon caches"
gtk-update-icon-cache -f -t "$HOME/.icons/NexusOS" 2>/dev/null \
&& say "ok NexusOS icon cache rebuilt" \
|| say "WARN NexusOS icon cache rebuild failed"
# Inherits is a comma-separated list, so check the first (primary) parent
# rather than the whole string — treating "breeze-dark,hicolor" as one theme
# name found no such directory and warned about a fallback that is installed.
INH="$(grep -i '^Inherits=' "$REPO/assets/themes/NexusOS-icons/index.theme" 2>/dev/null | cut -d= -f2 | cut -d, -f1)"
INH="$(grep -i '^Inherits=' "$REPO/assets/themes/NexusOS-icons/index.theme" 2>/dev/null | cut -d= -f2)"
if [[ -n "$INH" ]]; then
INH_OK=0
for dir in /usr/share/icons ~/.icons ~/.local/share/icons; do
-39
View File
@@ -1,39 +0,0 @@
# NexusOS dock theme — matches the XFCE top panel.
# Colors are taken straight from xfce4-panel's background-rgba
# (0.588, 0.152, 0.773, 0.294) scaled to 0-255: 150;;39;;197;;75.
# Flat fill (start == end) because the panel has no gradient.
[PlankTheme]
TopRoundness=10
BottomRoundness=10
LineWidth=1
OuterStrokeColor=150;;39;;197;;110
FillStartColor=150;;39;;197;;75
FillEndColor=150;;39;;197;;75
InnerStrokeColor=150;;39;;197;;40
# Padding is NOT pixels: Plank renders it as value * icon_size / 10. These are
# tuned for the 48px icons set in dconf — 5px top/bottom, 10px left/right.
# Change icon-size and the borders scale with it; recompute as px * 10 / icon_size.
[PlankDockTheme]
HorizPadding=2.0833
TopPadding=1.0417
BottomPadding=1.0417
ItemPadding=2.0
IndicatorSize=6.0
IconShadowSize=1.0
UrgentBounceHeight=1.6667
LaunchBounceHeight=0.625
FadeOpacity=1.0
ClickTime=300
UrgentBounceTime=600
LaunchBounceTime=600
ActiveTime=300
SlideTime=300
FadeTime=250
HideTime=250
GlowSize=30
GlowTime=10000
GlowPulseTime=2000
UrgentHueShift=150
ItemMoveTime=450
CascadeHide=true
@@ -1,28 +0,0 @@
[Desktop Entry]
Name=Visual Studio Code
Comment=Code Editing. Redefined.
GenericName=Text Editor
Exec=/usr/share/code/code %F
Icon=vscode
Type=Application
StartupNotify=false
StartupWMClass=code
Categories=TextEditor;Development;IDE;
MimeType=application/x-code-workspace;
Actions=new-empty-window;
Keywords=vscode;
[Desktop Action new-empty-window]
Name=New Empty Window
Name[cs]=Nové prázdné okno
Name[de]=Neues leeres Fenster
Name[es]=Nueva ventana vacía
Name[fr]=Nouvelle fenêtre vide
Name[it]=Nuova finestra vuota
Name[ja]=新しい空のウィンドウ
Name[ko]=새 빈 창
Name[ru]=Новое пустое окно
Name[zh_CN]=新建空窗口
Name[zh_TW]=開新空視窗
Exec=/usr/share/code/code --new-window %F
Icon=vscode
@@ -1,9 +0,0 @@
[Desktop Entry]
Type=Application
Name=NexusOS
Comment=Launch the NexusOS assistant (memory + backend + UI)
Exec=/home/jon/nexus-core/management/nexus-app.sh
Icon=/home/jon/nexus-core/assets/n-small.png
Terminal=false
Categories=Utility;
StartupNotify=false
@@ -1,10 +0,0 @@
[Desktop Entry]
Type=Application
Name=Promethean Terminal
Comment=Launch a terminal with the Promethean environment
Exec=/home/jon/.local/kitty.app/bin/kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf --class promethean-terminal -e bash --rcfile /home/jon/nexus-core/.promethean_bashrc -i
Icon=/home/jon/nexus-core/assets/promethean-terminal.png
Terminal=false
Categories=Utility;TerminalEmulator;
StartupNotify=false
StartupWMClass=promethean-terminal
@@ -1,6 +1,15 @@
[Settings]
gtk-font-name=Ubuntu 10
gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=true
gtk-button-images=true
gtk-cursor-theme-name=DMZ-White
gtk-cursor-theme-size=24
gtk-decoration-layout=close,minimize,maximize:
gtk-enable-animations=true
gtk-font-name=Ubuntu 10
gtk-icon-theme-name=NexusOS
gtk-menu-images=true
gtk-modules=colorreload-gtk-module
gtk-primary-button-warps-slider=false
gtk-theme-name=NexusOS
gtk-toolbar-style=3
gtk-xft-dpi=98304
@@ -1,6 +1,12 @@
[Settings]
gtk-font-name=Ubuntu 10
gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=true
gtk-cursor-theme-name=DMZ-White
gtk-cursor-theme-size=24
gtk-decoration-layout=close,minimize,maximize:
gtk-enable-animations=true
gtk-font-name=Ubuntu 10
gtk-icon-theme-name=NexusOS
gtk-modules=colorreload-gtk-module
gtk-primary-button-warps-slider=false
gtk-theme-name=NexusOS
gtk-xft-dpi=98304
@@ -1,19 +0,0 @@
[docks/dock1]
alignment='center'
auto-pinning=true
current-workspace-only=false
dock-items=['microsoft-edge.dockitem', 'promethean-terminal.dockitem', 'nexus-core.dockitem', 'code.dockitem', 'thunar.dockitem']
hide-delay=0
hide-mode='intelligent'
icon-size=48
items-alignment='center'
lock-items=false
offset=0
pinned-only=false
pressure-reveal=false
show-dock-item=false
theme='NexusOS'
tooltips-enabled=true
unhide-delay=0
zoom-enabled=false
zoom-percent=150
@@ -1,2 +1,2 @@
[PlankDockItemPreferences]
Launcher=file:///usr/share/applications/microsoft-edge.desktop
Launcher=file:///home/jon/.local/share/applications/microsoft-edge.desktop
@@ -0,0 +1,2 @@
[PlankDockItemPreferences]
Launcher=file:///home/jon/.local/share/applications/nexus-activate.desktop
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.1" encoding="UTF-8"?>
<channel name="xfce4-desktop" version="1.0">
<property name="backdrop" type="empty">
<property name="screen0" type="empty">
<property name="monitor0" type="empty">
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="empty"/>
<property name="last-single-image" type="empty"/>
<property name="brightness" type="empty"/>
<property name="image-show" type="bool" value="true"/>
@@ -13,13 +13,13 @@
</property>
<property name="monitor1" type="empty">
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="empty"/>
<property name="last-single-image" type="empty"/>
<property name="brightness" type="empty"/>
</property>
<property name="monitor2" type="empty">
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="empty"/>
<property name="last-single-image" type="empty"/>
<property name="brightness" type="empty"/>
</property>
@@ -32,17 +32,17 @@
<property name="workspace1" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="string" value="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
</property>
<property name="workspace2" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="string" value="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
</property>
<property name="workspace3" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="string" value="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
</property>
</property>
<property name="monitoreDP-1" type="empty">
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.1" encoding="UTF-8"?>
<channel name="xfce4-panel" version="1.0">
<property name="configver" type="int" value="2"/>
@@ -38,14 +38,7 @@
<property name="dark-mode" type="bool" value="true"/>
</property>
<property name="plugins" type="empty">
<property name="plugin-1" type="empty"/>
<property name="plugin-2" type="string" value="power-manager-plugin"/>
<property name="plugin-3" type="empty">
<property name="items" type="empty"/>
</property>
<property name="plugin-4" type="empty">
<property name="items" type="empty"/>
</property>
<property name="plugin-5" type="string" value="separator">
<property name="items" type="array">
<value type="string" value="17640946043.desktop"/>
@@ -88,11 +81,15 @@
</property>
<property name="button-icon" type="string" value="/home/jon/nexus-core/assets/n-small.png"/>
<property name="recent" type="array">
<value type="string" value="microsoft-edge.desktop"/>
<value type="string" value="xfce4-taskmanager.desktop"/>
<value type="string" value="virtualbox.desktop"/>
<value type="string" value="virt-manager.desktop"/>
<value type="string" value="org.gnome.DiskUtility.desktop"/>
<value type="string" value="org.x.editor.desktop"/>
<value type="string" value="promethean-terminal.desktop"/>
<value type="string" value="claude-usage-widget.desktop"/>
<value type="string" value="kitty.desktop"/>
<value type="string" value="putty.desktop"/>
<value type="string" value="code.desktop"/>
</property>
<property name="view-mode" type="int" value="2"/>
@@ -110,7 +107,6 @@
<property name="show-notifications" type="bool" value="true"/>
<property name="known-players" type="string" value="Microsoft Edge"/>
</property>
<property name="plugin-9" type="empty"/>
<property name="plugin-10" type="string" value="notification-plugin"/>
<property name="plugin-11" type="empty"/>
<property name="plugin-12" type="string" value="clock">
@@ -122,13 +118,8 @@
<property name="digital-date-format" type="string" value="%m/%d/%Y"/>
<property name="digital-date-font" type="string" value="Sans 8"/>
</property>
<property name="plugin-13" type="string" value="genmon">
<property name="digital-time-format" type="empty"/>
<property name="digital-layout" type="empty"/>
<property name="digital-date-font" type="empty"/>
<property name="digital-time-font" type="empty"/>
</property>
<property name="plugin-14" type="empty"/>
<property name="plugin-13" type="string" value="genmon"/>
<property name="plugin-15" type="string" value="genmon"/>
<property name="plugin-16" type="string" value="genmon"/>
</property>
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.1" encoding="UTF-8"?>
<channel name="xsettings" version="1.0">
<property name="Net" type="empty">
@@ -27,8 +27,6 @@
<property name="MonospaceFontName" type="empty"/>
<property name="IconSizes" type="empty"/>
<property name="KeyThemeName" type="empty"/>
<property name="ToolbarStyle" type="empty"/>
<property name="ToolbarIconSize" type="empty"/>
<property name="MenuImages" type="bool" value="false"/>
<property name="ButtonImages" type="empty"/>
<property name="MenuBarAccel" type="empty"/>
@@ -37,6 +35,8 @@
<property name="DecorationLayout" type="string" value="icon,menu:minimize,maximize,close"/>
<property name="DialogsUseHeader" type="empty"/>
<property name="TitlebarMiddleClick" type="empty"/>
<property name="ToolbarStyle" type="empty"/>
<property name="ToolbarIconSize" type="empty"/>
<property name="IMPreeditStyle" type="empty"/>
<property name="IMStatusStyle" type="empty"/>
<property name="IMModule" type="empty"/>
-12
View File
@@ -51,18 +51,6 @@ if [ -d "$HOME/.config/plank" ]; then
mkdir -p "$snap/plank"
cp -rf "$HOME/.config/plank/." "$snap/plank/" 2>/dev/null || true
fi
# The .dockitem files alone are NOT the dock. Everything else — item list, theme,
# icon size, position, hide mode — lives in dconf, and on startup Plank DELETES
# every .dockitem not in its list, so copying files alone restores nothing.
# `monitor` is stripped: it's per-machine, and plank-primary-watch.sh sets it.
dconf dump /net/launchpad/plank/ 2>/dev/null | grep -v '^monitor=' > "$snap/plank-dconf.ini" || true
# Dock launchers point at .desktop files in ~/.local/share/applications, which
# is outside the repo — Plank prunes any item whose target is missing.
mkdir -p "$snap/applications"
for d in $(sed -n 's#.*applications/\([^.]*\.desktop\).*#\1#p' \
"$snap"/plank/dock1/launchers/*.dockitem 2>/dev/null); do
cp -f "$HOME/.local/share/applications/$d" "$snap/applications/$d" 2>/dev/null || true
done
# Copy the Claude memory notes (machine knowledge under ~/.claude) into the
# repo so they're versioned too. Some aren't Nexus-specific (Fusion 360, etc.).
-118
View File
@@ -1,118 +0,0 @@
#!/bin/bash
# NexusOS boot-time branding: the Plymouth boot splash and the LightDM greeter
# (which is also what you see on lock -- light-locker just switches to it).
#
# Both are system-level and need root, which is why this is a standalone script
# rather than part of assets/themes/install-theme.sh.
#
# Usage: sudo bin/boot-branding.sh [--uninstall]
set -euo pipefail
NEXUS_ROOT="${NEXUS_ROOT:-$(cd "$(dirname "$(realpath "$0")")/.." && pwd)}"
# Plymouth. A real copy is placed under /usr/share/plymouth/themes -- NOT a
# symlink into /home. The stock initramfs plymouth hook copies the theme path
# verbatim, so a symlink into an unmounted /home dangles at early boot and
# Plymouth falls back to text mode. That is what
# assets/boot/initramfs-hook-my-custom-logo exists to work around; with a real
# copy the hook is unnecessary.
SRC="$NEXUS_ROOT/assets/boot/my-custom-logo"
DEST=/usr/share/plymouth/themes/my-custom-logo
THEME="$DEST/my-custom-logo.plymouth"
# Greeter. slick-greeter cannot lay out a custom login screen the way the
# NexusOS-QML SDDM theme does, so the logo and wordmark are baked into the
# background image instead and the greeter's own centered login box lands under
# them. The SVG is used as-is: gdk-pixbuf's SVG loader is installed, so there is
# nothing to rasterize. Copied out of the repo because the greeter runs as user
# `lightdm` and the user home is 0750, so nothing in here is readable to it.
GREETER_ART="$NEXUS_ROOT/assets/themes/greeter/greeter-background.svg"
GREETER_DIR=/usr/share/nexusos
GREETER_CONF=/etc/lightdm/slick-greeter.conf
# SDDM. Its greeter is a QML app, so unlike slick-greeter it can draw the real
# layout (logo, wordmark, login box, clock) -- this is the theme the design was
# written for. Installed whenever sddm is present, regardless of which display
# manager is currently active, so switching between the two needs no re-run.
SDDM_SRC="$NEXUS_ROOT/assets/themes/KDE/sddm/NexusOS-QML"
SDDM_DEST=/usr/share/sddm/themes/NexusOS-QML
SDDM_CONF=/etc/sddm.conf.d/nexusos.conf
[ "$(id -u)" -eq 0 ] || { echo "run with sudo"; exit 1; }
if [ "${1:-}" = "--uninstall" ]; then
update-alternatives --remove default.plymouth "$THEME" 2>/dev/null || true
rm -rf "$DEST" "$GREETER_DIR" "$GREETER_CONF" "$SDDM_DEST" "$SDDM_CONF"
command -v update-initramfs >/dev/null && update-initramfs -u
echo "reverted to the distro defaults"
exit 0
fi
# --- Plymouth boot splash -----------------------------------------------------
if [ ! -d "$SRC" ]; then
echo "missing theme source: $SRC -- skipping boot splash"
elif ! command -v update-initramfs >/dev/null || [ ! -d /usr/share/plymouth/themes ]; then
echo "no Plymouth on this box -- skipping boot splash"
# update-initramfs is slow, and a restore runs this every time. Bail out when the
# installed copy already matches the repo and the alternative already points here.
elif diff -rq "$SRC" "$DEST" >/dev/null 2>&1 &&
[ "$(readlink -f /usr/share/plymouth/themes/default.plymouth)" = "$THEME" ]; then
echo "boot splash already installed"
else
rm -rf "$DEST"
cp -aL "$SRC" "$DEST"
chown -R root:root "$DEST"
chmod -R a+rX,go-w "$DEST"
update-alternatives --install /usr/share/plymouth/themes/default.plymouth \
default.plymouth "$THEME" 300
update-alternatives --set default.plymouth "$THEME"
update-initramfs -u
echo "boot splash installed"
fi
# --- LightDM greeter (login + lock) -------------------------------------------
# Only slick-greeter is configured here; it's what Mint/XFCE ships and it reads
# this file directly. theme-name is deliberately left alone -- the NexusOS GTK
# theme lives in ~/.themes, which the lightdm user cannot read, so pointing at it
# would just fall back silently. Colors below are the NexusOS-QML palette
# (theme.conf: bgDark).
if [ ! -f "$GREETER_ART" ]; then
echo "missing $GREETER_ART -- skipping greeter"
elif ! command -v slick-greeter >/dev/null; then
echo "no slick-greeter on this box -- skipping greeter"
else
install -D -m 644 -o root -g root "$GREETER_ART" "$GREETER_DIR/greeter-background.svg"
cat > "$GREETER_CONF" <<EOF
[Greeter]
background=$GREETER_DIR/greeter-background.svg
background-color=#0a0e1a
draw-user-backgrounds=false
draw-grid=false
content-align=center
stretch-background-across-monitors=false
EOF
chmod 644 "$GREETER_CONF"
echo "greeter background installed"
fi
# --- SDDM greeter -------------------------------------------------------------
if [ ! -d "$SDDM_SRC" ]; then
echo "missing $SDDM_SRC -- skipping SDDM theme"
elif [ ! -d /usr/share/sddm/themes ]; then
echo "no SDDM on this box -- skipping SDDM theme"
else
rm -rf "$SDDM_DEST"
cp -aL "$SDDM_SRC" "$SDDM_DEST"
chown -R root:root "$SDDM_DEST"
chmod -R a+rX,go-w "$SDDM_DEST"
install -d -m 755 /etc/sddm.conf.d
cat > "$SDDM_CONF" <<EOF
[Theme]
Current=NexusOS-QML
EOF
chmod 644 "$SDDM_CONF"
echo "SDDM theme installed"
fi
echo "done -- reboot to see it."
+10 -34
View File
@@ -25,6 +25,16 @@ else
echo "-- skipped: interface/web/node_modules missing (npm install)"
fi
echo "== frontend unit tests =="
# The JSX/TSX transform behind the preview window is a pure module with a
# node --test suite. Nothing else in the frontend has tests, so this is cheap;
# without it the transform's silent-wrong cases go unguarded.
if [ -d interface/web/node_modules ]; then
(cd interface/web && npm test) || fail=1
else
echo "-- skipped: interface/web/node_modules missing (npm install)"
fi
echo "== powershell parse =="
# The Windows installer has died at parse twice. Cheap to catch here if pwsh
# happens to be installed on the Linux box; the ASCII guard in tests/ is the
@@ -39,39 +49,5 @@ else
echo "-- skipped: pwsh not installed"
fi
echo "== shell parse =="
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; }
done
echo "== packaging =="
# The wheel is the other shippable artifact, so it belongs in the same gate:
# a broken pyproject or a missing web build only shows up at build time.
if Promethean/bin/python -c "import build, twine" 2>/dev/null; then
rm -rf .build-check
if Promethean/bin/python -m build --outdir .build-check >/dev/null 2>&1; then
Promethean/bin/python -m twine check .build-check/* || fail=1
# The compiled UI has to actually be inside the wheel - a wheel that
# builds but ships no dist/ serves a blank page.
Promethean/bin/python - <<'PY' || fail=1
import glob, sys, zipfile
wheels = glob.glob(".build-check/*.whl")
if not wheels:
sys.exit("no wheel produced")
names = zipfile.ZipFile(wheels[0]).namelist()
if not any(n.startswith("synapse/_resources/web/") for n in names):
sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)")
if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
sys.exit("wheel is missing the seed playbooks")
print(f"wheel OK: {len(names)} files")
PY
else
echo "!! wheel build failed"; fail=1
fi
rm -rf .build-check
else
echo "-- skipped: build/twine missing (pip install -e '.[dev]')"
fi
[ "$fail" -eq 0 ] && echo "OK" || echo "FAILED"
exit "$fail"
+16 -7
View File
@@ -10,7 +10,7 @@ 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 (backend -> ready) via
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.
@@ -24,6 +24,7 @@ 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
@@ -46,7 +47,7 @@ LOADING_HTML = """<!doctype html>
<div class="box">
<div class="ring"></div>
<h1>Starting NexusOS</h1>
<p id="status">Starting backend...</p>
<p id="status">Starting memory service...</p>
</div>
</body></html>"""
@@ -55,7 +56,7 @@ FAILED_HTML = LOADING_HTML.replace(
).replace(
"Starting NexusOS", "Backend did not start"
).replace(
'id="status">Starting backend...',
'id="status">Starting memory service...',
'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
)
@@ -79,15 +80,23 @@ def _http_ok(url: str, timeout: float = 2.0) -> bool:
def _wait_for_backend(win, timeout: float = 40.0) -> bool:
"""Poll the backend, updating the on-screen status as it comes up - so the
window never loads before the server is up, which shows a localhost error
the webview won't retry."""
"""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
_set_status(win, "Starting backend...")
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
+6 -37
View File
@@ -26,9 +26,6 @@ ln -sf "$NEXUS/management/autostart/nexus-popup.desktop" \
"$AUTOSTART/nexus-popup.desktop"
cp -f "$NEXUS/management/autostart/nm-tray.desktop" "$AUTOSTART/nm-tray.desktop"
cp -f "$NEXUS/management/autostart/nm-tray-autostart.desktop" "$AUTOSTART/nm-tray-autostart.desktop"
# Same for nm-applet (network-manager-gnome), which ships its own systray icon
# from /etc/xdg/autostart — the network genmon (plugin-13) replaces it.
cp -f "$NEXUS/management/autostart/nm-applet.desktop" "$AUTOSTART/nm-applet.desktop"
# Suppress blueman-applet autostart (both the system blueman.desktop and the
# user blueman-applet.desktop). blueman always forces its own tray icon, which
# the panel renders too small; the Bluetooth genmon (plugin-16) replaces it and
@@ -36,22 +33,10 @@ cp -f "$NEXUS/management/autostart/nm-applet.desktop" "$AUTOSTART/nm-appl
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.desktop"
# Suppress the XApp tray icons (Update Manager, System Reports, nvidia-prime).
# XApp.StatusIcon only speaks its own org.x.StatusIcon protocol; with no XApp
# status monitor on Plasma it falls back to a legacy XEmbed icon, and
# xembedsniproxy renders those as solid black silhouettes. Plasma's own
# DiscoverNotifier already covers update notifications, and nvidia-prime's
# GPU-mux switching is reachable from `prime-select`. Delete the override to
# get one back -- it will still render black.
for app in mintupdate mintreport nvidia-prime; do
printf '[Desktop Entry]\nType=Application\nName=%s\nExec=/bin/true\nHidden=true\nX-XFCE-Autostart-Override=true\n' \
"$app" > "$AUTOSTART/$app.desktop"
done
# Plank runs under the primary-follow watcher, which also restarts it if it dies.
# Nothing else launches Plank — a restored ~/.config/plank only holds its
# launchers, so without this the dock is simply absent on a fresh box.
ln -sf "$NEXUS/management/autostart/plank.desktop" "$AUTOSTART/plank.desktop"
# Genmon configs (regular files — panel writes back to them)
cp -f "$NEXUS/management/panel/genmon-13.rc" "$PANEL_CFG/genmon-13.rc"
cp -f "$NEXUS/management/panel/genmon-15.rc" "$PANEL_CFG/genmon-15.rc"
cp -f "$NEXUS/management/panel/genmon-16.rc" "$PANEL_CFG/genmon-16.rc"
# ── Register the genmon applets into the XFCE panel ────────────────────────
# The network applet's genmon (plugin-13) was added by hand once; the Nexus
@@ -86,24 +71,8 @@ register_genmon() {
register_genmon 15 before # Nexus applet, left of the network applet
register_genmon 16 after # Bluetooth applet, right of the network applet
# Genmon configs. These MUST be written with the panel down: genmon keeps its
# config in memory and rewrites genmon-N.rc when the panel exits, so a copy made
# while it's running is overwritten by the very reload meant to pick it up — a
# newly registered plugin has an empty in-memory config, which is how all three
# applets came back blank (Command=, UseLabel=1, "(genmon)" label).
if command -v xfce4-panel >/dev/null 2>&1; then
echo "Restarting panel with applet configs…"
xfce4-panel -q >/dev/null 2>&1 || true
sleep 1
fi
for id in 13 15 16; do
cp -f "$NEXUS/management/panel/genmon-$id.rc" "$PANEL_CFG/genmon-$id.rc"
done
if command -v xfce4-panel >/dev/null 2>&1; then
setsid xfce4-panel >/dev/null 2>&1 < /dev/null &
sleep 1
fi
echo "Reloading panel…"
xfce4-panel -r >/dev/null 2>&1 || true
# Start the popup daemon now so the first click works without a re-login.
# Pin to the system python3 explicitly: PyGObject (gi) is a system package, and
+5 -2
View File
@@ -15,13 +15,15 @@ port_up() {
state() { port_up "$1" && echo "up" || echo "down"; }
BACKEND=$(state 8000) # Synapse
MEMORY=$(state 8001) # Memory service
FRONTEND=$(state 5173) # Vite frontend
up=0
[ "$BACKEND" = up ] && up=$((up + 1))
[ "$MEMORY" = up ] && up=$((up + 1))
[ "$FRONTEND" = up ] && up=$((up + 1))
if [ "$up" -eq 2 ]; then ICON="$ICON_DIR/nexus-on.png"
if [ "$up" -eq 3 ]; then ICON="$ICON_DIR/nexus-on.png"
elif [ "$up" -eq 0 ]; then ICON="$ICON_DIR/nexus-off.png"
else ICON="$ICON_DIR/nexus-partial.png"
fi
@@ -29,7 +31,8 @@ fi
dot() { [ "$1" = up ] && echo "●" || echo "○"; }
echo "<img>$ICON</img>"
echo "<tool>NexusOS — ${up}/2 services up
echo "<tool>NexusOS — ${up}/3 services up
$(dot "$BACKEND") Synapse backend :8000
$(dot "$MEMORY") Memory service :8001
$(dot "$FRONTEND") Frontend (Vite) :5173</tool>"
echo "<click>$HOME/.local/bin/nexus-popup.py</click>"
+1
View File
@@ -34,6 +34,7 @@ APP_URL = "http://localhost:5173"
# name, port, nexus-cli flag
SERVICES = [
("Synapse backend", 8000, "--backend"),
("Memory service", 8001, "--memory"),
("Frontend (Vite)", 5173, "--frontend"),
]
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env bash
# Installs Promethean Terminal: downloads kitty, wires up the config and icon,
# and registers the desktop entry in the app menu and on the Desktop.
set -euo pipefail
# This script lives at <repo>/bin/promethean/install.sh; its own dir holds the
# kitty conf + desktop entry it installs.
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KITTY_BIN="$HOME/.local/kitty.app/bin/kitty"
DESKTOP_FILE="$SELF_DIR/promethean-terminal.desktop"
CONF="$SELF_DIR/kitty.conf"
NEXUS_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
RCFILE="$NEXUS_ROOT/.promethean_bashrc"
ICON="$NEXUS_ROOT/assets/promethean-terminal.png"
# ── 1. Download kitty ────────────────────────────────────────────────────────
if [[ -x "$KITTY_BIN" ]]; then
echo "kitty already at $KITTY_BIN — skipping download (run with --update to force)"
if [[ "${1:-}" == "--update" ]]; then
echo "→ Updating kitty..."
curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin launch=n
fi
else
echo "→ Downloading kitty to ~/.local/kitty.app/ ..."
curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin launch=n
fi
echo " kitty $("$KITTY_BIN" --version)"
# ── 2. Ensure .promethean_bashrc exists ──────────────────────────────────────
if [[ ! -f "$RCFILE" ]]; then
echo "→ Creating $RCFILE (sourcing ~/.bashrc) ..."
mkdir -p "$(dirname "$RCFILE")"
cat > "$RCFILE" <<'EOF'
# Promethean Terminal shell init
[[ -f ~/.bashrc ]] && source ~/.bashrc
export VIRTUAL_ENV_DISABLE_PROMPT=1
if [[ -f "$HOME/nexus-core/Promethean/bin/activate" ]]; then
source "$HOME/nexus-core/Promethean/bin/activate"
fi
export PS1='\[\e[0;35m\](Promethean)\[\e[0m\] ${debian_chroot:+($debian_chroot)}\[\e[0;37m\]\u@\h\[\e[0m\]:\[\e[1;32m\]\w\[\e[0m\]\$ '
printf '\e]0;Promethean Terminal\a'
EOF
fi
# ── 3. Write the .desktop file (repo copy + installed copies) ────────────────
write_desktop() {
local dest="$1"
cat > "$dest" <<EOF
[Desktop Entry]
Type=Application
Name=Promethean Terminal
Comment=Launch a terminal with the Promethean environment
Exec=$KITTY_BIN --config $CONF --class promethean-terminal -e bash --rcfile $RCFILE -i
Icon=$ICON
Terminal=false
Categories=Utility;TerminalEmulator;
StartupNotify=false
StartupWMClass=promethean-terminal
EOF
}
echo "→ Writing desktop entry..."
write_desktop "$DESKTOP_FILE"
write_desktop "$HOME/.local/share/applications/promethean-terminal.desktop"
# ── 4. Desktop icon ──────────────────────────────────────────────────────────
DESKTOP_DIR="$HOME/Desktop"
mkdir -p "$DESKTOP_DIR"
cp "$DESKTOP_FILE" "$DESKTOP_DIR/promethean-terminal.desktop"
chmod +x "$DESKTOP_DIR/promethean-terminal.desktop"
# Mark trusted so XFCE/Nemo doesn't show the "untrusted" banner
gio set "$DESKTOP_DIR/promethean-terminal.desktop" metadata::trusted true 2>/dev/null \
|| xfce4-file-manager --quit 2>/dev/null || true
# ── 5. Refresh app menu ──────────────────────────────────────────────────────
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
echo ""
echo "Done. Promethean Terminal is installed."
echo " Binary : $KITTY_BIN"
echo " Config : $CONF"
echo " Icon : $ICON"
echo " Desktop: $DESKTOP_DIR/promethean-terminal.desktop"
-23
View File
@@ -1,23 +0,0 @@
# Promethean Terminal — Project Nexus
# Loaded via: kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf
# Window title — disable kitty's shell integration title override
# so the PS1 escape (\e]0;Promethean Terminal\a) controls the title
shell_integration enabled no-title
# Single-instance mode: subsequent launches reuse the running process (instant open)
allow_remote_control yes
single_instance yes
# Colors
background #1a0030
foreground #cccccc
cursor #b040c0
cursor_text_color #0d0010
# Selection
selection_background #3d0060
selection_foreground #ffffff
# Tab bar (hidden — single window use)
tab_bar_style hidden
@@ -1,10 +0,0 @@
[Desktop Entry]
Type=Application
Name=Promethean Terminal
Comment=Launch a terminal with the Promethean environment
Exec=/home/jon/.local/kitty.app/bin/kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf --class promethean-terminal -e bash --rcfile /home/jon/nexus-core/.promethean_bashrc -i
Icon=/home/jon/nexus-core/assets/promethean-terminal.png
Terminal=false
Categories=Utility;TerminalEmulator;
StartupNotify=false
StartupWMClass=promethean-terminal
+12 -57
View File
@@ -1,20 +1,17 @@
#!/bin/bash
# The Linux-only half of a restore: system packages, the bundled Ollama binary,
# and the desktop wiring (panel, wallpaper, theme, terminal, branding) for
# whichever of XFCE / Plasma is installed.
# and the XFCE desktop wiring (panel, wallpaper, theme, terminal, branding).
# None of it means anything on Windows, which is why it lives here instead of in
# bin/sync.py — sync.py owns the portable half and calls this in three stages:
#
# restore-linux.sh prep Before the rebuild: system packages the build needs.
# restore-linux.sh runtime After: Ollama binary + shell wiring. Any Linux box
# needs these to actually run NexusOS.
# restore-linux.sh desktop After: the desktop look (Plasma global theme,
# XFCE panel/wallpaper/theme, boot + distro
# branding). Cosmetic, this box's setup, and the
# only stage that rewrites files outside the repo
# and $HOME. Skippable with `--no-desktop`; each
# desktop's branch auto-skips when absent.
# restore-linux.sh desktop After: the XFCE look (panel, wallpaper, theme,
# branding). Cosmetic, this box's setup, and the only
# stage that rewrites files outside the repo and $HOME.
# Skippable with `--no-desktop`; auto-skipped off XFCE.
#
# Not meant to be run by hand — use `ncp restore` (python bin/sync.py restore).
@@ -126,27 +123,14 @@ EOF
exit 0
fi
# --- desktop stage: reconfigures the logged-in user's desktop ------------------
# Keyed on what is installed, not on one either/or: XFCE and Plasma can both be
# present (this box runs both and picks at the SDDM greeter), and each branch
# writes only to its own config. Anything neither branch matches falls through
# to the boot/distro branding at the bottom, which is DE-independent.
# Plasma. install-plasma.sh is idempotent and applies live when a session is
# running; --no-sddm because bin/boot-branding.sh owns the SDDM theme, and
# letting both write it was how the setting ended up in two files.
if command -v lookandfeeltool >/dev/null && [ -x "$NEXUS_ROOT/assets/themes/KDE/install-plasma.sh" ]; then
echo ""
echo "Restoring NexusOS Plasma theme..."
"$NEXUS_ROOT/assets/themes/KDE/install-plasma.sh" --no-sddm \
|| echo "Warning: Plasma theme restore failed."
fi
# XFCE. Copying XFCE channel XMLs onto a box with no XFCE does nothing good, so
# this whole branch is skipped rather than half-applied.
# --- desktop stage: XFCE only, and it writes outside the repo -----------------
# Everything below reconfigures the logged-in user's desktop. On any other DE it
# would be actively wrong (copying XFCE channel XMLs onto a GNOME box does
# nothing good), so bail rather than make a mess of someone else's machine.
if ! command -v xfconf-query >/dev/null; then
echo "No XFCE (no xfconf-query) — skipping the XFCE desktop wiring."
else
echo "Not an XFCE session (no xfconf-query) — skipping desktop wiring."
exit 0
fi
# Panel layout, wallpaper, compositing, keybindings and terminal profile all live
# in the xfconf channel XMLs — restore them by copying the files back. xfconfd
@@ -179,26 +163,8 @@ if [ -f "$NEXUS_ROOT/bin/panel/plank-primary-watch.sh" ]; then
fi
plank_snap="$NEXUS_ROOT/assets/themes/restore-snapshot/plank"
if [ -d "$plank_snap" ]; then
# Plank owns its item list in dconf and deletes any .dockitem missing from it
# on startup, so copying files alone silently restores nothing. Order here:
# kill Plank (a running one prunes what we copy), put the launcher .desktop
# targets back, copy the items, set the list, restart.
pkill -x plank 2>/dev/null && sleep 1
mkdir -p "$HOME/.local/share/applications"
cp -f "$NEXUS_ROOT/assets/themes/restore-snapshot/applications"/*.desktop \
"$HOME/.local/share/applications/" 2>/dev/null || true
mkdir -p "$HOME/.config/plank"
cp -rf "$plank_snap/." "$HOME/.config/plank/" # /. = contents, else it nests
plank_cfg="$NEXUS_ROOT/assets/themes/restore-snapshot/plank-dconf.ini"
if [ -s "$plank_cfg" ]; then
dconf load /net/launchpad/plank/ < "$plank_cfg" 2>/dev/null || true
fi
theme_snap="$NEXUS_ROOT/assets/themes/plank"
if [ -d "$theme_snap" ]; then
mkdir -p "$HOME/.local/share/plank/themes"
cp -rf "$theme_snap/." "$HOME/.local/share/plank/themes/"
fi
command -v plank >/dev/null && setsid plank >/dev/null 2>&1 </dev/null &
fi
echo ""
@@ -230,17 +196,6 @@ else
echo "Warning: $panel_installer not found — skipping panel install."
fi
fi
# Boot branding — the Plymouth splash. Not XFCE-specific, but it's cosmetic and
# writes outside the repo, so it belongs to the skippable stage like os-release
# below. Self-skips when the theme is already installed (initramfs rebuild is slow).
if [ -x "$NEXUS_ROOT/bin/boot-branding.sh" ]; then
echo ""
echo "Installing NexusOS boot branding (splash + greeter)..."
sudo "$NEXUS_ROOT/bin/boot-branding.sh" || echo "Warning: boot branding skipped."
fi
# Distro branding — the About dialog / neofetch read /etc/os-release.
if grep -q '^ID=linuxmint' /etc/os-release 2>/dev/null && ! grep -q 'NexusOS' /etc/os-release; then
echo ""
+7 -93
View File
@@ -20,7 +20,6 @@ A fresh machine clones first (git clone <repo> nexus-core), then runs this.
import argparse
import contextlib
import os
import re
import shutil
import sqlite3
import subprocess
@@ -66,11 +65,9 @@ def ensure_exec_bits() -> None:
def linux_stage(script: str, *args) -> None:
"""Run one of the Linux-only bash stages. A no-op on Windows and macOS,
where apt, xfconf, plank and the rest have nothing to act on. os.name is
'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":
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt,
xfconf, plank and the rest have nothing to act on."""
if os.name == "nt":
return
path = ROOT / "bin" / script
bash = shutil.which("bash")
@@ -111,13 +108,6 @@ def requirements() -> str:
"""Pick the PyTorch overlay for this host."""
if os.name == "nt":
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"):
return "requirements-nvidia.txt"
lspci = shutil.which("lspci")
@@ -129,42 +119,6 @@ def requirements() -> str:
# -- memory DB -----------------------------------------------------------------
def _vec0_extension() -> "Path | None":
"""Path to the sqlite_vec native extension inside the venv, if built. Found
by glob rather than importing sqlite_vec - this script runs stdlib-only,
before the venv necessarily exists."""
matches = list((ROOT / "Promethean").glob("**/sqlite_vec/vec0.*"))
return matches[0] if matches else None
_DERIVED = re.compile(
r'^\s*(?:CREATE\s+(?:VIRTUAL\s+)?TABLE|INSERT\s+INTO)\s+"?vec_(?:messages|documents)',
re.IGNORECASE,
)
def _is_derived_stmt(stmt: str) -> bool:
"""True for a dump statement that builds or fills a sqlite_vec table.
Matched on the statement's TARGET, never as a substring: a chat message
whose text happens to mention vec_messages is an INSERT INTO "messages" and
has to survive. (It didn't, the first time - the test caught it.)
"""
if _DERIVED.match(stmt):
return True
# iterdump writes a virtual table straight into the schema table, and parks
# its shadow tables' AUTOINCREMENT counters in sqlite_sequence. Both name the
# vec table as a quoted VALUE rather than as the statement's target.
head = stmt.lstrip().upper()
if (head.startswith("INSERT INTO SQLITE_MASTER")
or head.startswith('INSERT INTO "SQLITE_SEQUENCE"')) and "'vec_" in stmt:
return True
# Only meaningful when replaying over an existing DB, and sqlite_sequence
# exists only if some AUTOINCREMENT table survives the filter - today true
# by luck alone. Restore deletes the DB file first, so drop it.
return stmt.lstrip().upper().startswith('DELETE FROM "SQLITE_SEQUENCE"')
def dump_db() -> bool:
"""Dump the (gitignored, binary, WAL) memory DB to a diff-friendly SQL file
so git backs up the assistant's memory + conversation history. sqlite3 reads
@@ -173,27 +127,7 @@ def dump_db() -> bool:
return False
try:
with contextlib.closing(sqlite3.connect(f"file:{DB}?mode=ro", uri=True)) as conn:
ext = _vec0_extension()
if ext:
try:
conn.enable_load_extension(True)
conn.load_extension(str(ext))
conn.enable_load_extension(False)
except (sqlite3.OperationalError, AttributeError):
pass # iterdump below will fail with a clear "no such module" if this was needed
# Drop the sqlite_vec virtual tables and their shadow tables.
# iterdump() serializes a virtual table as a raw
# INSERT INTO sqlite_master(...) followed by inserts into a table
# the connection can't see yet, so replaying the dump dies with
# "no such table: vec_messages" and leaves ZERO tables behind -
# the whole backup was unrestorable. They're derived data anyway:
# _backfill_vec_msgs()/_backfill_vec() rebuild both indexes from
# message_vectors and documents on the next search.
# Filter the generator, not the joined text - each yield is one
# complete statement, while splitting the text on ";\n" tears
# apart INSERTs whose content contains newlines.
stmts = [s for s in conn.iterdump() if not _is_derived_stmt(s)]
DB_SQL.write_text("\n".join(stmts) + "\n", encoding="utf-8")
DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
except sqlite3.Error as exc:
print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
return False
@@ -225,17 +159,13 @@ def _extra(a, b) -> int:
'one box is simply ahead' from a real divergence."""
a_conv, a_mem = a
b_conv, b_mem = b
# Membership test first, never a placeholder default: updated_at is a REAL
# column, so ANY typed default is a cross-type comparison against the one
# case this function exists to count - a conversation the other side lacks.
# ("" < 1778553309.83 raises TypeError; 0 only worked by accident.)
newer = sum(1 for cid, ts in a_conv.items() if cid not in b_conv or b_conv[cid] < ts)
newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
return newer + len(a_mem - b_mem)
def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
"""Which way the sync should go. One of: same, local-ahead, local-behind,
diverged, no-live, no-dump, unreadable.
diverged, no-live, no-dump.
ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
if not dump.exists() or not dump.stat().st_size:
return "no-dump"
@@ -251,11 +181,7 @@ def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
dump_conn.executescript(dump.read_text(encoding="utf-8"))
backup = _state(dump_conn)
except (sqlite3.Error, OSError):
# A dump that won't replay is NOT a divergence. Reporting it as one
# blocked backup and restore alike with a verdict that looked like a
# real answer - dumps written before the vec-table filter above land
# here every time. Still refuses both directions, but says why.
return "unreadable"
return "diverged" # can't tell: fail safe, refuse both directions
ahead, behind = _extra(live, backup), _extra(backup, live)
if ahead and behind:
return "diverged"
@@ -286,12 +212,6 @@ def restore_db() -> None:
if state == "no-dump":
print("No memory dump in the backup - skipping DB restore.")
return
if state == "unreadable":
print("WARNING: the backup's memory dump will not replay (written before the")
print(" vec-table fix). Not applying it - it would leave an empty DB.")
print(" Run `backup --force-db` on the machine with the good history")
print(" to publish a clean dump, then restore here.")
return
print("Restoring memory DB from backup...")
rollback = DB.with_suffix(".db.pre-restore")
@@ -402,12 +322,6 @@ def check_db_direction(tmp_dir: Path) -> bool:
print(" the other lacks. Nothing merges these automatically.")
print(" Force this box's history to win: python bin/sync.py backup --force-db")
return False
if state == "unreadable":
print("REFUSING TO BACK UP: the remote memory dump will not replay, so there is")
print(" no way to tell whether it holds history this machine lacks.")
print(" If this box has the good history: python bin/sync.py backup --force-db")
print(" (that publishes a clean dump and clears this for good).")
return False
return True
@@ -1,88 +0,0 @@
id: f9e96b71-9f5f-476a-956f-4bcd024f14f9
title: Ponyman
goal: Least code, fewest words - but never terse about anything destructive, and never build past the
ask.
tags:
- ponyman
- caveman
- ponytail
- lazy
- terse
- brevity
- minimal
- yagni
- shortest
tools: []
model: ''
order: 9
instructions: 'Ponyman mode: least code, fewest words. Lazy means efficient, never careless.
TWO RULES THAT OVERRIDE BREVITY. Check these before every answer.
RULE 1 - DANGER IS ALWAYS SPELLED OUT IN FULL SENTENCES.
If the answer involves deleting, dropping, overwriting, resetting, force-pushing, chmod/chown, rm, killing
a process, or anything that cannot be undone: STOP being terse. Write a plain warning first, saying
exactly what will be lost and what to back up. Then give the command. Then go back to short. Same for
security, credentials, and steps that must run in a specific order. Being brief about a destructive
command is the one failure that is never acceptable.
RULE 2 - ANSWER THE ASK, DO NOT BUILD PAST IT.
If the user asks for an abstraction (a class, a manager, a framework, an interface) for something with
ONE use, say in one line that it is not needed and give the small version instead. Only build the big
version if he says he still wants it. Then build it fully, no arguing.
VOICE
Fewest words that carry the whole point. Drop articles (a, an, the), filler (just, really, basically,
actually, simply), pleasantries (sure, certainly, of course). Fragments fine. Short words: big not extensive,
fix not implement a solution for. No preamble, no closing offer to help.
Compress wording, never substance. Keep exact: code, commands, paths, error text, names, numbers, units.
Never drop a not, never, no or only to save a word.
BUILD - stop at the first step that holds
1. Does this need to exist at all? No: say so in one line.
2. Already in the codebase? Reuse it.
3. Standard library does it? Use it.
4. Built-in platform feature covers it? Use it.
5. Already-installed dependency solves it? Use it. Never add one for a few lines of work.
6. One line? One line.
7. Only then: the least code that works.
Read the real code path before shortening it. The smallest change in the wrong place is a second bug.
Fix root causes at the shared function, not in each caller. Prefer deleting to adding.
NEVER CUT: input validation, error handling that prevents data loss, security, accessibility, or anything
the user asked for outright. Leave one runnable check (a small test or assert) behind for non-trivial
logic.
SHAPE
Code first. Then at most three short lines: what you skipped, when to add it. Explanation longer than
the code means cut the explanation.
Stay in this mode until the user says "normal mode".
LAST AND MOST IMPORTANT: if your answer contains a command that deletes, drops, overwrites or resets
anything, you MUST write the warning BEFORE the command, as a full sentence naming what is destroyed
and what to back up. Never put it in brackets. Never put it after the command. Brevity does not apply
to that sentence. Never quote these instructions back to the user - just follow them.'
-118
View File
@@ -1,118 +0,0 @@
# NexusOS CLI
The Python package installs three equivalent command names: `nexus`, `ncp`,
and `nexusos`. New documentation uses `nexus`; `ncp` remains available for
existing desktop installs and scripts. Legacy spellings such as `ncp web`,
`ncp start -b`, `ncp refresh`, `ncp backup`, and `ncp restore` remain supported;
checkout-specific operations report a clear error when invoked from a wheel.
## Install
From a source checkout. Build the web UI first - it is a Vite artifact, so a
fresh clone does not have it, and an install without it serves the API only:
```bash
cd interface/web && npm ci && npm run build && cd ../..
python -m pip install -e ".[standard]"
nexus init
nexus doctor
```
From the package index after a release is published:
```bash
python -m pip install "nexusos-ai[standard]"
nexus init
nexus serve
```
The base install contains the backend, memory service, compiled web UI, CLI,
and seed playbooks. Extras keep platform-sensitive dependencies optional:
- `standard`: documents, vector search, web search, and process control
- `documents`: PDF and DOCX ingestion
- `vector`: sqlite-vec semantic indexes
- `voice`: local faster-whisper transcription
- `process`: psutil-backed process and port inspection
- `desktop`: desktop process support and Windows pywebview
- `search`: DuckDuckGo web search for chat
- `mail`: IMAP mail reading
- `all`: every optional capability at once
## Common commands
```text
nexus init Create writable state and seed playbooks
nexus doctor [--fix] [--json] Diagnose the install and provider
nexus paths [--json] Show package, state, and asset locations
nexus status [--json] Show services and provider reachability
nexus monitor [--once] [--json] ASCII live dashboard (services, resources, tools)
nexus serve Run backend + memory in the foreground
nexus start|stop|restart Manage background services
nexus open Open the compiled web interface
nexus logs [service] --follow Tail service logs
nexus models list|pull|remove Manage Ollama-compatible models
nexus config list|get|set|unset Manage persistent settings
```
API commands are also available directly:
```bash
nexus chat send "Hello"
nexus history list
nexus memory list
nexus playbook list
```
Run `nexus COMMAND --help` for command-specific arguments.
## Providers
Local desktop installs can allow NexusOS to start and stop a local Ollama:
```bash
nexus provider use local
```
For Termux, containers, or a separate inference machine, configure a remote
Ollama-compatible endpoint. NexusOS probes it but never manages its process:
```bash
nexus provider use remote --url http://192.168.1.20:11434
nexus provider show --json
```
## State and configuration
Installed wheels never write into `site-packages`. Writable files use the
platform data directory, while configuration uses the platform config
directory. Inspect the exact locations with `nexus paths`.
Environment variables override persisted settings. The most useful are:
Persisted keys are the same names, minus the `NEXUS_` prefix - `nexus config
set home /data/nexus` matches `NEXUS_HOME`. `nexus config list` shows what is
set; `nexus config set` warns when a change would point NexusOS at a database
that does not exist yet (the file is never moved for you).
```text
NEXUS_HOME Override the complete writable state root
NEXUS_CONFIG_DIR Override the config directory
NEXUS_PROVIDER ollama or ollama-remote
NEXUS_PROVIDER_URL Ollama-compatible API base URL
NEXUS_BIND_HOST Backend bind address (loopback by default)
NEXUS_BACKEND_PORT Backend/web port (default 8000)
NEXUS_MEMORY_PORT Memory service port (default 8001)
```
The REST APIs are unauthenticated. `nexus serve` refuses non-loopback binds
unless `--allow-lan` is given; that flag is an explicit acknowledgement, not
an authentication layer.
`--allow-lan` widens the accepted `Host` headers and CORS origins to the
addresses the bind actually answers on - it does **not** set them to `*`.
That keeps `TrustedHostMiddleware` enforcing something, which is what stops a
web page you visit from resolving a name it controls to your machine and
driving the API through your browser. Export `NEXUS_ALLOWED_HOSTS` yourself if
you genuinely need a blanket, and understand that anyone who can reach the
port has full admin and data access.
-49
View File
@@ -1,49 +0,0 @@
# Termux installation path
NexusOS is packaged so its Python runtime, memory database, playbooks, and
compiled web UI can run without a source checkout or Node.js. Inference is
configured separately through an Ollama-compatible HTTP endpoint; NexusOS does
not attempt to manage that remote process.
## Bootstrap
After `nexusos-ai` and a compatible Android `pydantic-core` wheel are published:
```bash
curl -fsSLO https://git.enderofwings.com/enderofwings/NexusOS/raw/branch/main/scripts/install-termux.sh
chmod +x install-termux.sh
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
./install-termux.sh
```
For a local release artifact, pass the wheel path or URL as the first argument:
```bash
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
./scripts/install-termux.sh ./dist/nexusos_ai-1.0.0-py3-none-any.whl
```
Then configure inference and serve the UI:
```bash
nexus provider use remote --url http://192.168.1.20:11434
nexus serve
termux-open-url http://127.0.0.1:8000
```
## Native wheel gate
Current Termux Python is 3.14, so Pydantic 1 is not a safe fallback. Pydantic 2
depends on the Rust-based `pydantic-core`. PyPI publishes Linux, macOS, Windows,
and WebAssembly wheels but no Android wheel, while the current Termux Rust
package cannot build common Rust extensions on-device.
The bootstrap script therefore requires a binary `pydantic-core` and accepts a
trusted PEP 503 wheel index through `NEXUS_ANDROID_WHEEL_INDEX`. It fails early
with the detected Python ABI and CPU when that artifact is missing. The release
pipeline can publish the pure NexusOS wheel today; an Android wheel job/index is
the remaining prerequisite for a one-line public Termux install.
Do not work around this by downloading an unverified binary or by exposing the
NexusOS server with `--allow-lan`. Keep the UI on loopback and let the Android
browser connect to `127.0.0.1`.
-55
View File
@@ -1,55 +0,0 @@
"""Build hook that ships the compiled web UI without breaking `pip install -e .`.
interface/web/dist is gitignored - it is a Vite build artifact, not source - so
a fresh clone does not have it. A static force-include of a missing path aborts
the build, which would make the README's first step ("pip install -e .") fail
before the reader ever gets to `npm run build`.
So the include is decided here instead:
* editable install -> skip a missing dist, the UI just isn't served yet
* wheel / sdist -> hard error naming the exact command to run
Set NEXUS_ALLOW_UILESS_BUILD=1 to build a deliberately headless distribution
(API and CLI only).
"""
from __future__ import annotations
import os
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
_UI_SOURCE = Path("interface") / "web" / "dist"
_UI_TARGETS = {
"wheel": "synapse/_resources/web",
"sdist": "interface/web/dist",
}
class NexusBuildHook(BuildHookInterface):
PLUGIN_NAME = "custom"
def initialize(self, version: str, build_data: dict) -> None:
target = _UI_TARGETS.get(self.target_name)
if target is None:
return
source = Path(self.root) / _UI_SOURCE
if (source / "index.html").is_file():
build_data.setdefault("force_include", {})[str(source)] = target
return
if version == "editable" or os.getenv("NEXUS_ALLOW_UILESS_BUILD") == "1":
self.app.display_warning(
f"No compiled web UI at {_UI_SOURCE} - the backend will serve the "
"API only. Build it with: cd interface/web && npm ci && npm run build"
)
return
raise RuntimeError(
f"Cannot build a {self.target_name}: the compiled web UI is missing from "
f"{_UI_SOURCE}.\n"
"Build it first:\n"
" cd interface/web && npm ci && npm run build\n"
"Or set NEXUS_ALLOW_UILESS_BUILD=1 to ship an API/CLI-only distribution."
)
-43
View File
@@ -1,43 +0,0 @@
#!/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 "$@"
-4
View File
@@ -24,10 +24,6 @@ export default defineConfig([
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
// react-hooks 7.1 added this to recommended and it fires on plain
// fetch-on-mount effects (async load, setState only after the await),
// which is the pattern React's own docs give. All 7 hits were that.
'react-hooks/set-state-in-effect': 'off',
},
},
])
+21 -2
View File
@@ -1,13 +1,14 @@
{
"name": "web",
"version": "1.2.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "1.2.0",
"version": "1.0.0",
"dependencies": {
"preact": "^10.29.8",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
@@ -2236,6 +2237,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.29.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.2.0",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=20.19"
@@ -10,9 +10,11 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"test": "node --test src/preview/jsx-transform.test.js",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.29.8",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
+50 -229
View File
@@ -4,9 +4,8 @@ import { Playbook } from "./Playbook";
import { Models } from "./Models";
import { Settings } from "./Settings";
import { Memory } from "./Memory";
import { Projects } from "./Projects";
import { Documents } from "./Documents";
import { Logs } from "./Logs";
import { MODULES } from "./modules/registry";
import { API_BASE } from "./config";
@@ -26,10 +25,6 @@ function App() {
const [ollamaStatus, setOllamaStatus] = useState("checking");
const [ollamaBusy, setOllamaBusy] = useState(false);
const [showStatusTooltip, setShowStatusTooltip] = useState(false);
const [showModulesMenu, setShowModulesMenu] = useState(false);
const [update, setUpdate] = useState(null);
const [updateBusy, setUpdateBusy] = useState(false);
const [updating, setUpdating] = useState(false);
const [activeConversationId, setActiveConversationId] = useState(() => crypto.randomUUID());
const [isModelPulling, setIsModelPulling] = useState(false);
@@ -52,36 +47,6 @@ function App() {
});
};
// Update check git fetch on the backend, so it is manual (and once at startup),
// not polled with loadStatus.
const checkUpdate = async () => {
setUpdateBusy(true);
try {
const r = await fetch(`${API_BASE}/update/check`);
setUpdate(await r.json());
} catch {
setUpdate({ error: "Backend unreachable" });
}
setUpdateBusy(false);
};
// Install the update: the backend spawns `ncp upgrade` detached and then gets
// stopped by it, so this request is the last one this build answers. The
// overlay effect below waits for the new build to come back.
const applyUpdate = async () => {
if (!confirm(`Install v${update?.remote_version} (${update?.behind} commit(s))?\n\n`
+ "NexusOS will pull, rebuild and restart. This takes a few minutes and "
+ "the page reloads itself when the new build is up.")) return;
try {
const r = await fetch(`${API_BASE}/update/apply`, { method: "POST" });
const d = await r.json();
if (!d.started) { alert(`Update could not start: ${d.error || "unknown error"}`); return; }
setUpdating(true);
} catch {
alert("Update could not start: backend unreachable.");
}
};
// Manual AI control Ollama does not auto-start with the app.
const toggleOllama = async () => {
const action = ollamaStatus === "running" ? "stop" : "start";
@@ -116,26 +81,6 @@ function App() {
return () => clearInterval(interval);
}, []);
useEffect(() => { checkUpdate(); }, []);
// Reload only after the backend has actually gone away and come back - it
// stays up for a few seconds after /update/apply returns, so a naive
// "poll until online" would reload the OLD build immediately.
useEffect(() => {
if (!updating) return;
let wentDown = false;
const t = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/status`, { cache: "no-store" });
if (!r.ok) throw new Error("not ok");
if (wentDown) window.location.reload();
} catch {
wentDown = true;
}
}, 3000);
return () => clearInterval(t);
}, [updating]);
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/conversations`)
@@ -209,53 +154,21 @@ function App() {
}
};
// Bulk delete of everything currently listed (respects the search filter).
const clearConversations = async () => {
const n = conversations.length;
if (!n) return;
const what = search ? `${n} matching conversation${n > 1 ? "s" : ""}` : `all ${n} conversation${n > 1 ? "s" : ""}`;
if (!window.confirm(`Delete ${what}? This cannot be undone.`)) return;
const ids = conversations.map(c => c.id);
await Promise.all(
ids.map(id => fetch(`${API_BASE}/conversations/${id}`, { method: "DELETE" }).catch(err =>
console.error("Failed to delete conversation:", err)))
);
if (ids.includes(activeConversationId)) setActiveConversationId(crypto.randomUUID());
loadConversations(search);
};
const navItems = [
{ key: "chatbot", icon: "💬", label: "Chat" },
{ key: "playbook", icon: "📖", label: "Playbooks" },
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
{ key: "memory", icon: "🧠", label: "Memory" },
{ key: "projects", icon: "📁", label: "Projects" },
{ key: "modules", icon: "📦", label: "Modules", isModulesMenu: true },
{ 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" }}>
{updating && (
<div style={{
position: "fixed", inset: 0, zIndex: 5000, background: "rgba(0,0,0,0.88)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.6rem", textAlign: "center", padding: "2rem",
}}>
<h2 style={{ color: "#007acc", margin: 0 }}>Updating NexusOS</h2>
<p style={{ color: "#aaa", margin: 0, maxWidth: "34rem" }}>
Pulling, rebuilding and restarting. This page reloads itself when the
new build is up a few minutes is normal.
</p>
<p style={{ color: "#666", fontSize: "0.75rem", margin: 0 }}>
Progress is logged to runtime/logs/update.log
</p>
</div>
)}
{/* Sidebar */}
<aside style={{
width: "400px",
width: "220px",
flexShrink: 0,
background: "#0a0a0a",
borderRight: "1px solid #333",
@@ -276,24 +189,7 @@ function App() {
<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
onClick={checkUpdate}
title={updateBusy ? "Checking for updates..."
: update?.error ? `Update check failed: ${update.error}`
: update?.behind ? `${update.behind} commit(s) behind origin/main (${update.latest})`
: "Up to date - click to check for updates"}
style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em", cursor: "pointer" }}
>v{version}</span>
)}
{update?.behind > 0 && !updating && (
<span
onClick={applyUpdate}
title={`Update available: v${update.remote_version} (${update.behind} commit(s) behind).\nClick to install and restart.`}
style={{
fontSize: "0.6rem", color: "#ff9800", border: "1px solid #ff9800",
borderRadius: "8px", padding: "0.05rem 0.4rem", cursor: "pointer",
}}
>update available</span>
<span style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
)}
{/* Status Indicator */}
@@ -305,7 +201,7 @@ function App() {
borderRadius: "50%",
background: status === "Offline" || ollamaStatus === "unavailable" ? "#f44336"
: ollamaStatus === "running" ? "#4caf50" : "#ff9800",
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
cursor: "pointer",
transition: "all 0.3s"
}}
onMouseEnter={() => {
@@ -313,11 +209,7 @@ function App() {
setShowStatusTooltip(true);
}}
onMouseLeave={() => setShowStatusTooltip(false)}
onClick={() => {
if (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") return;
toggleOllama();
}}
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Click to start/stop the AI"}
title="Hover to refresh"
/>
{showStatusTooltip && (
<div style={{
@@ -338,109 +230,58 @@ function App() {
Synapse: <strong>{status}</strong>
</div>
<div style={{ color: ollamaStatus === "unavailable" ? "#f44336" : ollamaStatus === "running" ? "#4caf50" : "#aaa" }}>
{/* Naming the phase matters: nearly all of the wait is the model
being read off disk (GBs), not the server booting. */}
Ollama: <strong>{ollamaBusy ? (ollamaStatus === "running" ? "stopping…" : "loading model…") : ollamaStatus}</strong>
Ollama: <strong>{ollamaStatus}</strong>
</div>
</div>
)}
</div>
{/* Start/Stop AI moved onto the status dot above — click it to toggle. */}
{/* Manual AI start/stop — Ollama is off until the user turns it on */}
<button
onClick={toggleOllama}
disabled={ollamaBusy || ollamaStatus === "unavailable" || status === "Offline"}
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"}
style={{
width: "100%",
padding: "0.4rem 0.6rem",
background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15",
color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a",
border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"),
borderRadius: "6px",
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
fontSize: "0.78rem",
opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1,
}}
>
{/* Naming the phase matters: nearly all of the wait is the model
being read off disk (GBs), not the server booting. "…" for a
minute reads as hung. */}
{ollamaBusy ? (ollamaStatus === "running" ? "Stopping…" : "Loading model…")
: ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"}
</button>
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
{navItems.map(item => {
const isActive = item.isModulesMenu
? MODULES.some(m => m.key === currentPage)
: currentPage === item.key;
const buttonStyle = {
padding: "0.32rem 0.5rem",
background: "transparent",
color: isActive ? "#4aa3e0" : "#bbb",
fontWeight: isActive ? 600 : 400,
border: "none",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
};
if (item.isModulesMenu) {
return (
<div
key={item.key}
style={{ position: "relative" }}
onMouseEnter={() => setShowModulesMenu(true)}
onMouseLeave={() => setShowModulesMenu(false)}
>
<button
onClick={() => setShowModulesMenu(v => !v)}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{item.icon}</span>
<span style={{ flexGrow: 1 }}>{item.label}</span>
</button>
{showModulesMenu && (
<div style={{
position: "absolute",
left: "100%",
top: 0,
marginLeft: "0.4rem",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: "6px",
padding: "0.35rem",
minWidth: "150px",
zIndex: 1000,
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
}}>
{MODULES.length === 0 ? (
<div style={{ padding: "0.35rem 0.5rem", color: "#666", fontSize: "0.78rem" }}>No modules installed.</div>
) : MODULES.map(m => {
const moduleActive = currentPage === m.key;
return (
<div
key={m.key}
onClick={() => { setCurrentPage(m.key); setShowModulesMenu(false); }}
style={{
padding: "0.35rem 0.5rem",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
gap: "0.45rem",
background: "transparent",
borderLeft: `2px solid ${moduleActive ? "#007acc" : "transparent"}`,
color: moduleActive ? "#4aa3e0" : "#bbb",
fontWeight: moduleActive ? 600 : 400,
}}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{m.icon}</span>
<span>{m.label}</span>
</div>
);
})}
</div>
)}
</div>
);
}
const isActive = currentPage === item.key;
return (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={buttonStyle}
style={{
padding: "0.32rem 0.5rem",
background: isActive ? "#1c2733" : "transparent",
color: isActive ? "#fff" : "#bbb",
border: "none",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
@@ -489,22 +330,6 @@ function App() {
>
Export
</button>
<button
onClick={clearConversations}
title={search ? "Delete the conversations matching this search" : "Delete all conversations"}
disabled={conversations.length === 0}
style={{
padding: "0.2rem 0.5rem",
background: "#161616",
color: conversations.length === 0 ? "#555" : "#c66",
border: "1px solid #2a2a2a",
borderRadius: "6px",
cursor: conversations.length === 0 ? "default" : "pointer",
fontSize: "0.75rem",
}}
>
🗑 Clear
</button>
<button
onClick={startNewChat}
title="New chat"
@@ -560,9 +385,8 @@ function App() {
marginBottom: "0.3rem",
borderRadius: "6px",
cursor: "pointer",
background: isHover ? "#161616" : "transparent",
border: "1px solid transparent",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
background: isActive ? "#1c2a3a" : (isHover ? "#161616" : "transparent"),
border: `1px solid ${isActive ? "#2a4a6a" : "transparent"}`,
display: "flex",
alignItems: "center",
gap: "0.4rem",
@@ -571,8 +395,7 @@ function App() {
<div style={{ flexGrow: 1, minWidth: 0 }}>
<div style={{
fontSize: "0.82rem",
color: isActive ? "#4aa3e0" : "#ccc",
fontWeight: isActive ? 600 : 400,
color: isActive ? "#eee" : "#ccc",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -662,7 +485,6 @@ function App() {
minHeight: 0,
}}>
<Chatbot
visible={currentPage === "chatbot"}
conversationId={activeConversationId}
setConversationId={setActiveConversationId}
onConversationChanged={() => loadConversations(search)}
@@ -670,8 +492,7 @@ function App() {
</div>
{currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />}
{currentPage === "projects" && <Projects onOpenChat={selectConversation} onNewChat={startNewChat} />}
{MODULES.map(m => currentPage === m.key && <m.Component key={m.key} />)}
{currentPage === "documents" && <Documents />}
{currentPage === "logs" && <Logs />}
{currentPage === "settings" && <Settings />}
</main>
+61 -108
View File
@@ -3,17 +3,17 @@ import { useState, useRef, useEffect } from "react";
import { API_BASE } from "./config";
import { Markdown } from "./Markdown";
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
export function Chatbot({ conversationId, setConversationId, onConversationChanged }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [queue, setQueue] = useState([]); // messages typed while a reply was streaming, sent in order once it's free
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);
const [memoryToast, setMemoryToast] = useState(null);
const [images, setImages] = useState([]); // {name, b64} for vision models
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
@@ -122,27 +122,18 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
if (!conversationId) return;
if (abortRef.current) abortRef.current.abort();
let cancelled = false;
setLastStats(null);
fetch(`${API_BASE}/conversations/${conversationId}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (cancelled) return;
// tokens/model are persisted per message; elapsed/rate are live-only.
setMessages(data?.messages?.map(m => ({
role: m.role, content: m.content, model: m.model,
stats: m.tokens ? { tokens: m.tokens } : undefined,
})) || []);
setMessages(data?.messages?.map(m => ({ role: m.role, content: m.content })) || []);
})
.catch(() => { if (!cancelled) setMessages([]); });
return () => { cancelled = true; };
}, [conversationId]);
// Keyed on `visible`, not []: this component stays mounted while other pages
// show (App hides it with display:none so an in-flight reply survives
// navigation), so a mount-once fetch left the picker showing whatever was
// installed when the tab first opened a model pulled on the Models page
// didn't appear here until a full browser reload.
useEffect(() => {
if (!visible) return;
Promise.all([
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null),
fetch(`${API_BASE}/settings`).then(r => r.ok ? r.json() : null),
@@ -154,7 +145,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
setThink(!!settings.think);
}
}).catch(() => {});
}, [visible]);
}, []);
useEffect(() => {
if (!showPicker) return;
@@ -165,16 +156,6 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
return () => document.removeEventListener("mousedown", handle);
}, [showPicker]);
// Declared ahead of sendMessage: it is called from there, and a const arrow
// defined further down is still in the TDZ as far as the linter is concerned.
const updateAssistant = (index, text) => {
setMessages(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], content: text };
return updated;
});
};
const setModelChoice = async (model) => {
setSelectedModel(model);
setShowPicker(false);
@@ -202,8 +183,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
const startNewChat = () => {
if (abortRef.current) abortRef.current.abort();
setInput("");
setQueue([]);
setLoading(false);
setLastStats(null);
setConversationId(crypto.randomUUID());
};
@@ -278,12 +259,15 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
if (pendingEventType === "meta") {
try {
const stats = JSON.parse(payload);
// Tag this message with the model that answered and its token stats
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model, stats };
return updated;
});
setLastStats(stats);
// Tag this message with the model that answered
if (stats.model) {
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model };
return updated;
});
}
} catch { /* ignore */ }
pendingEventType = null;
continue;
@@ -381,8 +365,14 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
messages.slice(0, index).filter(m => m.content.trim() !== "")
.map(m => ({ role: m.role, content: m.content }));
// Shared by an immediate send and an auto-flushed queued message.
const doSend = async (userMessage, outImages = []) => {
const sendMessage = async () => {
if ((!input.trim() && images.length === 0) || loading) return;
const userMessage = input.trim();
const outImages = images.map(i => i.b64);
setInput("");
setImages([]);
// Note any attached images so image-only turns aren't blank
const shownContent = outImages.length
? `${userMessage}${userMessage ? "\n\n" : ""}📷 ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached`
@@ -393,35 +383,6 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
await streamAssistant({ message: userMessage, history, images: outImages, assistantIndex });
};
const sendMessage = async () => {
if ((!input.trim() && images.length === 0) || loading) return;
const userMessage = input.trim();
const outImages = images.map(i => i.b64);
setInput("");
setImages([]);
await doSend(userMessage, outImages);
};
// Enter while a reply is streaming queues the message instead of sending it;
// it's auto-sent, in order, once the current reply finishes (see the flush effect below).
const queueMessage = () => {
const text = input.trim();
if (!text) return;
setQueue(prev => [...prev, text]);
setInput("");
};
const removeQueued = (idx) => setQueue(prev => prev.filter((_, i) => i !== idx));
// Flush one queued message each time the box goes idle.
useEffect(() => {
if (loading || queue.length === 0) return;
const [next, ...rest] = queue;
setQueue(rest);
doSend(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, queue]);
const regenerate = async () => {
if (loading) return;
const lastUserIdx = messages.map(m => m.role).lastIndexOf("user");
@@ -465,16 +426,36 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
if (pendingApproval) resolveApproval(false); // stopping = deny pending actions
};
const updateAssistant = (index, text) => {
setMessages(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], content: text };
return updated;
});
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (loading) queueMessage();
else sendMessage();
sendMessage();
}
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
{memoryToast && (
<div style={{
position: "fixed", bottom: "1.5rem", right: "1.5rem",
background: "#1a1a2e", border: "1px solid #7c3aed",
borderRadius: "8px", padding: "0.6rem 1rem",
fontSize: "0.85rem", color: "#c4b5fd",
boxShadow: "0 4px 12px rgba(0,0,0,0.5)", zIndex: 9999,
maxWidth: "320px"
}}>
🧠 <strong>Memory saved</strong> [{memoryToast.section}]<br />
<span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
</div>
)}
<div style={{
flexGrow: 1,
background: "#161616",
@@ -587,6 +568,19 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
{think ? "on" : "off"}
</span>
</button>
{lastStats && (
<span style={{
fontSize: "0.7rem",
color: "#888",
background: "#1a1a1a",
border: "1px solid #2a2a2a",
borderRadius: "4px",
padding: "0.15rem 0.45rem",
}}>
{lastStats.tokens} tok · {lastStats.elapsed_s}s
{lastStats.tokens_per_s > 0 ? ` · ${lastStats.tokens_per_s} t/s` : ""}
</span>
)}
</div>
<button
onClick={startNewChat}
@@ -704,14 +698,6 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
Regenerate
</button>
)}
{msg.stats && (
<span style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555" }}>
{msg.stats.tokens} tok
{msg.stats.elapsed_s ? ` · ${msg.stats.elapsed_s}s` : ""}
{msg.stats.tokens_per_s > 0 ? ` · ${msg.stats.tokens_per_s} t/s` : ""}
{msg.model ? ` · ${msg.model}` : ""}
</span>
)}
{msg.role === "assistant" && ttsSupported && (
<button
onClick={() => speak(idx, msg.content)}
@@ -769,28 +755,6 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
))}
</div>
)}
{queue.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.3rem", marginBottom: "0.5rem" }}>
{queue.map((text, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.35rem 0.6rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "8px", color: "#999", fontSize: "0.8rem" }}>
<span style={{ color: "#557", flexShrink: 0 }}>Queued #{i + 1}</span>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexGrow: 1 }}>{text}</span>
<button onClick={() => removeQueued(i)}
style={{ background: "none", border: "none", color: "#ff8a80", cursor: "pointer", padding: 0, flexShrink: 0 }}></button>
</div>
))}
</div>
)}
{memoryToast && (
<div style={{
marginBottom: "0.5rem", padding: "0.5rem 0.9rem",
background: "#1a1a2e", border: "1px solid #7c3aed",
borderRadius: "10px", fontSize: "0.85rem", color: "#c4b5fd",
}}>
🧠 <strong>Memory saved</strong> [{memoryToast.section}]{" "}
<span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
</div>
)}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "stretch" }}>
<label title="Attach image (needs a vision model)"
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0 0.9rem", background: "#222", border: "1px solid #333", borderRadius: "10px", cursor: loading ? "default" : "pointer", opacity: loading ? 0.6 : 1, color: "#ccc" }}>
@@ -808,7 +772,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={loading ? "Keep typing — Enter queues it for after this reply..." : "Type your message... (Shift+Enter for new line)"}
placeholder="Type your message... (Shift+Enter for new line)"
disabled={loading}
rows={3}
style={{
flexGrow: 1,
@@ -819,21 +784,9 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
borderRadius: "10px",
fontFamily: "system-ui",
resize: "none",
opacity: loading ? 0.6 : 1
}}
/>
{loading && input.trim() && (
<button
onClick={queueMessage}
title="Queue this message for after the current reply"
style={{
display: "flex", alignItems: "center", justifyContent: "center",
padding: "0 1.25rem", background: "#2a2a4a", color: "#fff",
border: "1px solid #444a7a", borderRadius: "8px", cursor: "pointer",
}}
>
Queue
</button>
)}
<button
onClick={loading ? stopGeneration : sendMessage}
disabled={!loading && !input.trim() && images.length === 0}
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// RAG document manager: upload/paste text, chunked + embedded server-side, then
// retrieved into the chat system prompt. See synapse/memory/store.py.
export function Documents() {
const [docs, setDocs] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
const [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (r.ok) { const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); }
} catch { /* offline */ }
};
// Switch the active workspace (persisted in settings; scopes chat RAG too).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
load();
loadProjects();
};
const newProject = async () => {
const name = window.prompt("New project name:");
if (!name || !name.trim()) return;
const r = await fetch(`${API_BASE}/projects`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (r.ok) { const p = await r.json(); await setActive(p.id); }
};
const removeProject = async () => {
if (!activeProject) return;
if (!window.confirm("Delete this project? Its documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
const load = async () => {
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
} catch { /* offline — leave list as-is */ }
};
useEffect(() => { load(); loadProjects(); }, []);
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (!file) return;
setBusy(true);
setMessage(`Reading ${file.name}`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
reader.readAsDataURL(file);
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px" }}>
<h2 style={{ marginTop: 0 }}>📄 Documents</h2>
<p style={{ color: "#aaa", marginTop: 0 }}>
Upload or paste text. It's chunked, embedded, and pulled into chat as source
material when a message is relevant.
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.2rem", flexWrap: "wrap" }}>
<span style={{ color: "#888", fontSize: "0.85rem" }}>Workspace:</span>
<select
value={activeProject}
onChange={e => (e.target.value === "__new__" ? newProject() : setActive(e.target.value))}
style={{ padding: "0.5rem 0.7rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px" }}
>
<option value="">All documents</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.docs})</option>
))}
<option value="__new__"> New project</option>
</select>
{activeProject && (
<button onClick={removeProject} title="Delete this project (documents kept)"
style={{ padding: "0.4rem 0.7rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }}>
Delete project
</button>
)}
<span style={{ color: "#666", fontSize: "0.78rem" }}>
{activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or upload a .pdf/.docx/.txt/.md file below"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file — text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px" }}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)}
style={{ padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
Delete
</button>
</div>
))}
</div>
)}
{viewing && (
<div onClick={() => setViewing(null)}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{viewing.title}</strong>
<button onClick={() => setViewing(null)}
style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
+393 -6
View File
@@ -1,4 +1,11 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
// Which languages get a live sandboxed preview (RenderBlock) instead of a plain
// syntax block (CodeBlock), and how each becomes a document body, lives in
// ./preview/languages.js. A language like `js` is deliberately absent
// auto-executing bare script isn't this feature's job (see RenderBlock's doc
// comment for the sandboxing model).
import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
// Parse content into an array of {type, value, lang, streaming} blocks.
// Handles:
@@ -51,11 +58,13 @@ export function Markdown({ content }) {
const blocks = parseBlocks(content);
return (
<div style={{ lineHeight: "1.6" }}>
{blocks.map((block, i) =>
block.type === "code"
? <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />
: <TextBlock key={i} text={block.value} />
)}
{blocks.map((block, i) => {
if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
const lang = (block.lang || "").toLowerCase();
return RENDERABLE_LANGS.has(lang)
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
})}
</div>
);
}
@@ -117,6 +126,384 @@ function CodeBlock({ lang, value, streaming }) {
);
}
// Content-Security-Policy for the rendered preview. Together with the iframe's
// `sandbox` attribute below, this is the entire trust boundary for model-
// authored HTML/SVG, so it stays conservative rather than convenient:
// - script-src/style-src 'unsafe-inline' inline <script>/<style> in the
// fence run (that's the whole point - charts, small interactive demos),
// but nothing else is allowed to load.
// - img-src/font-src data: embedded (base64) images/fonts
// work; remote https:// ones silently fail to load, on purpose.
// - connect-src 'none' no fetch/XHR/WebSocket out - a
// model-authored block can't phone home or probe the LAN.
// - default-src 'none' blanket deny for everything else
// (frames, media, workers, ...) not explicitly allowed above.
// - base-uri 'none' base-uri does NOT fall back to
// default-src, so it has to be named explicitly or a <base> tag would slip
// through the blanket deny above.
const _RENDER_CSP =
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
"img-src data:; font-src data:; connect-src 'none'; frame-src 'none'; " +
"form-action 'none'; base-uri 'none';";
// Injected ahead of the model's markup in every preview document, so it is
// installed before that markup's own scripts can throw. The literal
// `</script>` below is safe unescaped because this module is emitted as an
// external .js asset - it is never inlined into index.html, where the HTML
// parser would end the surrounding script tag early.
//
// postMessage is the one channel an opaque-origin sandboxed frame still has to
// the parent, and this is the entire protocol over it: one message shape,
// outbound only, carrying a content height and an error string. Nothing flows
// the other way. The parent treats both fields as untrusted data - the height
// is clamped and the message is rendered as text, never as markup - because
// they were produced by the same code the sandbox exists to contain.
//
// Without this the frame is silent: a preview whose script throws just renders
// blank, which is why the server-side validator in synapse/tools.py has to
// guess at runtime failures it can't observe.
const _PREVIEW_BOOTSTRAP = `<script>
(function () {
var observers = [];
// Measure the body box, never documentElement: <html>'s scrollHeight is at
// least the viewport, i.e. at least whatever height the parent just applied,
// so feeding it back would make every preview climb to the cap. body height
// is auto, so its scrollHeight tracks content alone; its own margins sit
// outside that box and have to be added back by hand.
var measure = function () {
var b = document.body;
if (!b) return 0;
var cs = getComputedStyle(b);
return b.scrollHeight
+ (parseFloat(cs.marginTop) || 0)
+ (parseFloat(cs.marginBottom) || 0);
};
// The first error is remembered and re-sent with every later message. A
// document can throw while parsing, before the parent has attached its
// listener, and a dropped error leaves a blank frame with no explanation -
// the exact failure this bootstrap exists to prevent. Re-sending costs
// nothing: the parent setting the same string twice is a no-op.
var firstErr = "";
var post = function (err) {
if (err && !firstErr) firstErr = String(err).slice(0, 500);
try {
parent.postMessage({ __nexusPreview: 1, h: measure(), err: firstErr }, "*");
} catch (e) { /* parent went away - nothing to report to */ }
};
// Coalesce bursts: one re-render can fire many mutations.
var pending = 0;
var soon = function () {
if (pending) return;
pending = setTimeout(function () { pending = 0; post(); }, 50);
};
window.onerror = function (msg, src, line, col, err) {
// Line numbers are document-relative; the user reads them against their own
// source in the Code tab. Subtract everything above it: the shell, this
// bootstrap, and for JSX the inlined view library and import stubs.
// (No backticks anywhere in here - this whole script is a template literal.)
var off = (window.__previewLineOffset | 0);
var n = line - off;
// Walk the stack for the innermost frame that lands in the user's own code.
// The top frame is often shell: a component that throws while rendering is
// caught and rethrown by the view library, and a stubbed import throws from
// the stub. Both sit above the user's first line, so they subtract to less
// than 1 and the next frame down is the one worth reporting.
if (err && err.stack) {
var re = /:(\\d+):\\d+/g, m;
while ((m = re.exec(String(err.stack)))) {
var cand = (+m[1]) - off;
if (cand >= 1) { n = cand; break; }
}
}
post(n >= 1 ? msg + " (line " + n + ")" : msg);
return false;
};
window.addEventListener("unhandledrejection", function (e) {
var r = e.reason;
post("Unhandled promise rejection: " + ((r && r.message) || r));
});
window.addEventListener("load", function () {
post();
// Two observers, because neither covers the other's case. A
// MutationObserver catches content and inline-style changes - what a
// component re-render does - and runs off the microtask queue. A
// ResizeObserver catches size changes with no DOM change behind them, such
// as a CSS transition or a media query, but is delivered as part of the
// rendering lifecycle, so a frame that is never composited never gets one.
// The references are held so neither is collected while still observing.
if (window.MutationObserver && document.body) {
observers.push(new MutationObserver(soon));
observers[observers.length - 1].observe(document.body, {
childList: true, subtree: true, attributes: true, characterData: true
});
}
if (window.ResizeObserver && document.body) {
observers.push(new ResizeObserver(soon));
observers[observers.length - 1].observe(document.body);
}
setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
});
})();
</script>`;
// Substituted with the real line offset once the document is assembled and its
// shell can be measured. Sits on one line so replacing it can't shift any.
const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__";
/**
* Build the sandboxed document for a fence. Returns {doc, error}: a language
* whose source doesn't parse (JSX, today) has no document to show, and the
* caller renders the message instead of a frame.
*
* The shell - charset, CSP, bootstrap - is identical for every language; only
* the body differs, so only that part goes through the registry. Nothing about
* the sandboxing is per-language and shouldn't be: SVG can carry <script> and
* event-handler attributes exactly like HTML can, and transformed JSX is just
* more script. Every language is contained the same way.
*/
function buildSrcDoc(lang, value) {
const entry = PREVIEW_LANGS[lang];
if (!entry) return { doc: null, error: `No preview for '${lang}'.` };
let body;
try {
body = entry.toBody(value);
} catch (e) {
return { doc: null, error: e && e.message ? e.message : String(e) };
}
const head =
"<!doctype html><html><head><meta charset=\"utf-8\">" +
`<meta http-equiv="Content-Security-Policy" content="${_RENDER_CSP}">` +
`<script>window.__previewLineOffset=${_OFFSET_TOKEN};</script>` +
_PREVIEW_BOOTSTRAP +
"</head><body style=\"margin:0\">";
// Lines of shell above the user's own code: the document head, plus whatever
// the language puts in the body ahead of it (the Preact build, for JSX).
const offset = (head.match(/\n/g) || []).length + body.userOffset;
return {
doc: (head + body.html + "</body></html>").replace(_OFFSET_TOKEN, String(offset)),
error: "",
};
}
// Auto-height bounds. The frame is sized from content, and content sized in
// viewport/percentage units is therefore sized from the frame - a body with its
// own margin makes that loop grow by the margin on every pass. Measuring the
// body box rather than documentElement is what actually settles that loop;
// _MAX_PREVIEW_H then caps anything still climbing within a few iterations.
//
// _MAX_H_STEPS is only a last resort against a document that oscillates
// forever, so it is generous: an interactive component legitimately changes
// height on every click, and a tight budget would freeze the frame mid-session
// at whatever size it happened to reach.
const _MIN_PREVIEW_H = 160;
const _MAX_PREVIEW_H = 720;
const _MAX_H_STEPS = 60;
// Live preview for a ```html or ```svg fenced block: a Preview/Code toggle,
// rendered via a sandboxed <iframe srcDoc>.
//
// Trust boundary: `sandbox="allow-scripts"` deliberately without
// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
// allow-same-origin forces the iframe onto an opaque origin, which is what
// actually matters here: even the inline scripts the CSP allows to run can't
// read this app's cookies/localStorage, can't call its API (no credentialed
// or same-origin fetch is possible), and can't reach `window.parent`. The CSP
// above closes the remaining gap same-origin-denial doesn't cover on its own:
// arbitrary outbound network access to the open internet. Nothing here is
// treated as a substitute for real code execution sandboxing (Docker, a WASM
// runtime, etc.) - this block never runs anything outside the browser's own
// iframe sandbox, which is the point: no backend attack surface at all.
function RenderBlock({ lang, value, streaming }) {
const [tab, setTab] = useState("preview");
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(value.trimEnd()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
// Don't preview a block whose fence hasn't closed yet - it's incomplete
// markup by definition, and re-pointing an iframe's srcDoc at a half-formed
// document on every streamed token is both wasteful and flickery. Code view
// already has its own streaming indicator (the same dot CodeBlock uses).
const showPreview = tab === "preview" && !streaming;
return (
<div style={{
background: "#0d0d0d",
border: "1px solid #2a2a2a",
borderRadius: "6px",
margin: "0.5rem 0",
overflow: "hidden",
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.3rem 0.75rem",
background: "#161616",
borderBottom: "1px solid #2a2a2a",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
<TabButton active={tab === "preview"} disabled={streaming} onClick={() => setTab("preview")}>
Preview
</TabButton>
<TabButton active={tab === "code"} onClick={() => setTab("code")}>
Code
</TabButton>
<span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace", marginLeft: "0.25rem" }}>
{lang}
{streaming && <span style={{ color: "#444", marginLeft: "0.4rem" }}></span>}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{showPreview && (
<button onClick={() => setExpanded((e) => !e)} style={_chromeButtonStyle("#555")}>
{expanded ? "Collapse" : "Expand"}
</button>
)}
{!streaming && (
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
{copied ? "Copied!" : "Copy"}
</button>
)}
</div>
</div>
{showPreview ? (
// Keyed by the markup: new markup is a new document, so remounting is
// what resets the reported error and measured height. No reset effect.
<PreviewFrame key={`${lang}:${value}`} lang={lang} value={value} expanded={expanded} />
) : (
<pre style={{
padding: "0.75rem 1rem",
overflowX: "auto",
fontSize: "0.85rem",
lineHeight: "1.5",
margin: 0,
fontFamily: "monospace",
}}>
<code>{value.trimEnd()}</code>
</pre>
)}
</div>
);
}
// The sandboxed frame plus the two things it reports back: its content height
// and its first uncaught error. Split out of RenderBlock so the caller can key
// it by markup - a fresh document then gets fresh state by remounting.
function PreviewFrame({ lang, value, expanded }) {
const [error, setError] = useState("");
const [height, setHeight] = useState(240);
const frameRef = useRef(null);
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
const stepsRef = useRef(0);
// Receive the bootstrap's reports. The frame is on an opaque origin, so
// e.origin is the string "null" and proves nothing - identify the sender by
// its window instead, which content inside the sandbox cannot forge.
useEffect(() => {
const onMessage = (e) => {
if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
const data = e.data;
if (!data || data.__nexusPreview !== 1) return;
if (typeof data.err === "string" && data.err) setError(data.err);
if (typeof data.h === "number" && Number.isFinite(data.h) && stepsRef.current < _MAX_H_STEPS) {
const next = Math.min(_MAX_PREVIEW_H, Math.max(_MIN_PREVIEW_H, Math.round(data.h)));
if (Math.abs(next - heightRef.current) >= 8) {
heightRef.current = next;
stepsRef.current += 1;
setHeight(next);
}
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
// A build failure (JSX that doesn't parse) has no document to show at all, so
// the message stands in for the frame rather than sitting under it.
const { doc, error: buildError } = buildSrcDoc(lang, value);
const shown = buildError || error;
return (
<>
{doc && (
<iframe
ref={frameRef}
title="rendered output"
sandbox="allow-scripts"
srcDoc={doc}
style={{
width: "100%",
height: expanded ? "70vh" : `${height}px`,
border: "none",
background: "#fff",
display: "block",
}}
/>
)}
{shown && (
<div style={{
background: "#2a1414",
borderTop: "1px solid #4a2020",
color: "#ff8a80",
fontFamily: "monospace",
fontSize: "0.75rem",
padding: "0.4rem 0.75rem",
// Text from inside the sandbox: rendered as a string, and wrapped
// rather than allowed to stretch the block.
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{shown}
</div>
)}
</>
);
}
function _chromeButtonStyle(color) {
return {
background: "transparent",
border: "none",
color,
cursor: "pointer",
fontSize: "0.75rem",
padding: "0.1rem 0.3rem",
};
}
function TabButton({ active, disabled, onClick, children }) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
background: active ? "#262626" : "transparent",
border: "none",
borderRadius: "4px",
color: disabled ? "#3a3a3a" : active ? "#eee" : "#888",
cursor: disabled ? "default" : "pointer",
fontSize: "0.75rem",
padding: "0.15rem 0.5rem",
}}
>
{children}
</button>
);
}
function TextBlock({ text }) {
const lines = text.split("\n");
const elements = [];
+1 -20
View File
@@ -44,7 +44,6 @@ export function Memory() {
const [message, setMessage] = useState("");
const [draggingId, setDraggingId] = useState(null);
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
const [projectNames, setProjectNames] = useState({}); // id -> name, for the scope badge
const loadItems = useCallback(async () => {
try {
@@ -58,15 +57,6 @@ export function Memory() {
useEffect(() => { loadItems(); }, [loadItems]);
// Facts scoped to a project only reach that project's chats label them so
// this page doesn't read as "everything here applies everywhere".
useEffect(() => {
fetch(`${API_BASE}/projects`)
.then(r => r.ok ? r.json() : { projects: [] })
.then(d => setProjectNames(Object.fromEntries((d.projects || []).map(p => [p.id, p.name]))))
.catch(() => {});
}, []);
const flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); };
const saveNew = async (section, text) => {
@@ -396,16 +386,7 @@ export function Memory() {
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
>
<span style={{ color: "#aaa", marginTop: "0.1rem", flexShrink: 0 }}>-</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>
{displayText}
{item.project_id && (
<span title="Only injected into this project's chats"
style={{ marginLeft: "0.5rem", padding: "0.05rem 0.4rem", background: "#1b2b3a",
border: "1px solid #2c4a63", borderRadius: "6px", color: "#8ab4ff", fontSize: "0.72rem" }}>
{projectNames[item.project_id] || "project"}
</span>
)}
</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>{displayText}</span>
<div style={{ display: "flex", gap: "0.3rem", flexShrink: 0, opacity: 0.4 }}
onMouseEnter={e => e.currentTarget.style.opacity = 1}
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
+4 -10
View File
@@ -20,8 +20,6 @@ const GRID_STYLE = {
alignItems: "stretch",
};
const withTag = (n) => (n.includes(":") ? n : `${n}:latest`).toLowerCase();
function ModelCard({ model, installed, pulling, pullingName, locked, onPull }) {
const fit = FIT[model.fit] || FIT.no;
const isPullingThis = pulling && pullingName === model.name;
@@ -226,12 +224,8 @@ export function Models({ onPullStateChange }) {
return () => clearInterval(interval);
}, [loadModels]);
// Ollama treats a bare name as ":latest" you pull "nomic-embed-text" and it
// comes back installed as "nomic-embed-text:latest". Comparing raw names left
// any untagged catalog entry permanently "missing", which locked the Required
// gate shut no matter how many times it was pulled.
const installedNames = useMemo(
() => new Set(allInstalled.map(m => withTag(m.name))),
() => new Set(allInstalled.map(m => m.name.toLowerCase())),
[allInstalled]
);
@@ -243,7 +237,7 @@ export function Models({ onPullStateChange }) {
() => (recommended?.models ?? []).filter(m => !m.required),
[recommended]
);
const requiredMissing = requiredModels.filter(m => !installedNames.has(withTag(m.name)));
const requiredMissing = requiredModels.filter(m => !installedNames.has(m.name.toLowerCase()));
const requiredSatisfied = recommended != null && requiredMissing.length === 0;
return (
@@ -357,7 +351,7 @@ export function Models({ onPullStateChange }) {
<ModelCard
key={m.name}
model={m}
installed={installedNames.has(withTag(m.name))}
installed={installedNames.has(m.name.toLowerCase())}
pulling={pulling}
pullingName={pullingName}
locked={false}
@@ -378,7 +372,7 @@ export function Models({ onPullStateChange }) {
<ModelCard
key={m.name}
model={m}
installed={installedNames.has(withTag(m.name))}
installed={installedNames.has(m.name.toLowerCase())}
pulling={pulling}
pullingName={pullingName}
locked={!requiredSatisfied}
+2 -1
View File
@@ -29,6 +29,7 @@ export function Playbook() {
}
}, []);
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount; setPlaybooks runs after await, not a synchronous cascading render
useEffect(() => { loadPlaybooks(); }, [loadPlaybooks]);
const safeString = (v) => (v == null ? "" : String(v));
@@ -359,7 +360,7 @@ export function Playbook() {
/>
<input
type="text"
placeholder="Tools: search_memory, search_history, search_documents, list_models, get_time, web_search, fetch_url, remember"
placeholder="Playbook tools: search_memory, … (render_preview auto-attaches on visual asks)"
value={form.tools}
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
-430
View File
@@ -1,430 +0,0 @@
import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// Project workspace: a project groups chats and RAG documents. The active
// project is persisted in settings, and chat scopes its document retrieval to
// it (see synapse/main.py chat_stream_endpoint). "" = unscoped / All.
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
const card = { padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px", display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.6rem" };
const btn = { padding: "0.55rem 1rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" };
const ghost = { padding: "0.55rem 1rem", background: "#1a1a1a", color: "#ccc", border: "1px solid #333", borderRadius: "8px", cursor: "pointer" };
const danger = { padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" };
function Modal({ title, onClose, children }) {
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{title}</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>{children}</div>
</div>
</div>
);
}
export function Projects({ onOpenChat, onNewChat }) {
const [docs, setDocs] = useState([]);
const [chats, setChats] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} preview
const [uploading, setUploading] = useState(false); // upload modal open
const [picking, setPicking] = useState(null); // conversations available to add
const [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const [instructions, setInstructions] = useState(""); // per-project system prompt
const [savedInstructions, setSavedInstructions] = useState("");
const [facts, setFacts] = useState([]); // memory scoped to this project
const [newFact, setNewFact] = useState("");
const [dragging, setDragging] = useState(false); // file hovering the drop zone
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (!r.ok) return;
const d = await r.json();
setProjects(d.projects || []);
setActiveProject(d.active || "");
const mine = (d.projects || []).find(p => p.id === (d.active || ""));
setInstructions(mine?.instructions || "");
setSavedInstructions(mine?.instructions || "");
} catch { /* offline */ }
};
const load = async (pid) => {
const scope = pid ?? activeProject;
if (!scope) { setDocs([]); setChats([]); setFacts([]); return; }
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
const c = await fetch(`${API_BASE}/conversations?project=${encodeURIComponent(scope)}`);
if (c.ok) setChats((await c.json()).conversations || []);
const m = await fetch(`${API_BASE}/memory?project=${encodeURIComponent(scope)}`);
if (m.ok) setFacts((await m.json()).items || []);
} catch { /* offline — leave lists as-is */ }
};
useEffect(() => { loadProjects().then(() => {}); }, []);
useEffect(() => { load(activeProject); }, [activeProject]); // eslint-disable-line react-hooks/exhaustive-deps
// Switch the active workspace (persisted in settings; scopes chat RAG and
// any new conversation started from here).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
loadProjects();
};
const newProject = async () => {
const name = window.prompt("New project name:");
if (!name || !name.trim()) return;
const r = await fetch(`${API_BASE}/projects`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (r.ok) { const p = await r.json(); await setActive(p.id); }
};
const removeProject = async () => {
if (!activeProject) return;
if (!window.confirm("Delete this project? Its chats and documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
// --- instructions --------------------------------------------------------
const saveInstructions = async () => {
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instructions }),
});
setSavedInstructions(instructions);
loadProjects();
};
// --- memory facts scoped to this project ---------------------------------
const addFact = async () => {
const text = newFact.trim();
if (!text) return;
await fetch(`${API_BASE}/memory`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, section: "Projects", project_id: activeProject }),
});
setNewFact("");
load();
};
const removeFact = async (id) => {
await fetch(`${API_BASE}/memory/${encodeURIComponent(id)}`, { method: "DELETE" });
load();
};
// --- chats ---------------------------------------------------------------
const moveChat = async (id, pid) => {
await fetch(`${API_BASE}/conversations/${encodeURIComponent(id)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: pid }),
});
load();
};
const openPicker = async () => {
const r = await fetch(`${API_BASE}/conversations`);
if (r.ok) setPicking(((await r.json()).conversations || []).filter(c => c.project_id !== activeProject));
};
const addChat = async (id) => {
await moveChat(id, activeProject);
setPicking(p => (p || []).filter(c => c.id !== id));
};
// --- documents -----------------------------------------------------------
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (file) uploadFile(file);
};
// Resolves when the file is indexed, so a multi-file drop uploads in order
// instead of racing (each one re-renders the list on completion).
const uploadFile = (file) => new Promise((resolve) => {
setBusy(true);
setMessage(`Reading ${file.name}`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
resolve();
}
};
reader.readAsDataURL(file);
});
const onDrop = async (e) => {
e.preventDefault();
setDragging(false);
if (!activeProject) return;
const files = [...(e.dataTransfer?.files || [])];
if (!files.length) return;
setUploading(true);
for (const f of files) await uploadFile(f);
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const label = (c) => c.title || c.preview || "Untitled chat";
return (
<div
onDragOver={e => { e.preventDefault(); if (activeProject) setDragging(true); }}
onDragLeave={e => { if (e.currentTarget === e.target) setDragging(false); }}
onDrop={onDrop}
style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px", position: "relative",
outline: dragging ? "2px dashed #007acc" : "none", borderRadius: "12px" }}
>
{!activeProject ? (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1rem" }}>
<h2 style={{ margin: 0, flex: 1 }}>📁 Projects</h2>
<button onClick={newProject} style={btn}> New project</button>
</div>
{projects.length === 0 ? (
<div style={{ color: "#777" }}>
No projects. A project keeps its own chats, documents, memory and instructions.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{projects.map(p => (
<div key={p.id} onClick={() => setActive(p.id)} style={{ ...card, cursor: "pointer" }} title="Open project">
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>
{p.chats} chat{p.chats === 1 ? "" : "s"} · {p.docs} document{p.docs === 1 ? "" : "s"}
</div>
</div>
<span style={{ color: "#555" }}></span>
</div>
))}
</div>
)}
</>
) : (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.4rem" }}>
<button onClick={() => setActive("")} title="Back to all projects" style={ghost}> Projects</button>
<h2 style={{ margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{projects.find(p => p.id === activeProject)?.name || "Project"}
</h2>
<button onClick={removeProject} title="Delete this project (chats and documents kept)" style={danger}>
Delete project
</button>
</div>
{dragging && (
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0d2233", border: "1px solid #007acc",
borderRadius: "10px", padding: "0.7rem 1rem", marginBottom: "0.8rem", color: "#8ab4ff" }}>
Drop files to index them in this project.
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>How the assistant should act here</h3>
<textarea rows={4} value={instructions} onChange={e => setInstructions(e.target.value)}
placeholder="e.g. Answer as a build engineer. Prefer bash over Python. Always cite the doc you used."
style={input} />
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginTop: "0.5rem" }}>
<button onClick={saveInstructions} disabled={instructions === savedInstructions}
style={{ ...btn, background: instructions === savedInstructions ? "#333" : "#007acc",
cursor: instructions === savedInstructions ? "default" : "pointer" }}>
Save instructions
</button>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Layered under the active playbook, for chats in this project only.
</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Chats</h3>
<button onClick={onNewChat} style={btn}> New chat</button>
<button onClick={openPicker} style={ghost}>Add existing</button>
</div>
{chats.length === 0 ? (
<div style={{ color: "#777", marginBottom: "1.5rem" }}>No chats in this project yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", marginBottom: "1.5rem" }}>
{chats.map(c => (
<div key={c.id} style={card}>
<div onClick={() => onOpenChat(c.id)} style={{ cursor: "pointer", flex: 1, minWidth: 0 }} title="Open in chat">
<div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{new Date(c.updated_at * 1000).toLocaleString()}</div>
</div>
<button onClick={() => moveChat(c.id, "")} title="Remove from project (chat kept)" style={danger}>Remove</button>
</div>
))}
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>Project memory</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{facts.map(f => (
<div key={f.id} style={card}>
<span style={{ flex: 1, minWidth: 0, color: "#ddd", fontSize: "0.9rem" }}>{f.text}</span>
<button onClick={() => removeFact(f.id)} style={danger}>Delete</button>
</div>
))}
<div style={{ display: "flex", gap: "0.6rem" }}>
<input type="text" value={newFact} onChange={e => setNewFact(e.target.value)}
onKeyDown={e => e.key === "Enter" && addFact()}
placeholder="Fact the assistant should remember in this project only" style={input} />
<button onClick={addFact} style={btn}>Add</button>
</div>
</div>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Facts learned in this project's chats land here. Global facts stay on the Memory page.
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Documents</h3>
<button onClick={() => { setMessage(""); setUploading(true); }} style={btn}> Add documents</button>
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={card}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)} style={danger}>Delete</button>
</div>
))}
</div>
)}
</>
)}
{uploading && (
<Modal title="Add documents to this project" onClose={() => setUploading(false)}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or drop / pick a .pdf/.docx/.txt/.md file"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file — text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ ...btn, padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
</Modal>
)}
{picking && (
<Modal title="Add an existing chat" onClose={() => setPicking(null)}>
{picking.length === 0 ? (
<div style={{ color: "#777" }}>No other chats.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{picking.map(c => (
<div key={c.id} style={card}>
<div style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<button onClick={() => addChat(c.id)} style={ghost}>Add</button>
</div>
))}
</div>
)}
</Modal>
)}
{viewing && (
<Modal title={viewing.title} onClose={() => setViewing(null)}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</Modal>
)}
</div>
);
}
-253
View File
@@ -1,253 +0,0 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
import { MailAccounts } from "./MailAccounts";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const accountLabel = (a) => a.label || a.from_addr || a.username || "(unnamed)";
export function Mail() {
const [accounts, setAccounts] = useState(null); // null until loaded
const [expanded, setExpanded] = useState({}); // {accountId: bool}
const [foldersByAccount, setFoldersByAccount] = useState({}); // {accountId: [folders]}
const [selection, setSelection] = useState(null); // {accountId, folder}
const [messages, setMessages] = useState([]);
const [selected, setSelected] = useState(null); // full message
const [composing, setComposing] = useState(null); // {accountId,to,cc,subject,body}
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const [showAccounts, setShowAccounts] = useState(false);
const loadFoldersFor = async (accountId) => {
const r = await fetch(`${API_BASE}/mail/folders?account_id=${encodeURIComponent(accountId)}`);
const folders = r.ok ? (await r.json()).folders || [] : [];
setFoldersByAccount(prev => ({ ...prev, [accountId]: folders }));
return folders;
};
const loadMessages = async (sel = selection) => {
if (!sel) return;
setBusy(true); setSelected(null);
try {
const r = await fetch(`${API_BASE}/mail/messages?account_id=${encodeURIComponent(sel.accountId)}&folder=${encodeURIComponent(sel.folder)}&limit=40`);
const data = r.ok ? await r.json() : null;
setMessages(data ? data.messages || [] : []);
if (!r.ok) setNote((await r.json().catch(() => ({}))).detail || "Failed to load messages.");
} finally { setBusy(false); }
};
const bootstrap = async () => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
const configured = list.filter(a => a.configured);
setExpanded(Object.fromEntries(list.map(a => [a.id, true])));
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { bootstrap(); }, []);
const onAccountsChanged = async (list) => {
setAccounts(list);
setExpanded(prev => ({ ...Object.fromEntries(list.map(a => [a.id, true])), ...prev }));
const configured = list.filter(a => a.configured);
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
// if the active selection's account vanished, fall back to the first configured one
if (selection && !configured.some(a => a.id === selection.accountId)) {
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
} else {
setSelection(null); setMessages([]); setSelected(null);
}
} else if (!selection && configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
const openMessage = async (m) => {
setBusy(true);
try {
const r = await fetch(`${API_BASE}/mail/message?account_id=${encodeURIComponent(selection.accountId)}&folder=${encodeURIComponent(selection.folder)}&uid=${encodeURIComponent(m.uid)}`);
if (r.ok) { setSelected(await r.json()); setMessages(prev => prev.map(x => x.uid === m.uid ? { ...x, seen: true } : x)); }
} finally { setBusy(false); }
};
const deleteMessage = async (m) => {
if (!window.confirm("Delete this message?")) return;
await fetch(`${API_BASE}/mail/delete`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account_id: selection.accountId, folder: selection.folder, uid: m.uid }),
});
setSelected(null); loadMessages();
};
const sendCompose = async () => {
setBusy(true); setNote("Sending…");
try {
const r = await fetch(`${API_BASE}/mail/send`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(composing),
});
const d = await r.json().catch(() => ({}));
if (r.ok) { setNote(`Sent to ${(d.to || []).join(", ")}.`); setComposing(null); }
else setNote(d.detail || "Send failed.");
} finally { setBusy(false); }
};
const startCompose = () => setComposing({
accountId: (selection && selection.accountId) || (accounts || []).find(a => a.configured)?.id || "",
to: "", cc: "", subject: "", body: "",
});
const reply = (m) => setComposing({
accountId: selection.accountId,
to: m.from, cc: "", subject: /^re:/i.test(m.subject || "") ? m.subject : `Re: ${m.subject || ""}`,
body: `\n\n---\nOn ${m.date ? new Date(m.date).toLocaleString() : ""}, ${m.from} wrote:\n${(m.text || "").slice(0, 2000)}`,
});
const selectFolder = (accountId, folder) => {
const sel = { accountId, folder };
setSelection(sel);
loadMessages(sel);
};
const toggleExpanded = (accountId) => setExpanded(prev => ({ ...prev, [accountId]: !prev[accountId] }));
const configuredAccounts = (accounts || []).filter(a => a.configured);
if (accounts === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
// ---- empty state: no configured accounts yet ----
if (configuredAccounts.length === 0) {
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "560px" }}>
<h2 style={{ marginTop: 0 }}> Mail</h2>
<p style={{ color: "#aaa" }}>No mail accounts configured yet.</p>
<button onClick={() => setShowAccounts(true)} style={btn("#007acc")}>+ Add mail account</button>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
// ---- client ----
return (
<div style={{ display: "flex", height: "100%", color: "#eee" }}>
<div style={{ width: "200px", borderRight: "1px solid #2a2a2a", padding: "0.75rem", overflowY: "auto", flexShrink: 0, display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "0.4rem", marginBottom: "0.75rem" }}>
<button onClick={startCompose} style={{ ...btn("#007acc"), flexGrow: 1 }}> Compose</button>
<button onClick={() => setShowAccounts(true)} title="Manage accounts"
style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", padding: "0.5rem 0.6rem" }}></button>
</div>
<div style={{ flexGrow: 1, overflowY: "auto" }}>
{(accounts || []).map(a => (
<div key={a.id} style={{ marginBottom: "0.5rem" }}>
<div onClick={() => toggleExpanded(a.id)}
style={{ display: "flex", alignItems: "center", gap: "0.35rem", padding: "0.3rem 0.3rem", cursor: "pointer", color: a.configured ? "#ddd" : "#c9a227", fontSize: "0.82rem", fontWeight: 600 }}>
<span style={{ display: "inline-block", width: "0.8em", transform: expanded[a.id] ? "rotate(90deg)" : "none", transition: "transform 0.1s" }}></span>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{accountLabel(a)}</span>
</div>
{expanded[a.id] && (
!a.configured ? (
<div style={{ padding: "0.2rem 0.5rem 0.2rem 1.4rem", fontSize: "0.75rem", color: "#c9a227" }}>needs password to fix</div>
) : (foldersByAccount[a.id] || []).map(f => {
const isActive = selection && selection.accountId === a.id && selection.folder === f;
return (
<div key={f} onClick={() => selectFolder(a.id, f)}
style={{ padding: "0.32rem 0.5rem 0.32rem 1.4rem", borderRadius: "6px", cursor: "pointer", fontSize: "0.8rem", background: isActive ? "#1c2a3a" : "transparent", color: isActive ? "#fff" : "#aaa", marginBottom: "0.1rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{f}
</div>
);
})
)}
</div>
))}
</div>
</div>
<div style={{ width: "320px", borderRight: "1px solid #2a2a2a", overflowY: "auto", flexShrink: 0 }}>
<div style={{ padding: "0.5rem 0.75rem", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid #2a2a2a" }}>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{selection ? selection.folder : "—"}
</span>
<button onClick={() => loadMessages()} disabled={!selection} style={{ background: "none", border: "none", color: "#888", cursor: "pointer" }}></button>
</div>
{!selection ? <div style={{ padding: "1rem", color: "#777" }}>Select a folder.</div>
: busy && messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>Loading</div>
: messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>{note || "No messages."}</div>
: messages.map(m => (
<div key={m.uid} onClick={() => openMessage(m)}
style={{ padding: "0.6rem 0.75rem", borderBottom: "1px solid #1e1e1e", cursor: "pointer", background: selected && selected.uid === m.uid ? "#1c2a3a" : "transparent" }}>
<div style={{ fontSize: "0.8rem", color: m.seen ? "#999" : "#fff", fontWeight: m.seen ? 400 : 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.from}</div>
<div style={{ fontSize: "0.82rem", color: m.seen ? "#aaa" : "#eee", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.subject || "(no subject)"}</div>
<div style={{ fontSize: "0.72rem", color: "#666", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.preview}</div>
</div>
))}
</div>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "1rem", minWidth: 0 }}>
{composing ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "640px" }}>
<h3 style={{ margin: 0 }}>New message</h3>
{configuredAccounts.length > 1 && (
<div>
<label style={{ display: "block", fontSize: "0.75rem", color: "#888", marginBottom: "0.3rem" }}>From</label>
<select value={composing.accountId} onChange={e => setComposing({ ...composing, accountId: e.target.value })}
style={{ ...inputStyle, width: "100%" }}>
{configuredAccounts.map(a => <option key={a.id} value={a.id}>{accountLabel(a)} {a.from_addr || a.username}</option>)}
</select>
</div>
)}
<input placeholder="To" value={composing.to} onChange={e => setComposing({ ...composing, to: e.target.value })} style={inputStyle} />
<input placeholder="Cc" value={composing.cc} onChange={e => setComposing({ ...composing, cc: e.target.value })} style={inputStyle} />
<input placeholder="Subject" value={composing.subject} onChange={e => setComposing({ ...composing, subject: e.target.value })} style={inputStyle} />
<textarea rows={14} placeholder="Body" value={composing.body} onChange={e => setComposing({ ...composing, body: e.target.value })} style={inputStyle} />
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button onClick={sendCompose} disabled={busy || !composing.to.trim()} style={btn(busy || !composing.to.trim() ? "#555" : "#007acc")}>Send</button>
<button onClick={() => setComposing(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>Cancel</button>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
) : selected ? (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem" }}>
<h3 style={{ margin: "0 0 0.5rem" }}>{selected.subject || "(no subject)"}</h3>
<div style={{ display: "flex", gap: "0.4rem", flexShrink: 0 }}>
<button onClick={() => reply(selected)} style={btn("#2a4a6a")}>Reply</button>
<button onClick={() => deleteMessage(selected)} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>
</div>
</div>
<div style={{ color: "#aaa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>
<div><b>From:</b> {selected.from}</div>
<div><b>To:</b> {(selected.to || []).join(", ")}</div>
{selected.date && <div>{new Date(selected.date).toLocaleString()}</div>}
</div>
{/* Sandboxed iframe: no scripts, no same-origin — email HTML can't touch the app. */}
{selected.html
? <iframe title="message" sandbox="" srcDoc={selected.html} style={{ width: "100%", height: "60vh", border: "1px solid #2a2a2a", borderRadius: "8px", background: "#fff" }} />
: <pre style={{ whiteSpace: "pre-wrap", fontFamily: "system-ui", color: "#ddd" }}>{selected.text || "(empty)"}</pre>}
{selected.attachments && selected.attachments.length > 0 && (
<div style={{ marginTop: "0.75rem", color: "#888", fontSize: "0.8rem" }}>
📎 {selected.attachments.map(a => a.name).join(", ")}
</div>
)}
</div>
) : (
<div style={{ color: "#777", paddingTop: "2rem", textAlign: "center" }}>Select a message.</div>
)}
</div>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
@@ -1,182 +0,0 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const labelStyle = { display: "block", fontSize: "0.72rem", color: "#888", marginBottom: "0.3rem", textTransform: "uppercase", letterSpacing: "0.05em" };
const BLANK = {
label: "", username: "", password: "", from_addr: "", from_name: "",
imap_host: "imap.mail.me.com", imap_port: 993, smtp_host: "smtp.mail.me.com", smtp_port: 587,
};
// macOS-Mail-style "Internet Accounts" window: accounts on the left, the
// selected account's config on the right. Lets you manage several mailboxes
// that happen to share the same IMAP/SMTP server.
export function MailAccounts({ onClose, onChanged }) {
const [accounts, setAccounts] = useState(null); // null = loading
const [selectedId, setSelectedId] = useState(null); // null = "add account" form
const [form, setForm] = useState(BLANK);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const selectAccount = (id, list = accounts) => {
setNote("");
setSelectedId(id);
if (id === null) { setForm(BLANK); return; }
const a = (list || []).find(x => x.id === id);
if (a) setForm({ ...a, password: "" });
};
const load = async (selectAfter) => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
onChanged && onChanged(list);
if (selectAfter !== undefined) selectAccount(selectAfter, list);
return list;
};
useEffect(() => {
load().then(list => { if (list.length) selectAccount(list[0].id, list); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = async () => {
setBusy(true); setNote("Saving…");
try {
const url = selectedId ? `${API_BASE}/mail/accounts/${selectedId}` : `${API_BASE}/mail/accounts`;
const r = await fetch(url, {
method: selectedId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!r.ok) { setNote("Save failed."); return; }
const saved = await r.json();
setNote("Saved.");
await load(saved.id);
} finally { setBusy(false); }
};
const test = async () => {
if (!selectedId) { setNote("Save the account first."); return; }
setBusy(true); setNote("Testing…");
try {
const r = await fetch(`${API_BASE}/mail/accounts/${selectedId}/test`, { method: "POST" });
const d = await r.json().catch(() => ({}));
setNote(d.ok ? `Connected — ${d.folders} folders.` : `Connection failed: ${d.error || "check credentials"}`);
} finally { setBusy(false); }
};
const remove = async () => {
if (!selectedId) return;
if (!window.confirm(`Delete account "${form.label || form.from_addr || form.username}"?`)) return;
setBusy(true);
try {
await fetch(`${API_BASE}/mail/accounts/${selectedId}`, { method: "DELETE" });
const list = await load();
selectAccount(list.length ? list[0].id : null, list);
} finally { setBusy(false); }
};
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 200, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", width: "720px", maxWidth: "100%", height: "520px", maxHeight: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.85rem 1.1rem", borderBottom: "1px solid #2a2a2a", flexShrink: 0 }}>
<strong>Mail Accounts</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ display: "flex", flexGrow: 1, minHeight: 0 }}>
{/* Left: accounts bar */}
<div style={{ width: "200px", flexShrink: 0, borderRight: "1px solid #2a2a2a", display: "flex", flexDirection: "column" }}>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "0.5rem" }}>
{accounts === null ? (
<div style={{ color: "#666", fontSize: "0.82rem", padding: "0.5rem" }}>Loading</div>
) : accounts.length === 0 ? (
<div style={{ color: "#666", fontSize: "0.8rem", padding: "0.5rem" }}>No accounts yet.</div>
) : accounts.map(a => (
<div key={a.id} onClick={() => selectAccount(a.id)}
style={{
padding: "0.5rem 0.6rem", borderRadius: "6px", cursor: "pointer", marginBottom: "0.2rem",
background: selectedId === a.id ? "#1c2a3a" : "transparent",
color: selectedId === a.id ? "#fff" : "#ccc",
}}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.label || a.from_addr || a.username || "(unnamed)"}
</div>
<div style={{ fontSize: "0.72rem", color: a.configured ? "#8aff8a" : "#c9a227", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.configured ? (a.from_addr || a.username) : "needs password"}
</div>
</div>
))}
</div>
<div style={{ padding: "0.5rem", borderTop: "1px solid #2a2a2a" }}>
<button onClick={() => selectAccount(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", width: "100%" }}>+ Add account</button>
</div>
</div>
{/* Right: selected account form */}
<div style={{ flexGrow: 1, padding: "1.1rem", overflowY: "auto" }}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#eee" }}>
{selectedId ? "Edit account" : "New account"}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "440px" }}>
<div>
<label style={labelStyle}>Nickname (optional)</label>
<input value={form.label} onChange={e => update("label", e.target.value)} placeholder="e.g. Personal, Work" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Apple ID (login)</label>
<input value={form.username} onChange={e => update("username", e.target.value)} placeholder="you@icloud.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{selectedId ? "App-specific password (blank = keep current)" : "App-specific password"}</label>
<input type="password" value={form.password} onChange={e => update("password", e.target.value)} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From address</label>
<input value={form.from_addr} onChange={e => update("from_addr", e.target.value)} placeholder="nexus@enderofwings.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From name (optional)</label>
<input value={form.from_name} onChange={e => update("from_name", e.target.value)} style={inputStyle} />
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>IMAP host</label>
<input value={form.imap_host} onChange={e => update("imap_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.imap_port} onChange={e => update("imap_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>SMTP host</label>
<input value={form.smtp_host} onChange={e => update("smtp_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.smtp_port} onChange={e => update("smtp_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center", marginTop: "0.4rem" }}>
<button onClick={save} disabled={busy} style={btn(busy ? "#555" : "#007acc")}>{selectedId ? "Save" : "Create"}</button>
{selectedId && <button onClick={test} disabled={busy} style={btn("#2a4a6a")}>Test connection</button>}
{selectedId && <button onClick={remove} disabled={busy} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>}
</div>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,7 +0,0 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Mail as Component } from "./Mail";
export const MANIFEST = { key: "mail", label: "Mail", icon: "✉️", order: 1 };
@@ -1,159 +0,0 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const sectionStyle = { background: "#161616", border: "1px solid #333", borderRadius: "12px", padding: "1.25rem", marginBottom: "1rem" };
const dot = (ok) => ({
display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", flexShrink: 0,
background: ok === null ? "#555" : ok ? "#4caf50" : "#f44336",
});
export function Network() {
const [status, setStatus] = useState(null); // {hostname, connection, vpn}
const [targets, setTargets] = useState(null); // [{id,label,host,ok,latency_ms}]
const [newLabel, setNewLabel] = useState("");
const [newHost, setNewHost] = useState("");
const [busy, setBusy] = useState(false);
const [vpnBusy, setVpnBusy] = useState(false);
const [note, setNote] = useState("");
const loadStatus = async () => {
const r = await fetch(`${API_BASE}/network/status`);
if (r.ok) setStatus(await r.json());
};
const loadAndPingTargets = async () => {
const r = await fetch(`${API_BASE}/network/ping`);
setTargets(r.ok ? (await r.json()).targets || [] : []);
};
const refresh = async () => {
setBusy(true);
try { await Promise.all([loadStatus(), loadAndPingTargets()]); }
finally { setBusy(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { refresh(); }, []);
const toggleVpn = async () => {
if (!status || !status.vpn.configured) return;
setVpnBusy(true); setNote("");
try {
const r = await fetch(`${API_BASE}/network/vpn/toggle`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable: !status.vpn.connected }),
});
const d = await r.json().catch(() => ({}));
if (r.ok) setStatus(s => ({ ...s, vpn: d }));
else setNote(d.detail || "VPN toggle failed.");
} finally { setVpnBusy(false); }
};
const addTarget = async () => {
if (!newHost.trim()) return;
setBusy(true);
try {
const r = await fetch(`${API_BASE}/network/targets`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: newLabel.trim(), host: newHost.trim() }),
});
if (r.ok) { setNewLabel(""); setNewHost(""); await loadAndPingTargets(); }
} finally { setBusy(false); }
};
const removeTarget = async (id) => {
setBusy(true);
try {
await fetch(`${API_BASE}/network/targets/${id}`, { method: "DELETE" });
await loadAndPingTargets();
} finally { setBusy(false); }
};
if (status === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
const conn = status.connection;
const vpn = status.vpn;
return (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
<h2 style={{ margin: 0, fontSize: "1.1rem", color: "#eee" }}>📡 Network</h2>
<button onClick={refresh} disabled={busy} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>
{busy ? "Refreshing…" : "⟳ Refresh"}
</button>
</div>
{/* Connection */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>Connection</h3>
<div style={{ display: "flex", gap: "2rem", flexWrap: "wrap", fontSize: "0.88rem" }}>
<div><span style={{ color: "#666" }}>Host</span><div style={{ color: "#eee" }}>{status.hostname}</div></div>
<div><span style={{ color: "#666" }}>Type</span><div style={{ color: "#eee", textTransform: "capitalize" }}>{conn.type}</div></div>
<div><span style={{ color: "#666" }}>Interface</span><div style={{ color: "#eee" }}>{conn.interface || "—"}</div></div>
<div><span style={{ color: "#666" }}>IP</span><div style={{ color: "#eee" }}>{conn.ip || "—"}</div></div>
</div>
</div>
{/* VPN */}
{vpn.available && (
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>WireGuard VPN</h3>
{!vpn.configured ? (
<p style={{ margin: 0, fontSize: "0.85rem", color: "#666" }}>No WireGuard tunnel configured in NetworkManager.</p>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={dot(vpn.connected)} />
<span style={{ fontSize: "0.88rem", color: "#ccc" }}>{vpn.name}</span>
<span style={{ fontSize: "0.82rem", color: vpn.connected ? "#4caf50" : "#888" }}>
{vpn.connected ? "Connected" : "Disconnected"}
</span>
<button onClick={toggleVpn} disabled={vpnBusy}
style={{ ...btn(vpn.connected ? "#2a1a1a" : "#152a15"), color: vpn.connected ? "#ff8a80" : "#8aff8a", marginLeft: "auto" }}>
{vpnBusy ? "Working…" : vpn.connected ? "Disconnect" : "Connect"}
</button>
</div>
)}
{note && <p style={{ margin: "0.5rem 0 0", color: "#f44336", fontSize: "0.82rem" }}>{note}</p>}
</div>
)}
{/* Ping targets */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Ping targets</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
Hosts to check reachability for a router, a VPN endpoint, anything on your network.
</p>
{(targets || []).length === 0 ? (
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "1rem" }}>No targets yet.</div>
) : (
<div style={{ marginBottom: "1rem" }}>
{targets.map(t => (
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.45rem 0", borderBottom: "1px solid #222" }}>
<span style={dot(t.ok)} />
<span style={{ fontSize: "0.88rem", color: "#eee", minWidth: "120px" }}>{t.label}</span>
<span style={{ fontSize: "0.82rem", color: "#888" }}>{t.host}</span>
<span style={{ fontSize: "0.8rem", color: "#666", marginLeft: "auto" }}>
{t.ok ? (t.latency_ms != null ? `${t.latency_ms.toFixed(0)} ms` : "reachable") : t.ok === false ? "unreachable" : ""}
</span>
<button onClick={() => removeTarget(t.id)} disabled={busy}
style={{ background: "transparent", border: "none", color: "#888", cursor: "pointer", fontSize: "0.9rem", padding: "0 4px" }}></button>
</div>
))}
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input placeholder="Label (optional)" value={newLabel} onChange={e => setNewLabel(e.target.value)}
style={{ ...inputStyle, width: "160px" }} />
<input placeholder="Host or IP" value={newHost} onChange={e => setNewHost(e.target.value)}
onKeyDown={e => e.key === "Enter" && addTarget()} style={{ ...inputStyle, width: "200px" }} />
<button onClick={addTarget} disabled={busy || !newHost.trim()} style={btn(!newHost.trim() ? "#555" : "#007acc")}>+ Add</button>
</div>
</div>
</div>
);
}
@@ -1,7 +0,0 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Network as Component } from "./Network";
export const MANIFEST = { key: "network", label: "Network", icon: "📡", order: 2 };
-10
View File
@@ -1,10 +0,0 @@
// Auto-discovers feature modules under modules/ — any folder with a
// module.jsx exporting MANIFEST ({key,label,icon,order}) + Component is
// picked up automatically. Add a new module folder and it appears in the
// Modules hover menu with no edits here.
const discovered = import.meta.glob("./*/module.jsx", { eager: true });
export const MODULES = Object.values(discovered)
.filter(m => m.MANIFEST && m.Component)
.map(m => ({ ...m.MANIFEST, Component: m.Component }))
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
+686
View File
@@ -0,0 +1,686 @@
/*
* jsx-transform.js JSX/TSX in, plain JS out. No dependencies.
*
* Scope is deliberately the single self-contained component a model writes into
* a chat fence: elements, fragments, attributes, spreads, expression children,
* and the TypeScript annotations that decorate them. It is not a TypeScript
* compiler and does not try to be - the real one costs more bytes than this
* whole app, and nothing here needs to survive input the user didn't ask a
* local model to produce.
*
* Failure is loud on purpose. Anything this can't parse throws TransformError,
* which the caller shows in place of the preview; anything it declines to touch
* is passed through, so unsupported TypeScript reaches the browser and surfaces
* as a SyntaxError in the preview's error bar (see Markdown.jsx's bootstrap).
* Both beat quietly emitting code that runs and does the wrong thing.
*
* Pure string -> string, no DOM and no eval, which is what lets it run in the
* parent app instead of inside the sandbox: transforming untrusted text is not
* executing it. Tested headless by jsx-transform.test.js (node --test).
*/
export class TransformError extends Error {}
// Characters after which a value may start, so a `<` opens JSX rather than
// acting as less-than and a `/` starts a regex rather than dividing.
//
// `)` and `]` are deliberately absent: they *end* a value, so `f(x) / 2` is a
// division and `xs[0] < 3` is a comparison. Including them made
// `Math.sin(t) / 6` scan as an unterminated regex.
const EXPR_START = new Set([
"", "(", ",", "=", ":", ";", "{", "}", "[", "&", "|", "?", "!",
"+", "-", "*", "/", "%", "^", "~", ">", "<",
]);
// Same idea for keywords: `return <div/>` is JSX, `a in <b` is not real code.
const EXPR_KEYWORDS = new Set([
"return", "yield", "await", "typeof", "in", "of", "case", "do", "else",
"new", "delete", "void", "throw", "default", "instanceof",
]);
const ID_START = /[A-Za-z_$]/;
const ID_CHAR = /[\w$]/;
// A generic argument list holds only type syntax. Used to tell `useState<T>(0)`
// (strip the <T>) from `a < b > (c)` (arithmetic, leave alone).
const TYPE_ARG_CHARS = /^[\w$\s,.[\]|&<>'"-]*$/;
function isIdentifier(ch) {
return ch !== undefined && ID_CHAR.test(ch);
}
function countNewlines(text) {
let n = 0;
for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
return n;
}
/** The newlines `consumed` had that `produced` lost, so line numbers hold. */
function missingNewlines(consumed, produced) {
return "\n".repeat(Math.max(0, countNewlines(consumed) - countNewlines(produced)));
}
class Scanner {
constructor(src) {
this.src = src;
this.i = 0;
this.out = [];
this.prevSig = ""; // last significant char emitted
this.prevWord = ""; // last identifier/keyword emitted
this.defaultExport = null;
this.components = []; // capitalized declarations, in source order
this.imports = []; // {names, module} for every import statement dropped
// One frame per unclosed opener. `ch` tells a parameter list from an object
// literal - the difference between stripping a type annotation and eating a
// property's value - and `ternaries` counts `?`s still waiting for their
// `:`, so a conditional's else-branch isn't mistaken for a type either.
this.frames = [{ ch: "", ternaries: 0 }];
}
get frame() {
return this.frames[this.frames.length - 1];
}
get context() {
return this.frame.ch;
}
get eof() {
return this.i >= this.src.length;
}
peek(offset = 0) {
return this.src[this.i + offset];
}
emit(text) {
if (!text) return;
this.out.push(text);
const trimmed = text.trimEnd();
if (trimmed) this.prevSig = trimmed[trimmed.length - 1];
}
fail(message) {
const line = this.src.slice(0, this.i).split("\n").length;
throw new TransformError(`${message} (line ${line})`);
}
}
/** Transform a JSX/TSX source string into runnable JS. */
export function transform(source) {
const sc = new Scanner(String(source ?? ""));
scanCode(sc, null);
return {
code: sc.out.join(""),
defaultExport: sc.defaultExport,
components: sc.components,
imports: sc.imports,
};
}
/**
* Scan code until `stop` is reached at depth zero (or end of input). Used for
* the whole program (stop = null) and for the interiors of `${...}` and JSX
* `{...}`, both of which may contain arbitrary code including more JSX.
*/
function scanCode(sc, stop) {
let depth = 0;
while (!sc.eof) {
const ch = sc.peek();
if (stop && depth === 0 && ch === stop) return;
if (ch === "{" || ch === "(" || ch === "[") { depth++; sc.frames.push({ ch, ternaries: 0 }); }
if (ch === "}" || ch === ")" || ch === "]") { depth--; if (sc.frames.length > 1) sc.frames.pop(); }
// `?` opens a conditional whose `:` is not a type annotation. `?.` and `??`
// are their own operators, and a trailing `?` marks an optional parameter.
if (ch === "?" && sc.peek(1) !== "." && sc.peek(1) !== "?" && !/\s*:/.test(sc.src.slice(sc.i + 1, sc.i + 3))) {
sc.frame.ternaries++;
}
// --- things copied through verbatim ---
if (ch === "/" && sc.peek(1) === "/") { copyLineComment(sc); continue; }
if (ch === "/" && sc.peek(1) === "*") { copyBlockComment(sc); continue; }
if (ch === '"' || ch === "'") { copyString(sc, ch); continue; }
if (ch === "`") { copyTemplate(sc); continue; }
if (ch === "/" && regexAllowed(sc)) { copyRegex(sc); continue; }
// --- JSX ---
if (ch === "<" && jsxAllowed(sc)) {
const from = sc.i;
const call = parseElement(sc);
// A multi-line element becomes a single-line h() call, which would shift
// every line after it. The preview reports runtime errors by line number
// and the user reads those against the original in the Code tab, so the
// difference is padded back.
sc.emit(call + missingNewlines(sc.src.slice(from, sc.i), call));
continue;
}
// --- identifiers and keywords (where TS and module syntax live) ---
if (ID_START.test(ch)) {
const word = readWord(sc);
if (handleWord(sc, word)) continue;
sc.emit(word);
sc.prevWord = word;
continue;
}
// --- TypeScript punctuation ---
if (ch === ":" && annotationAhead(sc)) { skipTypeAnnotation(sc); continue; }
if (ch === "!" && nonNullAssertion(sc)) { sc.i++; continue; }
if (!/\s/.test(ch)) sc.prevWord = "";
sc.emit(ch);
sc.i++;
}
if (stop) sc.fail(`unterminated block, expected '${stop}'`);
}
// ── verbatim copiers ────────────────────────────────────────────────────────
function copyLineComment(sc) {
const end = sc.src.indexOf("\n", sc.i);
const stop = end === -1 ? sc.src.length : end;
sc.out.push(sc.src.slice(sc.i, stop));
sc.i = stop;
}
function copyBlockComment(sc) {
const end = sc.src.indexOf("*/", sc.i + 2);
if (end === -1) sc.fail("unterminated block comment");
sc.out.push(sc.src.slice(sc.i, end + 2));
sc.i = end + 2;
}
function copyString(sc, quote) {
const start = sc.i;
sc.i++;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "\\") { sc.i += 2; continue; }
if (ch === quote) { sc.i++; sc.emit(sc.src.slice(start, sc.i)); return; }
if (ch === "\n") break;
sc.i++;
}
sc.fail("unterminated string");
}
function copyTemplate(sc) {
sc.emit("`");
sc.i++;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "\\") { sc.out.push(sc.src.slice(sc.i, sc.i + 2)); sc.i += 2; continue; }
if (ch === "`") { sc.emit("`"); sc.i++; return; }
// `${...}` can hold anything, JSX included - hand it back to the scanner.
if (ch === "$" && sc.peek(1) === "{") {
sc.emit("${");
sc.i += 2;
const saved = sc.prevSig;
sc.prevSig = "";
scanCode(sc, "}");
sc.prevSig = saved;
sc.emit("}");
sc.i++;
continue;
}
sc.out.push(ch);
sc.i++;
}
sc.fail("unterminated template literal");
}
function copyRegex(sc) {
const start = sc.i;
sc.i++;
let inClass = false;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "\\") { sc.i += 2; continue; }
if (ch === "[") inClass = true;
else if (ch === "]") inClass = false;
else if (ch === "/" && !inClass) {
sc.i++;
while (isIdentifier(sc.peek())) sc.i++; // flags
sc.emit(sc.src.slice(start, sc.i));
return;
} else if (ch === "\n") break;
sc.i++;
}
sc.fail("unterminated regular expression");
}
/** A `/` starts a regex only where a value may start. */
function regexAllowed(sc) {
if (sc.peek(1) === "=") return false;
if (EXPR_KEYWORDS.has(sc.prevWord)) return true;
if (sc.prevWord) return false;
return sc.prevSig === "" || EXPR_START.has(sc.prevSig);
}
/** A `<` opens JSX only where a value may start, and only before a tag name. */
function jsxAllowed(sc) {
const next = sc.peek(1);
if (next !== ">" && !ID_START.test(next ?? "")) return false;
if (EXPR_KEYWORDS.has(sc.prevWord)) return true;
if (sc.prevWord) return false; // identifier before `<` means comparison or generics
return EXPR_START.has(sc.prevSig);
}
// ── identifiers, modules, TypeScript declarations ───────────────────────────
function readWord(sc) {
const start = sc.i;
while (isIdentifier(sc.peek())) sc.i++;
return sc.src.slice(start, sc.i);
}
function skipSpace(sc) {
while (!sc.eof && /\s/.test(sc.peek())) sc.i++;
}
/** True when the word was consumed here and needs no further emitting. */
function handleWord(sc, word) {
const atStatement = sc.prevSig === "" || sc.prevSig === ";" || sc.prevSig === "}";
// Note capitalized declarations as they go past: with no default export and
// nothing named App, the caller mounts the last one declared.
if (word === "function" || word === "class" || word === "const" || word === "let" || word === "var") {
const named = sc.src.slice(sc.i).match(/^\s*\*?\s*([A-Z][\w$]*)/);
if (named && !sc.components.includes(named[1])) sc.components.push(named[1]);
}
// Nothing can be imported into the sandbox - there is no module loader and
// no network. Hooks arrive as globals instead, so `import {useState} from
// "react"` is dropped rather than rewritten.
if (word === "import" && atStatement) {
if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta
const from = sc.i;
skipStatement(sc);
recordImport(sc, sc.src.slice(from, sc.i));
return true;
}
if (word === "export" && atStatement) {
skipSpace(sc);
if (sc.src.startsWith("default", sc.i) && !isIdentifier(sc.peek(7))) {
sc.i += 7;
skipSpace(sc);
// `export default App;` names a component; `export default function App()`
// declares one. Either way the mount target is what follows.
const rest = sc.src.slice(sc.i);
const named = rest.match(/^(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/);
if (named) sc.defaultExport = named[1];
if (/^[A-Za-z_$][\w$]*\s*;?\s*$/.test(rest)) { sc.i = sc.src.length; return true; }
}
sc.prevWord = "";
return true; // `export ` itself is dropped either way
}
// `interface X {...}` / `type X = ...` are types entire - drop the statement.
if ((word === "interface" || word === "type") && atStatement && typeDeclarationAhead(sc)) {
if (word === "interface") skipBalancedBraces(sc);
else skipStatement(sc);
return true;
}
// `x as Foo` / `x satisfies Foo` - drop the cast, keep the value.
if ((word === "as" || word === "satisfies") && sc.prevSig && sc.prevSig !== "=") {
skipSpace(sc);
if (sc.peek() === "c" && sc.src.startsWith("const", sc.i)) sc.i += 5;
else skipTypeExpression(sc);
sc.prevWord = "";
return true;
}
// `useState<number>(0)` - a generic argument list, not a comparison.
const save = sc.i;
skipSpace(sc);
if (sc.peek() === "<") {
const end = matchTypeArgs(sc, sc.i);
if (end !== -1) {
sc.emit(word);
sc.prevWord = word;
sc.i = end;
return true;
}
}
sc.i = save;
return false;
}
/**
* Note the bindings an import would have introduced, so the caller can say
* where a missing name was supposed to come from.
*
* Without this, dropping `import { useInView } from 'react-infinite-scroll'`
* turns into "useInView is not defined" at the first *use* a line nowhere
* near the import, describing a symptom rather than the cause. Parsing is
* deliberately forgiving: models write malformed imports (a missing brace was
* what prompted this), and a half-read name is still worth naming.
*/
function recordImport(sc, statement) {
const quoted = statement.match(/['"]([^'"]+)['"]\s*;?\s*$/);
const module = quoted ? quoted[1] : "";
const clause = statement
.replace(/^\s*import\s*/, "")
.replace(/['"][^'"]*['"]\s*;?\s*$/, "")
.replace(/\bfrom\b/, "");
const names = clause
.replace(/[{}]/g, " ")
.split(",")
.map((part) => part.trim().replace(/^\*\s*as\s+/, "").split(/\s+as\s+/).pop().trim())
.filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
if (module || names.length) sc.imports.push({ names, module });
}
function typeDeclarationAhead(sc) {
return /^\s+[A-Za-z_$][\w$]*\s*[<={]/.test(sc.src.slice(sc.i));
}
/**
* Skip to the end of the current statement (semicolon or line end). Nothing is
* emitted, so the scanner's idea of the last significant character has to be
* restored - otherwise a dropped `import ... from "react";` leaves a quote
* behind as `prevSig` and the next statement no longer looks like one.
*/
function skipStatement(sc) {
const sig = sc.prevSig;
const word = sc.prevWord;
while (!sc.eof) {
const ch = sc.peek();
if (ch === ";") { sc.i++; break; }
if (ch === "\n") break;
if (ch === '"' || ch === "'") { const o = sc.out.length; copyString(sc, ch); sc.out.length = o; continue; }
sc.i++;
}
sc.prevSig = sig;
sc.prevWord = word;
}
function skipBalancedBraces(sc) {
const from = sc.i;
const sig = sc.prevSig;
const word = sc.prevWord;
while (!sc.eof && sc.peek() !== "{") sc.i++;
let depth = 0;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
sc.i++;
if (depth === 0) {
// A multi-line `interface {...}` would otherwise shift the code below it.
sc.out.push(missingNewlines(sc.src.slice(from, sc.i), ""));
sc.prevSig = sig;
sc.prevWord = word;
return;
}
continue;
}
sc.i++;
}
sc.fail("unterminated type declaration");
}
/**
* Find the `>` closing a generic argument list starting at `from`, or -1 when
* the contents don't look like types at all (then it was a comparison).
*/
function matchTypeArgs(sc, from) {
let depth = 0;
for (let j = from; j < sc.src.length; j++) {
const ch = sc.src[j];
if (ch === "<") depth++;
else if (ch === ">") {
depth--;
if (depth === 0) {
const inner = sc.src.slice(from + 1, j);
if (!TYPE_ARG_CHARS.test(inner)) return -1;
// Only a call or a value position may follow a real generic list.
const after = sc.src.slice(j + 1).match(/^\s*(.)/);
return after && "(;,)]}=".includes(after[1]) ? j + 1 : -1;
}
} else if (ch === "\n" || ch === "{" || ch === ";") return -1;
}
return -1;
}
// ── TypeScript annotations ──────────────────────────────────────────────────
/**
* True when this `:` introduces a type annotation rather than an object-literal
* key, a ternary branch, or a label. Only the positions a small component
* actually uses are recognised; everything else is left alone deliberately.
*/
function annotationAhead(sc) {
// An unresolved `?` claims this `:` for its conditional first.
if (sc.frame.ternaries > 0) { sc.frame.ternaries--; return false; }
const before = sc.src.slice(0, sc.i);
// `const x: T` and a return type `): T` read the same anywhere.
if (/(?:\b(?:const|let|var)\s+[A-Za-z_$][\w$]*|\))\s*$/.test(before)) return true;
// The rest are parameter annotations, and only count inside a parameter
// list. The same shapes inside braces are object literals, where the value
// after `:` must survive.
if (sc.context !== "(") return false;
// `(a: T`, `, b: T`, `(...rest: T`
if (/[(,]\s*(?:\.\.\.)?[A-Za-z_$][\w$]*\??\s*$/.test(before)) return true;
// `({ a, b }: Props`, `([x, y]: T` - a destructured parameter
return /[}\]]\s*\??\s*$/.test(before);
}
function skipTypeAnnotation(sc) {
sc.i++; // the ':'
skipTypeExpression(sc);
sc.prevWord = "";
}
/** Consume a type expression, stopping where the surrounding value resumes. */
function skipTypeExpression(sc) {
let depth = 0;
while (!sc.eof) {
const ch = sc.peek();
if ("({[<".includes(ch)) depth++;
else if (")}]>".includes(ch)) {
if (depth === 0) return; // the enclosing paren/brace, not ours
depth--;
} else if (depth === 0) {
if (ch === "," || ch === ";" || ch === "\n") return;
if (ch === "=" && sc.peek(1) !== ">") return;
if (ch === "{") return;
if (ch === "=" && sc.peek(1) === ">") return;
}
sc.i++;
}
}
function nonNullAssertion(sc) {
const next = sc.peek(1);
if (next === "=" ) return false; // `!=`
return /[\w$)\]]/.test(sc.src[sc.i - 1] ?? ""); // `foo!.bar`, `arr[0]!`
}
// ── JSX ─────────────────────────────────────────────────────────────────────
/** Parse one JSX element (cursor on `<`) and return the equivalent h() call. */
function parseElement(sc) {
sc.i++; // '<'
if (sc.peek() === ">") { // fragment
sc.i++;
const kids = parseChildren(sc, "");
return `h(Fragment,null${kids})`;
}
const name = readTagName(sc);
if (!name) sc.fail("expected a JSX tag name after '<'");
const props = parseAttributes(sc);
const tag = /^[a-z][\w-]*$/.test(name) ? JSON.stringify(name) : name;
if (sc.peek() === "/") { // self-closing
sc.i++;
if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name} />`);
sc.i++;
return `h(${tag},${props})`;
}
if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name}>`);
sc.i++;
const kids = parseChildren(sc, name);
return `h(${tag},${props}${kids})`;
}
function readTagName(sc) {
if (!ID_START.test(sc.peek() ?? "")) return "";
const start = sc.i;
while (!sc.eof && /[\w$.-]/.test(sc.peek())) sc.i++;
return sc.src.slice(start, sc.i);
}
function parseAttributes(sc) {
const parts = [];
for (;;) {
skipSpace(sc);
const ch = sc.peek();
if (ch === undefined) sc.fail("unterminated JSX tag");
if (ch === ">" || ch === "/") break;
if (ch === "{") { // {...spread}
sc.i++;
skipSpace(sc);
if (!sc.src.startsWith("...", sc.i)) sc.fail("expected '...' in a JSX attribute spread");
sc.i += 3;
parts.push(`...${captureExpression(sc, "}")}`);
continue;
}
const name = readAttrName(sc);
if (!name) sc.fail("expected a JSX attribute name");
const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
skipSpace(sc);
if (sc.peek() !== "=") { parts.push(`${key}:true`); continue; }
sc.i++;
skipSpace(sc);
const valueCh = sc.peek();
if (valueCh === '"' || valueCh === "'") {
parts.push(`${key}:${JSON.stringify(readQuoted(sc, valueCh))}`);
} else if (valueCh === "{") {
sc.i++;
parts.push(`${key}:${captureExpression(sc, "}")}`);
} else if (valueCh === "<") {
parts.push(`${key}:${parseElement(sc)}`);
} else {
sc.fail(`unsupported value for JSX attribute '${name}'`);
}
}
return parts.length ? `{${parts.join(",")}}` : "null";
}
function readAttrName(sc) {
const start = sc.i;
while (!sc.eof && /[\w$:.-]/.test(sc.peek())) sc.i++;
return sc.src.slice(start, sc.i);
}
function readQuoted(sc, quote) {
sc.i++;
const start = sc.i;
while (!sc.eof && sc.peek() !== quote) sc.i++;
if (sc.eof) sc.fail("unterminated JSX attribute value");
const text = sc.src.slice(start, sc.i);
sc.i++;
return text;
}
/**
* Capture a braced expression as source, running it through the scanner so
* nested JSX inside it is transformed too. Leaves the cursor past `close`.
*/
function captureExpression(sc, close) {
const inner = new Scanner(sc.src);
inner.i = sc.i;
scanCode(inner, close);
if (inner.eof) sc.fail("unterminated JSX expression");
sc.i = inner.i + 1;
const code = inner.out.join("").trim();
return code === "" ? "undefined" : `(${code})`;
}
function parseChildren(sc, tagName) {
const kids = [];
for (;;) {
if (sc.eof) sc.fail(tagName ? `unclosed <${tagName}>` : "unclosed fragment");
// closing tag?
if (sc.peek() === "<" && sc.peek(1) === "/") {
sc.i += 2;
const closing = readTagName(sc);
skipSpace(sc);
if (sc.peek() !== ">") sc.fail(`expected '>' to close </${closing}>`);
sc.i++;
if (closing !== tagName) {
sc.fail(`</${closing}> does not match <${tagName || ""}>`);
}
break;
}
if (sc.peek() === "<") { kids.push(parseElement(sc)); continue; }
if (sc.peek() === "{") {
sc.i++;
skipSpace(sc);
// `{/* note */}` is a JSX comment - it renders nothing.
if (sc.peek() === "/" && sc.peek(1) === "*") {
const end = sc.src.indexOf("*/", sc.i);
if (end === -1) sc.fail("unterminated JSX comment");
sc.i = end + 2;
skipSpace(sc);
if (sc.peek() !== "}") sc.fail("expected '}' after a JSX comment");
sc.i++;
continue;
}
const expr = captureExpression(sc, "}");
if (expr !== "undefined") kids.push(expr);
continue;
}
const text = readText(sc);
if (text !== null) kids.push(JSON.stringify(text));
}
return kids.length ? `,${kids.join(",")}` : "";
}
/**
* JSX text, with JSX's whitespace rule: runs of whitespace containing a newline
* are layout, not content, so they collapse away at the edges and become a
* single space in the middle.
*/
function readText(sc) {
const start = sc.i;
while (!sc.eof && sc.peek() !== "<" && sc.peek() !== "{") sc.i++;
const raw = sc.src.slice(start, sc.i);
if (raw === "") return null;
const lines = raw.split("\n");
if (lines.length === 1) return raw;
const kept = lines
.map((line, idx) => (idx === 0 ? line.replace(/\s+$/, "") : line.trim()))
.filter((line) => line !== "");
const text = kept.join(" ");
return text === "" ? null : text;
}
@@ -0,0 +1,251 @@
/*
* node --test src/preview/jsx-transform.test.js
*
* The transform runs on model-authored text, so these cases are the shapes a
* model actually emits, plus the ones where a naive scanner silently produces
* code that runs and does the wrong thing (generics read as comparisons, `<` in
* a string read as a tag). Silent-wrong is the failure mode worth testing;
* anything that throws is already visible to the user.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { transform, TransformError } from "./jsx-transform.js";
// Whitespace is normalised for comparison: the transform drops the space a
// stripped annotation sat in (`const x: T = 1` -> `const x= 1`), which is
// invisible to everyone because the Code tab shows the original source, not
// this output.
const js = (src) => transform(src).code.replace(/\s+/g, " ").trim();
// ── elements ────────────────────────────────────────────────────────────────
test("element with no attributes or children", () => {
assert.equal(js("const a = <div />;"), 'const a = h("div",null);');
});
test("lowercase tags become strings, capitalized stay identifiers", () => {
assert.equal(js("<div />"), 'h("div",null)');
assert.equal(js("<App />"), "h(App,null)");
assert.equal(js("<Foo.Bar />"), "h(Foo.Bar,null)");
});
test("string, expression, boolean and hyphenated attributes", () => {
assert.equal(js('<a href="/x" />'), 'h("a",{href:"/x"})');
assert.equal(js("<a n={1 + 2} />"), 'h("a",{n:(1 + 2)})');
assert.equal(js("<input disabled />"), 'h("input",{disabled:true})');
assert.equal(js('<a data-id="7" />'), 'h("a",{"data-id":"7"})');
});
test("attribute spread", () => {
assert.equal(js("<div {...props} id=\"x\" />"), 'h("div",{...(props),id:"x"})');
});
test("children: text, expressions and nesting", () => {
assert.equal(js("<p>hi</p>"), 'h("p",null,"hi")');
assert.equal(js("<p>{name}</p>"), 'h("p",null,(name))');
assert.equal(js("<p>a {b} c</p>"), 'h("p",null,"a ",(b)," c")');
assert.equal(js("<ul><li>x</li></ul>"), 'h("ul",null,h("li",null,"x"))');
});
test("fragments", () => {
assert.equal(js("<><a /><b /></>"), 'h(Fragment,null,h("a",null),h("b",null))');
});
test("JSX nested inside an expression child", () => {
assert.equal(
js("<ul>{items.map((i) => <li key={i}>{i}</li>)}</ul>"),
'h("ul",null,(items.map((i) => h("li",{key:(i)},(i)))))',
);
});
test("JSX comments render nothing", () => {
assert.equal(js("<div>{/* note */}<a /></div>"), 'h("div",null,h("a",null))');
});
test("whitespace-only lines between elements collapse away", () => {
assert.equal(
js("<div>\n <a />\n <b />\n</div>"),
'h("div",null,h("a",null),h("b",null))',
);
});
test("text spanning lines keeps single spaces", () => {
assert.equal(js("<p>\n one\n two\n</p>"), 'h("p",null,"one two")');
});
// ── things that must NOT be treated as JSX ──────────────────────────────────
test("comparisons and arrow bodies are left alone", () => {
assert.equal(js("const t = a < b;"), "const t = a < b;");
assert.equal(js("if (x < 3 && y > 1) {}"), "if (x < 3 && y > 1) {}");
assert.equal(js("const f = (a, b) => a < b;"), "const f = (a, b) => a < b;");
});
test("angle brackets inside strings, templates and regexes survive", () => {
assert.equal(js('const s = "<div>not jsx</div>";'), 'const s = "<div>not jsx</div>";');
assert.equal(js("const s = `a <b> c`;"), "const s = `a <b> c`;");
assert.equal(js("const r = /<[a-z]+>/g;"), "const r = /<[a-z]+>/g;");
});
test("template interpolation can still contain JSX", () => {
assert.equal(js("const s = `${<a />}`;"), "const s = `${h(\"a\",null)}`;");
});
test("division is not mistaken for a regex", () => {
assert.equal(js("const r = (a + b) / 2 / c;"), "const r = (a + b) / 2 / c;");
// A `/` right after a call's closing paren: `)` ends a value, so this is
// division. Treating it as a regex swallowed the rest of the line.
assert.equal(js("const y = Math.sin((i + s) / 6) * 70;"), "const y = Math.sin((i + s) / 6) * 70;");
assert.equal(js("const m = xs[0] / total;"), "const m = xs[0] / total;");
});
test("indexing and calls before < are comparisons, not JSX", () => {
assert.equal(js("if (xs[0] < 3) {}"), "if (xs[0] < 3) {}");
assert.equal(js("while (f(i) < n) { i++; }"), "while (f(i) < n) { i++; }");
});
// ── TypeScript ──────────────────────────────────────────────────────────────
test("parameter and variable annotations are stripped", () => {
assert.equal(js("function f(a: string, b: number) {}"), "function f(a, b) {}");
assert.equal(js("const x: number = 5;"), "const x= 5;");
assert.equal(js("const f = (n: number): string => String(n);"), "const f = (n)=> String(n);");
});
test("interface and type declarations are dropped whole", () => {
assert.equal(js("interface Props { a: string; b?: number }\nconst x = 1;"), "const x = 1;");
assert.equal(js("type Id = string | number;\nconst x = 1;"), "const x = 1;");
});
test("generic call arguments are stripped, not read as comparisons", () => {
// The silent-wrong case: `useState<number>(0)` is valid JS meaning
// `(useState < number) > (0)`, so getting this wrong yields a boolean.
assert.equal(js("const [n, setN] = useState<number>(0);"), "const [n, setN] = useState(0);");
assert.equal(js("useRef<HTMLCanvasElement | null>(null);"), "useRef(null);");
});
test("as-casts and non-null assertions are stripped", () => {
assert.equal(js("const el = x as HTMLElement;"), "const el = x ;");
assert.equal(js("const v = raw as const;"), "const v = raw ;");
assert.equal(js("ref.current!.focus();"), "ref.current.focus();");
});
test("optional parameters keep their default values", () => {
assert.equal(js("function f(a: number = 3) { return a; }"), "function f(a= 3) { return a; }");
});
test("object literals and ternaries are not mistaken for annotations", () => {
assert.equal(js("const o = { a: 1, b: 'two' };"), "const o = { a: 1, b: 'two' };");
assert.equal(js("const v = c ? 1 : 2;"), "const v = c ? 1 : 2;");
assert.equal(js("el.style = { color: 'red' };"), "el.style = { color: 'red' };");
// Object literals passed as arguments sit inside a parameter list, where
// parameter annotations also live.
assert.equal(js("f({ a: 1, b: 2 });"), "f({ a: 1, b: 2 });");
assert.equal(js("ctx.fillRect(x, y, { w: 1 });"), "ctx.fillRect(x, y, { w: 1 });");
});
test("ternaries inside a call keep their else-branch", () => {
// Both of these end in `}` or `)` before the `:`, exactly like a destructured
// parameter annotation and a return type - only the pending `?` tells them apart.
assert.equal(js("f(cond ? { a: 1 } : { b: 2 });"), "f(cond ? { a: 1 } : { b: 2 });");
assert.equal(js("f(cond ? g() : h2());"), "f(cond ? g() : h2());");
assert.equal(js("const s = ok ? 'y' : 'n';"), "const s = ok ? 'y' : 'n';");
});
test("destructured parameter annotations are stripped", () => {
assert.equal(js("function C({ start }: Props) {}"), "function C({ start }) {}");
assert.equal(js("const f = ({ a, b }: P) => a + b;"), "const f = ({ a, b }) => a + b;");
assert.equal(js("function g([x, y]: Pair) {}"), "function g([x, y]) {}");
});
// ── modules ─────────────────────────────────────────────────────────────────
test("imports are dropped - the sandbox has no module loader", () => {
assert.equal(js('import React, { useState } from "react";\nconst x = 1;'), "const x = 1;");
assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;");
});
test("dropped imports report the bindings they would have provided", () => {
// So a missing name can say where it was supposed to come from, instead of
// surfacing as "useInView is not defined" at its first use.
const { imports } = transform(
'import React, { useState, useEffect } from "react";\n' +
'import { useInView } from "react-infinite-scroll";\n' +
'import * as d3 from "d3";\nconst x = 1;',
);
assert.deepEqual(imports, [
{ names: ["React", "useState", "useEffect"], module: "react" },
{ names: ["useInView"], module: "react-infinite-scroll" },
{ names: ["d3"], module: "d3" },
]);
});
test("a malformed import still yields its name and module", () => {
// Straight from a transcript: a missing closing brace. The import is dropped
// either way, so the binding it meant to create is what matters.
const { imports } = transform("import { useInView from 'react-infinite-scroll';\nconst x = 1;");
assert.deepEqual(imports, [{ names: ["useInView"], module: "react-infinite-scroll" }]);
});
test("export default names the mount target", () => {
assert.equal(transform("export default function App() {}").defaultExport, "App");
assert.equal(transform("function A() {}\nexport default A;").defaultExport, "A");
assert.equal(js("export default function App() {}"), "function App() {}");
});
test("named exports are unwrapped", () => {
assert.equal(js("export const x = 1;"), "const x = 1;");
assert.equal(js("export function Chart() {}"), "function Chart() {}");
});
// ── failure is loud ─────────────────────────────────────────────────────────
test("mismatched closing tag throws with a line number", () => {
assert.throws(() => transform("<div>\n<span>x</div>"), (e) =>
e instanceof TransformError && /does not match/.test(e.message) && /line 2/.test(e.message));
});
test("unterminated element throws", () => {
assert.throws(() => transform("const a = <div>"), TransformError);
});
// ── a whole component, end to end ───────────────────────────────────────────
test("a realistic component transforms to runnable JS", () => {
const src = `
import { useState } from "react";
interface Props { start: number }
export default function Counter({ start }: Props) {
const [n, setN] = useState<number>(start);
return (
<div className="box">
<button onClick={() => setN(n + 1)}>+1</button>
<span>{n} clicks</span>
{n > 3 && <em>many!</em>}
</div>
);
}`;
const out = transform(src);
assert.equal(out.defaultExport, "Counter");
assert.match(out.code, /function Counter\(\{ start \}\)/);
assert.match(out.code, /useState\(start\)/);
assert.match(out.code, /h\("button",\{onClick:\(\(\) => setN\(n \+ 1\)\)\},"\+1"\)/);
assert.doesNotMatch(out.code, /interface|import|: Props|<number>/);
// The real proof: it parses as JS.
assert.doesNotThrow(() => new Function(out.code));
});
test("output of every element case parses as JS", () => {
for (const src of [
"<div />",
"<a href=\"/x\">link</a>",
"<><p>a</p><p>b</p></>",
"const v = <ul>{xs.map((x) => <li key={x}>{x}</li>)}</ul>;",
"const v = <Foo {...p} n={1} on={() => f(`${x}`)} />;",
]) {
const { code } = transform(src);
assert.doesNotThrow(() => new Function("h", "Fragment", "xs", "p", "x", "f", code), src);
}
});
+99
View File
@@ -0,0 +1,99 @@
/*
* languages.js what the render window can preview, one entry per language.
*
* Each entry turns a fence's contents into the <body> of the sandboxed frame:
*
* toBody(value) -> { html, userOffset }
*
* `userOffset` is how many lines of that body come before the user's own code.
* The frame reports runtime errors by line number and those numbers are
* document-relative, so without this an error in a JSX component would be
* reported at some line deep inside the inlined Preact build. The caller adds
* the lines of document shell above the body and hands the total to the
* bootstrap, which subtracts it before reporting.
*
* A `toBody` may throw: JSX that doesn't parse has no preview to show. The
* caller catches and shows the message in place of the frame.
*
* The backend keeps a matching registry (PREVIEW_LANGS in synapse/tools.py)
* that says how each language is *validated* rather than rendered. Neither
* depends on the other at runtime; tests/test_tools.py asserts the key sets
* stay equal.
*/
import { transform } from "./jsx-transform.js";
import { PREACT_RUNTIME } from "./runtime.js";
const countNewlines = (text) => (text.match(/\n/g) || []).length;
/** Markup languages: the fence is already a document body. */
const markup = (value) => ({ html: value, userOffset: 0 });
/**
* Pick what to mount. An explicit default export wins, then a component named
* App, then the last capitalized declaration - models tend to define helpers
* first and the thing they were asked for last.
*/
function mountTarget({ defaultExport, components }) {
if (defaultExport) return defaultExport;
if (components.includes("App")) return "App";
if (components.length) return components[components.length - 1];
throw new Error(
"No component found to render. Name one `App`, or `export default` it.",
);
}
/**
* One line of stubs for every binding an import would have provided.
*
* Imports are dropped there is no module loader in the sandbox so a name
* that came from a package is simply missing, and the first use of it reports
* "useInView is not defined" at a line far from the import that explains it.
* Each stub throws with the module name instead, and `||` means anything the
* runtime already provides (useState and friends) keeps its real implementation.
*/
function importStubs(imports) {
const names = new Map();
for (const { names: bound, module } of imports || []) {
for (const name of bound) if (!names.has(name)) names.set(name, module);
}
if (!names.size) return "";
const lines = [...names].map(([name, module]) => {
const why = JSON.stringify(
`${name} came from ${module ? `"${module}"` : "an import"}, which the preview ` +
"cannot load — it has no module loader and no network. Inline what you need, " +
"or use the built-in hooks, which are already in scope.",
);
return `window[${JSON.stringify(name)}] = window[${JSON.stringify(name)}] ` +
`|| function () { throw new Error(${why}); };`;
});
return `<script>${lines.join("")}</script>\n`;
}
function jsxBody(value) {
const result = transform(value);
const target = mountTarget(result);
const head =
'<div id="root"></div>\n' +
`<script>${PREACT_RUNTIME}</script>\n` +
importStubs(result.imports) +
"<script>\n";
return {
html:
head +
result.code +
`\n;render(h(${target}, null), document.getElementById("root"));\n` +
"</script>",
userOffset: countNewlines(head),
};
}
export const PREVIEW_LANGS = {
html: { toBody: markup },
svg: { toBody: markup },
jsx: { toBody: jsxBody },
tsx: { toBody: jsxBody },
};
export const RENDERABLE_LANGS = new Set(Object.keys(PREVIEW_LANGS));
+42
View File
@@ -0,0 +1,42 @@
/*
* runtime.js the JS a JSX preview needs in scope, as a string.
*
* It has to be a string because the preview frame is on an opaque origin: it
* cannot fetch this app's assets, and it cannot read a blob: URL the parent
* created either. Anything a preview needs must be handed to it as bytes,
* which is what makes payload size the real currency here.
*
* Preact rather than React for exactly that reason - ~15 KB of UMD against
* ~140 KB, per preview. The alternative of re-rendering the whole tree on every
* state change and skipping the vdom entirely was rejected on behaviour, not
* size: it would wipe <canvas> contents on each update, and canvas is what most
* of these previews draw into.
*/
// Imported by file path, not by package specifier: preact's exports map puts
// the UMD builds behind a "umd" condition that a bundler targeting ESM never
// asks for, so `preact/dist/preact.umd.js` does not resolve. UMD is what we
// want here precisely because it has no module system - it assigns globals when
// loaded as a plain <script>, which is all the sandbox can offer it.
import preactSrc from "../../node_modules/preact/dist/preact.umd.js?raw";
import hooksSrc from "../../node_modules/preact/hooks/dist/hooks.umd.js?raw";
// Both UMD builds fall back to a global (`preact`, `preactHooks`) when there is
// no module system, which is the case inside an inline <script>. This lifts
// what transformed JSX expects - h/Fragment/render and the hooks - to bare
// globals, and mirrors them onto `React` so a model that writes React.useState
// or forgets to remove its import still works.
const GLUE = `
;(function (p, hooks) {
window.h = p.h;
window.Fragment = p.Fragment;
window.render = p.render;
window.createElement = p.h;
for (var k in hooks) window[k] = hooks[k];
window.React = Object.assign({}, p, hooks, { createElement: p.h, Fragment: p.Fragment });
window.ReactDOM = { render: function (v, el) { p.render(v, el); }, createRoot: function (el) {
return { render: function (v) { p.render(v, el); } };
} };
})(preact, preactHooks);
`;
export const PREACT_RUNTIME = `${preactSrc}\n${hooksSrc}\n${GLUE}`;
+9 -4
View File
@@ -1,7 +1,7 @@
# 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 backend, opening a native app window
# 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
@@ -40,15 +40,19 @@ Write-Host "Starting NexusOS..." -ForegroundColor Cyan
# 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 (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 (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")
}
# Open the UI in a native window (pywebview / WebView2) - no browser, no Edge
# profile cold-start - IMMEDIATELY, in parallel with the backend still
# profile cold-start - IMMEDIATELY, in parallel with memory/backend still
# coming up. bin\nexus_window.py opens on its own loading page and polls
# :8000 itself, updating that page's status line live as it
# :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
@@ -63,9 +67,10 @@ Write-Host "NexusOS window opened; services are starting at http://localhost:800
# shortcut runs hidden, where a prompt no one can answer would hang forever.
if ($app) { $app.WaitForExit() }
elseif ($backend) { $backend.WaitForExit() }
elseif ($memory) { $memory.WaitForExit() }
# Shut the services down.
Write-Host "Stopping NexusOS..." -ForegroundColor Cyan
foreach ($p in @($backend)) {
foreach ($p in @($backend, $memory)) {
if ($p -and -not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
}
+7 -6
View File
@@ -1,9 +1,4 @@
#!/bin/bash
# Loopback by default: the backend REST API is unauthenticated, so
# binding 0.0.0.0 hands the whole admin+data plane to any host on the LAN.
# Same knob management/ncp.py uses -- set NEXUS_BIND_HOST=0.0.0.0 to opt in.
BIND_HOST="${NEXUS_BIND_HOST:-127.0.0.1}"
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
PYTHON="$PROJECT_ROOT/Promethean/bin/python3"
FRONTEND_DIR="$PROJECT_ROOT/interface/web"
@@ -11,6 +6,7 @@ LOG_DIR="$PROJECT_ROOT/runtime"
mkdir -p "$LOG_DIR"
MEMORY_LOG="$LOG_DIR/memory.log"
BACKEND_LOG="$LOG_DIR/backend.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
@@ -33,10 +29,15 @@ _check() {
fi
}
# Launch memory + backend together
MEMORY_PID=$(_start "NEXUS MEMORY SERVICE" "$MEMORY_LOG" "$PROJECT_ROOT" \
"$PYTHON" -m uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload)
BACKEND_PID=$(_start "NEXUS BACKEND SERVICE" "$BACKEND_LOG" "$PROJECT_ROOT" \
"$PYTHON" -m uvicorn synapse.main:sio_app --host "$BIND_HOST" --port 8000 --reload)
"$PYTHON" -m uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload)
sleep 3
_check "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG"
_check "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG"
# Frontend last
-5
View File
@@ -1,5 +0,0 @@
"""Desktop-only NexusOS management helpers (Tk control panel, XFCE panel).
The portable CLI lives in the `nexusos_cli` package - that is what the
wheel ships and what `ncp`/`nexus`/`nexusos` dispatch to.
"""
-9
View File
@@ -1,9 +0,0 @@
[Desktop Entry]
Type=Application
Name=Network
Comment=Manage your network connections
Icon=nm-device-wireless
TryExec=nm-applet
Exec=nm-applet
Hidden=true
X-XFCE-Autostart-Override=true
-6
View File
@@ -1,6 +0,0 @@
[Desktop Entry]
Type=Application
Name=Plank (Nexus primary-follow)
Exec=/home/jon/.local/bin/plank-primary-watch.sh
Hidden=false
X-XFCE-Autostart-Override=true
+89 -15
View File
@@ -1,3 +1,4 @@
# /home/jon/nexus-core/management/controlpanel.py
import time
import tkinter as tk
@@ -19,9 +20,7 @@ try:
except Exception:
VERSION = "0.0.0"
# Loopback by default -- the REST APIs are unauthenticated, so 0.0.0.0 exposed
# the whole admin+data plane to the LAN. Same knob management/ncp.py uses.
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
# Force 0.0.0.0 to ensure the Windows Host bridge works for Project Nexus
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
def _find_npm():
"""Prefer nvm's newest node. When the panel is launched from the desktop
@@ -52,15 +51,28 @@ BACKEND_CMD = [
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
"-m", "uvicorn",
"synapse.main:sio_app",
"--host", BIND_HOST,
"--host", "0.0.0.0",
"--port", "8000",
"--reload",
"--reload-dir", str(PROJECT_ROOT / "synapse"),
"--log-level", "info"
]
MEMORY_DIR = PROJECT_ROOT
MEMORY_CMD = [
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
"-m", "uvicorn",
"synapse.memory.service:app",
"--host", "0.0.0.0",
"--port", "8001",
"--reload",
"--reload-dir", str(PROJECT_ROOT / "synapse"),
"--log-level", "info"
]
FRONTEND_PID = PROJECT_ROOT / "runtime" / "pids" / "frontend.pid"
BACKEND_PID = PROJECT_ROOT / "runtime" / "pids" / "backend.pid"
MEMORY_PID = PROJECT_ROOT / "runtime" / "pids" / "memory.pid"
CHAT_LOG = PROJECT_ROOT / "runtime" / "logs" / "chat.log"
class NexusControlPanel:
@@ -97,8 +109,10 @@ class NexusControlPanel:
self.frontend_process = None
self.backend_process = None
self.memory_process = None
self._frontend_starting = False
self._backend_starting = False
self._memory_starting = False
self._closing = False
self._setup_custom_titlebar()
@@ -118,6 +132,9 @@ class NexusControlPanel:
if self._is_running(frontend_pid):
threading.Thread(target=self._monitor_frontend, daemon=True).start()
if self._is_running(MEMORY_PID):
threading.Thread(target=self._monitor_memory, daemon=True).start()
threading.Thread(target=self._monitor_synapses, daemon=True).start()
self.update_master_ui()
@@ -150,11 +167,12 @@ class NexusControlPanel:
def _init_log_tags(self):
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console]:
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console, self.mem_console]:
console.tag_config("SYSTEM", foreground="#ff00ea")
console.tag_config("FRONTEND", foreground="#61dbfb")
console.tag_config("BACKEND", foreground="#ffd43b")
console.tag_config("MINDTRACE", foreground="#cc88ff")
console.tag_config("MEMORY", foreground="#78e08f")
console.tag_config("ERROR", foreground="#ff0000")
console.tag_config("GPU_NVIDIA", foreground="#76b900")
console.tag_config("GPU_AMD", foreground="#ed1c24")
@@ -175,7 +193,7 @@ class NexusControlPanel:
self.root.geometry(f"+{self.root.winfo_x() + (event.x - self.x)}+{self.root.winfo_y() + (event.y - self.y)}")
def _clear_all_logs(self):
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console]:
for console in [self.sys_console, self.front_console, self.back_console, self.mem_console, self.mind_console]:
console.delete("1.0", tk.END)
def _copy_selection(self, widget):
@@ -242,13 +260,19 @@ class NexusControlPanel:
self.btn_back_stop = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_backend)
self.btn_back_stop.pack(side=tk.LEFT, padx=2)
# Memory Service Controls
self.btn_mem_start = tk.Button(controls, text="MEMORY", fg="#78e08f", **START_STYLE, command=self.start_memory)
self.btn_mem_start.pack(side=tk.LEFT, padx=(8, 2))
self.btn_mem_stop = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_memory)
self.btn_mem_stop.pack(side=tk.LEFT, padx=2)
# Terminal Displays
sys_f, self.sys_console = self._create_terminal(self.root, " >> SYSTEM STATUS", height=5, text_color="#ff00ea")
sys_f.pack(fill=tk.X, padx=10, pady=(0, 8))
bottom_f = tk.Frame(self.root, bg="#1e1e1e")
bottom_f.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 8))
for col in range(3):
for col in range(4):
bottom_f.columnconfigure(col, weight=1, uniform="term")
bottom_f.rowconfigure(0, weight=1)
@@ -256,8 +280,10 @@ class NexusControlPanel:
f_f.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
b_f, self.back_console = self._create_terminal(bottom_f, " >> SYNAPSE (UVICORN)", text_color="#ffd43b")
b_f.grid(row=0, column=1, sticky="nsew", padx=4)
mm_f, self.mem_console = self._create_terminal(bottom_f, " >> MEMORY SERVICE", text_color="#78e08f")
mm_f.grid(row=0, column=2, sticky="nsew", padx=4)
m_f, self.mind_console = self._create_terminal(bottom_f, " >> MINDTRACE", text_color="#cc88ff")
m_f.grid(row=0, column=2, sticky="nsew", padx=(4, 0))
m_f.grid(row=0, column=3, sticky="nsew", padx=(4, 0))
def _safe_after(self, delay, func):
"""Schedule a Tk callback without ever raising.
@@ -279,6 +305,7 @@ class NexusControlPanel:
con = self.sys_console
if tag == "FRONTEND": con = self.front_console
elif tag == "BACKEND": con = self.back_console
elif tag == "MEMORY": con = self.mem_console
elif tag == "MINDTRACE": con = self.mind_console
con.insert(tk.END, f"[{tag}] {message}\n", tag)
con.see(tk.END)
@@ -331,14 +358,16 @@ class NexusControlPanel:
def update_master_ui(self):
if self._closing: # window may be destroyed; stop_* still calls this
return
f, b = self._frontend_is_active(), self._backend_is_active()
self.btn_master_start.config(state=tk.DISABLED if (f and b) else tk.NORMAL)
self.btn_master_stop.config(state=tk.NORMAL if (f or b) else tk.DISABLED)
f, b, m = self._frontend_is_active(), self._backend_is_active(), self._memory_is_active()
self.btn_master_start.config(state=tk.DISABLED if (f and b and m) else tk.NORMAL)
self.btn_master_stop.config(state=tk.NORMAL if (f or b or m) else tk.DISABLED)
self.btn_front_start.config(state=tk.DISABLED if f else tk.NORMAL, bg="#0b3d0b" if f else "#2d2d2d")
self.btn_back_start.config(state=tk.DISABLED if b else tk.NORMAL, bg="#3d3d0b" if b else "#2d2d2d")
self.btn_mem_start.config(state=tk.DISABLED if m else tk.NORMAL, bg="#0b2d14" if m else "#2d2d2d")
self.btn_front_stop.config(state=tk.NORMAL if f else tk.DISABLED)
self.btn_front_view.config(state=tk.NORMAL if f else tk.DISABLED)
self.btn_back_stop.config(state=tk.NORMAL if b else tk.DISABLED)
self.btn_mem_stop.config(state=tk.NORMAL if m else tk.DISABLED)
def open_frontend_view(self):
webbrowser.open_new_tab(FRONTEND_URL)
@@ -397,6 +426,10 @@ class NexusControlPanel:
self._frontend_starting = False
self.frontend_process = p
self._write_pid(FRONTEND_PID, p.pid)
elif name == "MEMORY":
self._memory_starting = False
self.memory_process = p
self._write_pid(MEMORY_PID, p.pid)
else:
self._backend_starting = False
self.backend_process = p
@@ -439,6 +472,10 @@ class NexusControlPanel:
self._frontend_starting = False
self.frontend_process = None
FRONTEND_PID.unlink(missing_ok=True)
elif name == "MEMORY":
self._memory_starting = False
self.memory_process = None
MEMORY_PID.unlink(missing_ok=True)
else:
self._backend_starting = False
self.backend_process = None
@@ -539,13 +576,47 @@ class NexusControlPanel:
self._terminate_pid_file(BACKEND_PID)
self.update_master_ui()
def start_memory(self):
if self._memory_is_active():
return
self._memory_starting = True
self.update_master_ui()
self.log("SYSTEM", "Starting MEMORY SERVICE...")
threading.Thread(target=self._worker, args=("MEMORY", MEMORY_CMD, MEMORY_DIR), daemon=True).start()
def stop_memory(self):
if self.memory_process:
self._terminate_process(self.memory_process)
self.memory_process = None
elif self._is_running(MEMORY_PID):
self._terminate_pid_file(MEMORY_PID)
self.update_master_ui()
def _memory_is_active(self):
return self._memory_starting or self.memory_process is not None or self._is_running(MEMORY_PID)
def _monitor_memory(self):
log_path = PROJECT_ROOT / "runtime/memory.log"
try:
with open(log_path, "r") as f:
f.seek(0, os.SEEK_END)
while self._is_running(MEMORY_PID):
line = f.readline()
if line:
self.log("MEMORY", line.rstrip())
else:
time.sleep(0.1)
except Exception as e:
self.log("ERROR", f"MEMORY monitor fault: {e}")
def start_all(self):
self.start_memory()
self.start_frontend()
self.start_backend()
def kill_all(self):
self.log("SYSTEM", "FORCE KILL — terminating all Nexus processes by port...")
ports = {8000: "SYNAPSE", 5173: "INTERFACE"}
ports = {8000: "SYNAPSE", 8001: "MEMORY", 5173: "INTERFACE"}
for port, name in ports.items():
try:
r = subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
@@ -557,18 +628,21 @@ class NexusControlPanel:
subprocess.run(["pkill", "-9", "-f", pat], capture_output=True, timeout=5)
except Exception:
pass
for pid_file in [FRONTEND_PID, BACKEND_PID]:
for pid_file in [FRONTEND_PID, BACKEND_PID, MEMORY_PID]:
pid_file.unlink(missing_ok=True)
self.frontend_process = None
self.backend_process = None
self.memory_process = None
self._frontend_starting = False
self._backend_starting = False
self._memory_starting = False
self._safe_after(0, self.update_master_ui)
self.log("SYSTEM", "Force kill complete.")
def stop_all(self):
self.stop_frontend()
self.stop_backend()
self.stop_memory()
def on_close(self):
if self._closing:
@@ -601,13 +675,13 @@ class NexusControlPanel:
except Exception:
pass
# Services the panel spawned itself (each in its own setsid group):
for proc in (self.frontend_process, self.backend_process):
for proc in (self.frontend_process, self.backend_process, self.memory_process):
if proc:
self._terminate_process(proc)
# Services attached via pid file (ncp-started, one shared group);
# _terminate_pid_file verifies ownership and unlinks. Guarantee the
# unlink even for a pid that's already dead so no stale files remain.
for pf in (FRONTEND_PID, BACKEND_PID):
for pf in (FRONTEND_PID, BACKEND_PID, MEMORY_PID):
if self._is_running(pf):
self._terminate_pid_file(pf)
else:
+4 -7
View File
@@ -12,20 +12,17 @@ REM exactly what this file exists to avoid.
REM
REM ASCII only, same rule as the .ps1 files - a test in tests/test_smoke.py enforces it.
setlocal
REM The CLI pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
REM ncp.py pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
REM redirected stdout would otherwise raise UnicodeEncodeError). A console still
REM on codepage 437 renders those bytes as mojibake, so switch it to UTF-8 here.
REM ponytail: chcp changes the calling console's codepage and does not restore
REM it on exit. Harmless in practice; if that ever matters, set the console CP
REM from inside the CLI with ctypes SetConsoleOutputCP instead.
REM from inside ncp.py with ctypes SetConsoleOutputCP instead.
chcp 65001 >nul 2>&1
set "ROOT=%~dp0.."
REM System Python fallback so backup/restore work before the venv exists; those
REM delegate to the stdlib-only bin/sync.py. Mirrors the same fallback in ncp.ps1.
set "PY=%ROOT%\Promethean\Scripts\python.exe"
if not exist "%PY%" set "PY=python"
pushd "%ROOT%"
"%PY%" -m nexusos_cli.cli %*
set "NEXUS_EXIT=%ERRORLEVEL%"
popd
exit /b %NEXUS_EXIT%
"%PY%" "%ROOT%\management\ncp.py" %*
exit /b %ERRORLEVEL%
+66 -141
View File
@@ -25,24 +25,17 @@ import subprocess
import sys
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from synapse import proc_util
from synapse.nexus_config import SOURCE_CHECKOUT, settings
ROOT = settings.project_root
PID_DIR = settings.runtime_dir / "pids"
LOG_DIR = settings.runtime_dir
FRONTEND_DIR = settings.frontend_source_dir
ROOT = Path(__file__).resolve().parent.parent
PID_DIR = ROOT / "runtime" / "pids"
LOG_DIR = ROOT / "runtime"
FRONTEND_DIR = ROOT / "interface" / "web"
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
OLLAMA_MODELS_DIR = settings.models_dir
OLLAMA_MODELS_DIR = ROOT / "models"
WINDOWS = os.name == "nt"
_VENV_PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
PYTHON = Path(os.getenv("NEXUS_PYTHON", "")) if os.getenv("NEXUS_PYTHON") else (
_VENV_PYTHON if _VENV_PYTHON.exists() else Path(sys.executable)
)
PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
PID_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -99,25 +92,17 @@ def _psutil():
import psutil
return psutil
except ImportError:
return None
sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.")
# -- services ------------------------------------------------------------------
@dataclass
class Service:
key: str
label: str
port: int
cwd: Path
patterns: list
_argv: object = None
pid_file: Path = field(init=False)
log_file: Path = field(init=False)
def __post_init__(self):
self.pid_file = PID_DIR / f"{self.key}.pid"
self.log_file = LOG_DIR / f"{self.key}.log"
def __init__(self, key, label, port, cwd, patterns, argv=None):
self.key, self.label, self.port = key, label, port
self.cwd, self.patterns, self._argv = cwd, patterns, argv
self.pid_file = PID_DIR / f"{key}.pid"
self.log_file = LOG_DIR / f"{key}.log"
@property
def url(self) -> str:
@@ -127,10 +112,10 @@ class Service:
return self._argv() if callable(self._argv) else self._argv
# Bind loopback by default: the backend REST API is unauthenticated, so
# Bind loopback by default: the backend/memory REST APIs are unauthenticated, so
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
BIND_HOST = settings.bind_host
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
def _uvicorn(app: str, port: int):
@@ -146,12 +131,12 @@ def _uvicorn(app: str, port: int):
SERVICES = {
"memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
"memory": Service("memory", "NEXUS MEMORY SERVICE", 8001, ROOT,
["uvicorn synapse.memory"],
lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
lambda: _uvicorn("synapse.memory.service:app", 8001)),
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
["uvicorn synapse.main"],
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
lambda: _uvicorn("synapse.main:sio_app", 8000)),
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
["vite --host", "npm run dev"],
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
@@ -167,13 +152,7 @@ def read_pid(svc: Service):
def alive(pid) -> bool:
ps = _psutil()
if pid is None:
return False
if ps is not None:
return ps.pid_exists(pid)
# Not os.kill(pid, 0): that reports False for a live process owned by
# another user, and Windows has no signals to fall back on.
return proc_util.pid_alive(pid)
return pid is not None and ps.pid_exists(pid)
def pid_is_ours(pid, patterns) -> bool:
@@ -183,10 +162,7 @@ def pid_is_ours(pid, patterns) -> bool:
is not enough. TERMing a recycled PID can log the user out."""
ps = _psutil()
try:
if ps is not None:
cmd = " ".join(ps.Process(pid).cmdline())
else:
cmd = proc_util.pid_cmdline(pid)
cmd = " ".join(ps.Process(pid).cmdline())
except Exception:
return False
return any(p in cmd for p in patterns)
@@ -242,10 +218,10 @@ _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 its services and
# only then waits on each, so by the time a service's turn comes it is
# normally up - and reporting "already running" for a service this same
# command started two seconds ago reads like a stale process.
# "READY", not "already running": `ncp start` launches memory and backend
# together and only then waits on each, so by the time the backend's turn
# comes it is normally up - and reporting "already running" for a service
# this same command started two seconds ago reads like a stale process.
print(f" {svc.label} READY (:{svc.port})")
return True
for _ in range(int(timeout / _POLL_STEP)):
@@ -273,13 +249,6 @@ def kill_matching(patterns, force=False) -> int:
ps = _psutil()
me = os.getpid()
hit = 0
if ps is None:
for pid, cmd in proc_util.iter_processes():
if pid == me or not any(pattern in cmd for pattern in patterns):
continue
if proc_util.terminate_pid(pid, force=force):
hit += 1
return hit
for proc in ps.process_iter(["pid", "cmdline"]):
if proc.info["pid"] == me:
continue
@@ -297,12 +266,6 @@ def kill_port(port: int) -> bool:
"""Whatever holds the port IS the service - this is the backstop that makes
stop reliable regardless of process-tree shape or PID-file accuracy."""
ps = _psutil()
if ps is None:
killed = False
for pid in proc_util.pids_listening_on(port):
if pid != os.getpid() and proc_util.terminate_pid(pid, force=True):
killed = True
return killed
killed = False
try:
conns = ps.net_connections(kind="inet")
@@ -326,18 +289,13 @@ def stop_service(svc: Service) -> bool:
if alive(pid) and pid_is_ours(pid, svc.patterns):
ps = _psutil()
try:
if ps is not None:
proc = ps.Process(pid)
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
else:
# No psutil means no process tree; the kill_matching sweep
# below is what catches reparented children here.
proc_util.terminate_pid(pid)
proc = ps.Process(pid)
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
except Exception:
pass
svc.pid_file.unlink(missing_ok=True)
@@ -368,15 +326,8 @@ def start_ollama(background: bool = False) -> None:
background=False (`ncp start --ai`): blocks until the model is warmed - weights
read off disk into RAM/VRAM, routinely about a minute - so "started" means the
AI can actually answer."""
if not settings.manage_ollama:
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags", timeout=3)
print(
f"REMOTE OLLAMA {'REACHABLE' if running else 'UNREACHABLE'} "
f"({settings.ollama_host})"
)
return
print("Starting OLLAMA...")
url = settings.api_url + "/ollama/start" + ("?background=true" if background else "")
url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "")
req = urllib.request.Request(url, method="POST")
try:
urllib.request.urlopen(req, timeout=180).read(1)
@@ -389,10 +340,7 @@ def stop_ollama() -> None:
"""Prefer the backend endpoint for a clean OllamaManager shutdown; if the
backend is already down, kill `ollama serve` directly so it never lingers
holding VRAM/RAM. Must run BEFORE the backend is torn down."""
if not settings.manage_ollama:
print(f"REMOTE OLLAMA IS EXTERNALLY MANAGED ({settings.ollama_host})")
return
req = urllib.request.Request(settings.api_url + "/ollama/stop", method="POST")
req = urllib.request.Request("http://localhost:8000/ollama/stop", method="POST")
try:
urllib.request.urlopen(req, timeout=5).read(1)
print("NEXUS OLLAMA STOPPED")
@@ -409,13 +357,12 @@ def stop_ollama() -> None:
# -- commands ------------------------------------------------------------------
def cmd_start(target) -> None:
if target in ("--backend", "-b"):
if target in ("--memory", "-m"):
launch(SERVICES["memory"]); wait_for_port(SERVICES["memory"])
elif target in ("--backend", "-b"):
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
start_ollama()
elif target in ("--frontend", "-f"):
if not SOURCE_CHECKOUT:
print("Vite source is unavailable in wheel installs; the backend serves the bundled UI.")
return
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
elif target in ("--ai", "-a"):
start_ollama()
@@ -434,15 +381,17 @@ def cmd_start(target) -> None:
# 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()
start_ollama(background=True)
if SOURCE_CHECKOUT and not WINDOWS:
if not WINDOWS:
launch(SERVICES["frontend"])
t_bg = time.perf_counter()
print("\nBoot timing:")
print(f" backend : {t_services - t0:5.1f}s")
print(f" services (memory+backend) : {t_services - t0:5.1f}s")
print(f" ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
print(f" total to interactive : {t_bg - t0:5.1f}s")
else:
@@ -450,13 +399,14 @@ def cmd_start(target) -> None:
def cmd_stop(target) -> None:
if target in ("--backend", "-b"):
if target in ("--memory", "-m"):
stop_service(SERVICES["memory"])
elif target in ("--backend", "-b"):
stop_ollama(); stop_service(SERVICES["backend"])
elif target in ("--frontend", "-f"):
stop_service(SERVICES["frontend"])
elif target in ("--ai", "-a"):
stop_ollama()
elif target in (None, "", "all"):
stop_service(SERVICES["memory"])
stop_ollama()
stop_service(SERVICES["backend"])
stop_service(SERVICES["frontend"])
@@ -466,21 +416,14 @@ def cmd_stop(target) -> None:
def cmd_kill() -> None:
print("Force-killing all Nexus processes...")
targets = [
(settings.backend_port, "SYNAPSE"),
(settings.memory_port, "MEMORY"),
(5173, "INTERFACE"),
]
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
if settings.manage_ollama:
targets.append((11434, "OLLAMA"))
patterns.append("ollama serve")
for port, name in targets:
for port, name in ((8000, "SYNAPSE"), (8001, "MEMORY"),
(5173, "INTERFACE"), (11434, "OLLAMA")):
if kill_port(port):
print(f" KILLED: {name} (:{port})")
else:
print(f" NOT RUNNING: {name} (:{port})")
kill_matching(patterns, force=True)
kill_matching(["uvicorn synapse", "npm run dev", "vite --host", "ollama serve"],
force=True)
for pid_file in PID_DIR.glob("*.pid"):
pid_file.unlink(missing_ok=True)
print("Done.")
@@ -500,12 +443,12 @@ def cmd_status() -> None:
print("Backend:")
one("Synapse ", SERVICES["backend"])
one("Memory service", SERVICES["memory"])
print("\nFrontend:")
one("Vite ", SERVICES["frontend"])
print("\nModel server:")
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags")
mode = "remote" if not settings.manage_ollama else "local"
print(f" Ollama ({mode}) : {'RUNNING' if running else 'STOPPED'} ({settings.ollama_host})")
running = http_ok("http://localhost:11434/api/tags")
print(f" Ollama : {'RUNNING (:11434)' if running else 'STOPPED'}")
def _tail(path: Path, n: int) -> None:
@@ -516,25 +459,26 @@ def _tail(path: Path, n: int) -> None:
print(f" (no log at {path})")
LOG_HEADINGS = {"backend": "BACKEND", "frontend": "FRONTEND"}
LOG_HEADINGS = {"memory": "MEMORY SERVICE", "backend": "BACKEND", "frontend": "FRONTEND"}
def cmd_logs(target) -> None:
named = {"--frontend": "frontend", "-f": "frontend",
"--backend": "backend", "-b": "backend"}
"--backend": "backend", "-b": "backend",
"--memory": "memory", "-m": "memory"}
if target in named:
key = named[target]
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
_tail(SERVICES[key].log_file, 50)
elif target in (None, "", "all"):
for i, key in enumerate(("backend", "frontend")):
for i, key in enumerate(("memory", "backend", "frontend")):
if i:
print()
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
_tail(SERVICES[key].log_file, 30)
else:
print(f"Unknown logs target: '{target}'")
print("Usage: ncp logs [frontend|backend|all]")
print("Usage: ncp logs [frontend|backend|memory|all]")
def _apply_fixes() -> None:
@@ -642,11 +586,11 @@ def cmd_doctor(fix: bool = False) -> None:
mark(importable("from synapse.main import sio_app"),
"Backend module importable", "Backend module failed to import")
print("\nChecking memory...")
print("\nChecking memory service...")
mark((ROOT / "synapse" / "memory").is_dir(),
"Memory module directory found", "Missing memory module directory")
mark(importable("from synapse.memory.curator import extract_for_conversation"),
"Memory curator importable", "Memory curator failed to import")
mark(importable("from synapse.memory.service import app"),
"Memory service module importable", "Memory service module failed to import")
mark(os.access(ROOT / "synapse" / "memory", os.W_OK),
"Memory database directory writable", "Memory database directory not writable")
@@ -687,26 +631,6 @@ def cmd_update() -> None:
cmd_doctor()
def cmd_upgrade() -> int:
"""Pull + rebuild + restart the backend. This is what the web UI's "install
update" button runs, so it must be spawned DETACHED from the backend - it
stops the very server that asked for it.
Ollama is deliberately left alone (stop_service, not cmd_stop): it is a
separate process on :11434, the restore does not touch a present binary, and
reloading a multi-GB model is the slowest part of a restart.
"""
print("Stopping backend...")
stop_service(SERVICES["backend"])
# --no-desktop: an in-app update should not rewrite XFCE panels/theme.
rc = sync_py("restore", "--no-desktop")
print("\nRestarting backend...")
launch(SERVICES["backend"])
ok = wait_for_port(SERVICES["backend"])
print("Backend is back up." if ok else "Backend did not come back - see runtime/backend.log")
return rc if ok else 1
def cmd_clean() -> None:
print("Cleaning Nexus runtime files...\n")
print("Removing PID files...")
@@ -746,10 +670,10 @@ MODEL_CATALOG = [
def cmd_models(action, name) -> None:
if action == "list":
if not http_ok(settings.ollama_host.rstrip("/") + "/api/tags"):
if not http_ok("http://localhost:11434/api/tags"):
print("Ollama is not running. Start the backend first with: ncp start -b")
return
raw = urllib.request.urlopen(settings.ollama_host.rstrip("/") + "/api/tags", timeout=5).read()
raw = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=5).read()
models = json.loads(raw).get("models", [])
print("Installed models:\n")
if not models:
@@ -807,6 +731,7 @@ def cmd_web() -> int:
started. Windows uses the pywebview window launch_nexus.ps1 opens."""
if WINDOWS:
if not http_ok(SERVICES["backend"].url):
launch(SERVICES["memory"])
launch(SERVICES["backend"])
wait_for_port(SERVICES["backend"])
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode
@@ -830,12 +755,14 @@ Commands:
show <id> Print a playbook's goal + instructions
history [query] Recent conversations (optional keyword)
start Start ALL Nexus services (backend + frontend)
start Start ALL Nexus services (memory + backend + frontend)
--memory, -m Start only the memory service
--frontend,-f Start only the frontend
--backend, -b Start only the backend
--ai, -a Start only the AI (Ollama); `start`/`start -b` already include it
stop Stop ALL Nexus services
--memory, -m Stop only the memory service
--frontend,-f Stop only the frontend
--backend, -b Stop only the backend
@@ -844,12 +771,12 @@ Commands:
status Show service status
logs Show logs for all services
--memory, -m Memory service logs
--frontend,-f Frontend logs
--backend, -b Backend logs
doctor [--fix] Run Nexus diagnostics (--fix applies safe repairs)
update Update Nexus dependencies
upgrade Pull, rebuild and restart the backend (what the UI's update button runs)
clean Remove runtime files and caches
models
@@ -897,8 +824,6 @@ def main(argv) -> int:
cmd_doctor(fix="--fix" in rest)
elif cmd == "update":
cmd_update()
elif cmd == "upgrade":
return cmd_upgrade()
elif cmd == "clean":
cmd_clean()
elif cmd == "nvidia-reqs":
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Launch NexusOS as a standalone Edge app window. Single-process mode: the
# backend on :8000 serves the built web UI itself (no separate Vite), so this
# only starts the backend. The AI (Ollama) does NOT auto-start
# only starts the memory service + backend. The AI (Ollama) does NOT auto-start
# — turn it on from the UI's Start AI button. Closing the app window shuts the
# services back down (matches the launcher's "closing the window shuts it down").
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
@@ -22,7 +22,7 @@ if curl -s --max-time 1 "$APP_URL/" >/dev/null 2>&1; then
OWN_SERVICES=0
else
OWN_SERVICES=1
# Single-process app: backend only. Backend serves the built UI at
# Single-process app: memory + backend only. Backend serves the built UI at
# :8000; no frontend/Vite process, no AI auto-start.
"$NEXUS_ROOT/management/nexus-cli.sh" start -m
"$NEXUS_ROOT/management/nexus-cli.sh" start -b
+4 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# ncp - Linux entry point. The CLI itself is nexusos_cli/cli.py, which runs
# ncp - Linux entry point. The CLI itself is management/ncp.py, which runs
# unchanged on Windows too (see management/ncp.cmd); this stays a shell script
# because ~/.bashrc, launch_nexus.sh, bin/restore-linux.sh, controlpanel.py,
# bin/panel/nexus-popup.py and management/nexus-app.sh all invoke this path.
@@ -8,10 +8,9 @@
# directory drives itself instead of reaching into the real install.
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
# Prefer the project venv for the full desktop dependency set. The portable CLI
# falls back to system Python when the package is installed without that venv.
# ncp.py needs psutil (venv), but its backup/restore path delegates to the
# stdlib-only bin/sync.py and must work before the venv is built.
PY="$NEXUS_ROOT/Promethean/bin/python3"
[ -x "$PY" ] || PY=python3
cd "$NEXUS_ROOT"
exec "$PY" -m nexusos_cli.cli "$@"
exec "$PY" "$NEXUS_ROOT/management/ncp.py" "$@"
@@ -11,9 +11,7 @@ import sys
import httpx
from synapse.nexus_config import settings
BASE = settings.api_url
BASE = __import__("os").environ.get("NEXUS_API", "http://localhost:8000")
def _client():
@@ -120,8 +118,8 @@ def cmd_history(args):
_die_if_down(e)
def main(argv=None, prog="nexus"):
p = argparse.ArgumentParser(prog=prog)
def main():
p = argparse.ArgumentParser(prog="ncp")
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("chat"); c.add_argument("message", nargs="+")
@@ -137,9 +135,8 @@ def main(argv=None, prog="nexus"):
h = sub.add_parser("history"); h.add_argument("query", nargs="?")
h.add_argument("--limit", type=int, default=20); h.set_defaults(fn=cmd_history)
args = p.parse_args(argv)
result = args.fn(args)
return result if isinstance(result, int) else 0
args = p.parse_args()
args.fn(args)
if __name__ == "__main__":
+1 -9
View File
@@ -6,15 +6,7 @@ worker's `finally` aborted before unlinking its PID file -> stale runtime/pids/*
These asserts pin the two invariants that prevent that. Run: python test_controlpanel_close.py
"""
from types import SimpleNamespace
import pytest
pytest.importorskip("tkinter", reason="controlpanel is a Tk GUI; headless boxes lack python3-tk")
try:
from management.controlpanel import NexusControlPanel # noqa: E402
except ModuleNotFoundError: # direct: python management/test_controlpanel_close.py
from controlpanel import NexusControlPanel # type: ignore[no-redef] # noqa: E402
from controlpanel import NexusControlPanel
def _fake(closing, after_raises):
@@ -1,5 +1,7 @@
"""Pin the SSE parser in nexus_api. Run: python tests/test_nexus_api.py"""
from nexusos_cli.nexus_api import iter_chunks
"""Pin the SSE parser in nexus_api. Run: python management/test_nexus_api.py"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from nexus_api import iter_chunks
# A realistic /chat/stream frame: two token chunks, a meta block, then done.
lines = [
View File
View File
-283
View File
@@ -1,283 +0,0 @@
"""Email backend for the NexusOS mail client.
IMAP read via imap-tools, SMTP send via the stdlib. Defaults target iCloud
(imap.mail.me.com / smtp.mail.me.com); with a custom-domain iCloud account the
LOGIN is the primary Apple ID + an app-specific password, and the custom-domain
address is the From alias.
Multiple accounts are supported (e.g. two iCloud aliases sharing the same
server config) each gets its own id, credentials, and cached IMAP
connection. Credentials live in a gitignored file under runtime/ NEVER the
settings table, which bin/sync.py dumps to git. Password is write-only over
the API.
"""
from __future__ import annotations
import json
import os
import smtplib
import ssl
import threading
import uuid
from email.message import EmailMessage
from typing import Any, Callable, Dict, List, Optional
from synapse.nexus_config import RUNTIME_DIR
_ACCOUNT_FILE = RUNTIME_DIR / "mail_accounts.json"
# Both imaplib.IMAP4_SSL and smtplib.SMTP.starttls() fall back to
# ssl._create_stdlib_context() when handed no context, and that one sets
# verify_mode=CERT_NONE with check_hostname=False -- encrypted, but to nobody in
# particular. Anything positioned to intercept the connection can present its
# own certificate and collect the app-specific password on login. The default
# context verifies the chain and the hostname.
_TLS = ssl.create_default_context()
_DEFAULTS: Dict[str, Any] = {
"label": "", # optional nickname shown in the account list
"imap_host": "imap.mail.me.com",
"imap_port": 993,
"smtp_host": "smtp.mail.me.com",
"smtp_port": 587,
"username": "", # primary Apple ID address (login), not the alias
"password": "", # app-specific password
"from_addr": "", # e.g. nexus@enderofwings.com
"from_name": "",
}
def _new_id() -> str:
return uuid.uuid4().hex[:12]
def _write_accounts(accounts: List[Dict[str, Any]]) -> None:
_ACCOUNT_FILE.parent.mkdir(parents=True, exist_ok=True)
# Create the file 0600 and rename it into place, rather than write-then-chmod:
# that left it at the umask (usually world-readable) for the length of the
# write, and a crash partway through truncated the real account file. The
# temp file inherits the mode through os.replace.
tmp = _ACCOUNT_FILE.with_name(_ACCOUNT_FILE.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as fh:
json.dump({"accounts": accounts}, fh, indent=2)
os.replace(tmp, _ACCOUNT_FILE)
def load_accounts() -> List[Dict[str, Any]]:
try:
raw = json.loads(_ACCOUNT_FILE.read_text())
except Exception:
return []
if isinstance(raw, dict) and "accounts" in raw:
accounts = raw["accounts"]
elif isinstance(raw, dict) and raw.get("username"):
# Legacy single-account file (pre-multi-account) — migrate in place.
accounts = [{**_DEFAULTS, **raw, "id": _new_id()}]
_write_accounts(accounts)
else:
accounts = []
return [{**_DEFAULTS, **a} for a in accounts]
def get_account(account_id: str) -> Dict[str, Any]:
for a in load_accounts():
if a["id"] == account_id:
return a
raise KeyError(account_id)
def save_account(account_id: Optional[str], update: Dict[str, Any]) -> Dict[str, Any]:
"""Create (account_id=None) or merge-update an account. A blank/missing
password on update keeps the stored one."""
accounts = load_accounts()
if account_id is None:
data = dict(_DEFAULTS)
data["id"] = _new_id()
for k in _DEFAULTS:
if k in update:
data[k] = update[k]
accounts.append(data)
else:
for a in accounts:
if a["id"] != account_id:
continue
for k in _DEFAULTS:
if k == "password":
if update.get("password"):
a["password"] = update["password"]
elif k in update:
a[k] = update[k]
data = a
break
else:
raise KeyError(account_id)
_write_accounts(accounts)
_reset_conn(data["id"]) # creds/host may have changed
return public_account(data)
def delete_account(account_id: str) -> None:
accounts = [a for a in load_accounts() if a["id"] != account_id]
_write_accounts(accounts)
_reset_conn(account_id)
def public_account(a: Dict[str, Any]) -> Dict[str, Any]:
"""Account config for the UI — password replaced by a has_password flag."""
configured = _is_configured(a)
data = dict(a)
pw = data.pop("password", "")
data["has_password"] = bool(pw)
data["configured"] = configured
return data
def public_accounts() -> List[Dict[str, Any]]:
return [public_account(a) for a in load_accounts()]
def _is_configured(a: Dict[str, Any]) -> bool:
return bool(a.get("username") and a.get("password") and a.get("imap_host"))
def is_configured(account_id: str) -> bool:
try:
return _is_configured(get_account(account_id))
except KeyError:
return False
# --- IMAP ----------------------------------------------------------------------
# One authenticated connection per account, reused across requests — a fresh
# login per click is the ~1-2s iCloud handshake, and that was the visible lag.
# imap-tools/imaplib is single-command-at-a-time, so a lock serialises the
# thread-pooled endpoints.
_conns: Dict[str, Any] = {}
_conn_lock = threading.Lock()
def _new_mailbox(a: Dict[str, Any]):
from imap_tools import MailBox
return MailBox(a["imap_host"], a["imap_port"], ssl_context=_TLS).login(a["username"], a["password"])
def _reset_conn(account_id: str) -> None:
with _conn_lock:
conn = _conns.pop(account_id, None)
try:
if conn is not None:
conn.logout()
except Exception:
pass
def _run(account_id: str, fn: Callable):
"""Run fn(mailbox) on the account's shared connection, rebuilding it once
if the connection was dropped (iCloud closes idle sockets)."""
a = get_account(account_id)
with _conn_lock:
for attempt in (1, 2):
try:
conn = _conns.get(account_id)
if conn is None:
conn = _conns[account_id] = _new_mailbox(a)
return fn(conn)
except Exception:
conn = _conns.pop(account_id, None)
try:
if conn is not None:
conn.logout()
except Exception:
pass
if attempt == 2:
raise
def test_connection(account_id: str) -> Dict[str, Any]:
_reset_conn(account_id) # force a fresh login so the test reflects the saved creds
try:
return {"ok": True, "folders": len(_run(account_id, lambda mb: mb.folder.list()))}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_folders(account_id: str) -> List[str]:
return _run(account_id, lambda mb: [f.name for f in mb.folder.list()])
def _summary(msg) -> Dict[str, Any]:
return {
"uid": msg.uid,
"subject": msg.subject,
"from": msg.from_,
"to": list(msg.to),
"date": msg.date.isoformat() if msg.date else None,
"seen": "\\Seen" in msg.flags,
"preview": (msg.text or msg.html or "")[:160].strip(),
}
def list_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0) -> List[Dict[str, Any]]:
def op(mb):
mb.folder.set(folder)
# Newest first; over-fetch by offset then slice (fine at personal scale).
msgs = list(mb.fetch(reverse=True, limit=offset + limit, headers_only=True, bulk=True, mark_seen=False))
return [_summary(m) for m in msgs[offset:offset + limit]]
return _run(account_id, op)
def get_message(account_id: str, folder: str, uid: str) -> Optional[Dict[str, Any]]:
from imap_tools import AND
def op(mb):
mb.folder.set(folder)
for msg in mb.fetch(AND(uid=uid), mark_seen=True, bulk=True):
return {
"uid": msg.uid,
"subject": msg.subject,
"from": msg.from_,
"to": list(msg.to),
"cc": list(msg.cc),
"date": msg.date.isoformat() if msg.date else None,
"html": msg.html,
"text": msg.text,
"attachments": [{"name": a.filename, "size": a.size} for a in msg.attachments],
}
return None
return _run(account_id, op)
def set_seen(account_id: str, folder: str, uid: str, seen: bool = True) -> None:
def op(mb):
mb.folder.set(folder)
mb.flag(uid, "\\Seen", seen)
_run(account_id, op)
def delete_message(account_id: str, folder: str, uid: str) -> None:
def op(mb):
mb.folder.set(folder)
mb.delete(uid)
_run(account_id, op)
# --- SMTP ----------------------------------------------------------------------
def send_message(account_id: str, to: str, subject: str, body: str,
cc: str = "", from_addr: str = "") -> Dict[str, Any]:
a = get_account(account_id)
sender = from_addr or a["from_addr"] or a["username"]
msg = EmailMessage()
msg["From"] = f'{a["from_name"]} <{sender}>' if a["from_name"] else sender
msg["To"] = to
if cc:
msg["Cc"] = cc
msg["Subject"] = subject
msg.set_content(body)
recipients = [r.strip() for r in (to + "," + cc).split(",") if r.strip()]
with smtplib.SMTP(a["smtp_host"], a["smtp_port"]) as s:
s.starttls(context=_TLS)
s.login(a["username"], a["password"])
s.send_message(msg, from_addr=sender, to_addrs=recipients)
return {"sent": True, "to": recipients}
-113
View File
@@ -1,113 +0,0 @@
"""HTTP routes for the mail module — mounted onto the main app via modules/registry.py."""
from __future__ import annotations
import asyncio as _asyncio
from typing import Any, Dict
from fastapi import APIRouter, Body, HTTPException
from . import backend as mail
MANIFEST = {"key": "mail", "label": "Mail", "icon": "✉️"}
router = APIRouter(prefix="/mail", tags=["mail"])
def _require_mail(account_id: str):
if not mail.is_configured(account_id):
raise HTTPException(status_code=400, detail="mail account not configured")
# --- accounts --------------------------------------------------------------------
@router.get("/accounts")
async def mail_accounts():
return {"accounts": mail.public_accounts()}
@router.post("/accounts")
async def mail_account_create(payload: Dict[str, Any] = Body(...)):
return mail.save_account(None, payload)
@router.put("/accounts/{account_id}")
async def mail_account_update(account_id: str, payload: Dict[str, Any] = Body(...)):
try:
return mail.save_account(account_id, payload)
except KeyError:
raise HTTPException(status_code=404, detail="account not found")
@router.delete("/accounts/{account_id}")
async def mail_account_delete(account_id: str):
mail.delete_account(account_id)
return {"status": "deleted"}
@router.post("/accounts/{account_id}/test")
async def mail_account_test(account_id: str):
_require_mail(account_id)
return await _asyncio.to_thread(mail.test_connection, account_id)
# --- mailbox -----------------------------------------------------------------------
@router.get("/folders")
async def mail_folders(account_id: str):
_require_mail(account_id)
try:
return {"folders": await _asyncio.to_thread(mail.list_folders, account_id)}
except Exception as e:
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
@router.get("/messages")
async def mail_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0):
_require_mail(account_id)
try:
msgs = await _asyncio.to_thread(mail.list_messages, account_id, folder, limit, offset)
return {"messages": msgs}
except Exception as e:
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
@router.get("/message")
async def mail_message(account_id: str, folder: str, uid: str):
_require_mail(account_id)
try:
msg = await _asyncio.to_thread(mail.get_message, account_id, folder, uid)
except Exception as e:
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
if not msg:
raise HTTPException(status_code=404, detail="message not found")
return msg
@router.post("/send")
async def mail_send(payload: Dict[str, Any] = Body(...)):
account_id = payload.get("account_id", "")
_require_mail(account_id)
to = (payload.get("to") or "").strip()
if not to:
raise HTTPException(status_code=400, detail="'to' is required")
try:
return await _asyncio.to_thread(
mail.send_message, account_id, to, payload.get("subject", ""), payload.get("body", ""),
payload.get("cc", ""), payload.get("from_addr", ""),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"SMTP error: {e}")
@router.post("/seen")
async def mail_seen(payload: Dict[str, Any] = Body(...)):
account_id = payload.get("account_id", "")
_require_mail(account_id)
await _asyncio.to_thread(mail.set_seen, account_id, payload["folder"], payload["uid"], payload.get("seen", True))
return {"status": "ok"}
@router.post("/delete")
async def mail_delete(payload: Dict[str, Any] = Body(...)):
account_id = payload.get("account_id", "")
_require_mail(account_id)
await _asyncio.to_thread(mail.delete_message, account_id, payload["folder"], payload["uid"])
return {"status": "deleted"}
View File
-141
View File
@@ -1,141 +0,0 @@
"""Network status module.
Cross-platform connection info (via psutil), a WireGuard VPN status/toggle
that only works where NetworkManager + nmcli exist (Linux this mirrors the
XFCE panel's network-popup.py, minus the GTK UI), and a small user-defined
list of ping targets so "is my router/VPN endpoint reachable" isn't tied to
any one hardcoded host.
"""
from __future__ import annotations
import json
import os
import platform
import re
import socket
import subprocess
import uuid
from typing import Any, Dict, List
from synapse.nexus_config import RUNTIME_DIR
_TARGETS_FILE = RUNTIME_DIR / "network_targets.json"
_PING_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE)
# --- connection info ---------------------------------------------------------------
def hostname() -> str:
return socket.gethostname()
def primary_connection() -> Dict[str, Any]:
import psutil
stats = psutil.net_if_stats()
addrs = psutil.net_if_addrs()
for name, addr_list in addrs.items():
st = stats.get(name)
if not st or not st.isup:
continue
lname = name.lower()
if lname.startswith(("lo", "loopback")):
continue
for a in addr_list:
if a.family == socket.AF_INET and not a.address.startswith("169.254"):
iface_type = "wifi" if any(k in lname for k in ("wlan", "wi-fi", "wireless", "wl")) else "ethernet"
return {"interface": name, "ip": a.address, "type": iface_type}
return {"interface": None, "ip": None, "type": "offline"}
# --- WireGuard / VPN (Linux + NetworkManager only) ----------------------------------
def _nmcli(*args: str) -> str:
try:
return subprocess.check_output(
["nmcli", "-t", "--escape", "no", *args],
text=True, stderr=subprocess.DEVNULL, timeout=5,
).strip()
except Exception:
return ""
def _nmcli_available() -> bool:
if os.name != "posix":
return False
try:
subprocess.check_output(["nmcli", "--version"], stderr=subprocess.DEVNULL, timeout=3)
return True
except Exception:
return False
def vpn_status() -> Dict[str, Any]:
if not _nmcli_available():
return {"available": False}
wgs = [p for p in (line.split(":") for line
in _nmcli("-f", "NAME,TYPE,STATE", "connection", "show").splitlines())
if len(p) >= 3 and p[1] == "wireguard"]
for name, _t, state in wgs:
if state == "activated":
return {"available": True, "configured": True, "name": name, "connected": True}
if wgs:
return {"available": True, "configured": True, "name": wgs[0][0], "connected": False}
return {"available": True, "configured": False, "name": None, "connected": False}
def vpn_toggle(enable: bool) -> Dict[str, Any]:
status = vpn_status()
if not status.get("configured"):
raise RuntimeError("no WireGuard tunnel configured")
action = "up" if enable else "down"
subprocess.check_output(
["nmcli", "connection", action, status["name"]],
stderr=subprocess.STDOUT, text=True, timeout=15,
)
return vpn_status()
# --- ping targets --------------------------------------------------------------------
def _load_targets() -> List[Dict[str, Any]]:
try:
return json.loads(_TARGETS_FILE.read_text()).get("targets", [])
except Exception:
return []
def _write_targets(targets: List[Dict[str, Any]]) -> None:
_TARGETS_FILE.parent.mkdir(parents=True, exist_ok=True)
_TARGETS_FILE.write_text(json.dumps({"targets": targets}, indent=2))
def list_targets() -> List[Dict[str, Any]]:
return _load_targets()
def add_target(label: str, host: str) -> Dict[str, Any]:
targets = _load_targets()
t = {"id": uuid.uuid4().hex[:12], "label": label, "host": host}
targets.append(t)
_write_targets(targets)
return t
def delete_target(target_id: str) -> None:
_write_targets([t for t in _load_targets() if t["id"] != target_id])
def ping(host: str) -> Dict[str, Any]:
is_windows = platform.system() == "Windows"
cmd = ["ping", "-n", "1", "-w", "1000", host] if is_windows else ["ping", "-c", "1", "-W", "1", host]
try:
out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT, timeout=3)
m = _PING_LATENCY_RE.search(out)
return {"ok": True, "latency_ms": float(m.group(1)) if m else None}
except subprocess.CalledProcessError:
return {"ok": False, "latency_ms": None}
except Exception as e:
return {"ok": False, "latency_ms": None, "error": str(e)}
def ping_targets() -> List[Dict[str, Any]]:
return [{**t, **ping(t["host"])} for t in _load_targets()]
-52
View File
@@ -1,52 +0,0 @@
"""HTTP routes for the network module — mounted onto the main app via modules/registry.py."""
from __future__ import annotations
import asyncio as _asyncio
from typing import Any, Dict
from fastapi import APIRouter, Body, HTTPException
from . import backend as net
MANIFEST = {"key": "network", "label": "Network", "icon": "📡"}
router = APIRouter(prefix="/network", tags=["network"])
@router.get("/status")
async def network_status():
return {"hostname": net.hostname(), "connection": net.primary_connection(), "vpn": net.vpn_status()}
@router.post("/vpn/toggle")
async def network_vpn_toggle(payload: Dict[str, Any] = Body(...)):
try:
return await _asyncio.to_thread(net.vpn_toggle, bool(payload.get("enable")))
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=502, detail=f"nmcli error: {e}")
@router.get("/targets")
async def network_targets():
return {"targets": net.list_targets()}
@router.post("/targets")
async def network_target_create(payload: Dict[str, Any] = Body(...)):
host = (payload.get("host") or "").strip()
if not host:
raise HTTPException(status_code=400, detail="'host' is required")
return net.add_target((payload.get("label") or "").strip() or host, host)
@router.delete("/targets/{target_id}")
async def network_target_delete(target_id: str):
net.delete_target(target_id)
return {"status": "deleted"}
@router.get("/ping")
async def network_ping_all():
return {"targets": await _asyncio.to_thread(net.ping_targets)}
-37
View File
@@ -1,37 +0,0 @@
"""Auto-discovers backend modules under modules/ — any subpackage with a
router.py exposing `router` (an APIRouter) is mounted automatically. Drop a
new module folder in and it's picked up on next start; nothing here needs
editing.
A missing router.py just means the folder isn't a module (skipped quietly).
An import error *inside* an existing router.py is a real bug and is left to
raise silently swallowing it would hide broken modules instead of
surfacing them at startup.
"""
from __future__ import annotations
import importlib
import pkgutil
from pathlib import Path
from typing import List
from fastapi import APIRouter
_MODULES_DIR = Path(__file__).resolve().parent
def _discover() -> List[APIRouter]:
routers = []
for info in sorted(pkgutil.iter_modules([str(_MODULES_DIR)]), key=lambda m: m.name):
if not info.ispkg:
continue
if not (_MODULES_DIR / info.name / "router.py").exists():
continue
mod = importlib.import_module(f"modules.{info.name}.router")
router = getattr(mod, "router", None)
if isinstance(router, APIRouter):
routers.append(router)
return routers
ROUTERS = _discover()

Some files were not shown because too many files have changed in this diff Show More