forked from enderofwings/NexusOS
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d6aa8bdb0 | ||
|
|
12af13b019 | ||
|
|
a06366001b | ||
|
|
3e4fc9beb3 | ||
|
|
e60ed13361 | ||
|
|
2614bd10a6 | ||
|
|
35f7461ca4 | ||
|
|
5ea8b2ad72 | ||
|
|
99381f7e9e | ||
|
|
ef176dbb68 | ||
|
|
a0f033142f | ||
|
|
8bc8123bfa | ||
|
|
c4d7dc42f2 | ||
|
|
0d26f630e6 | ||
|
|
3e89df142b | ||
|
|
214ce07d1f | ||
|
|
93d78c0ad3 | ||
|
|
9ed2908170 | ||
|
|
9ca37057eb | ||
|
|
da3509eb04 | ||
|
|
6f5094b5fc | ||
|
|
1449280fcd | ||
|
|
5f67d19e80 | ||
|
|
9104c724c4 | ||
|
|
d579502a5b | ||
|
|
952ef8a0c4 | ||
|
|
7262e7730e | ||
|
|
656c14caf3 | ||
|
|
2ebe93b4f7 | ||
|
|
d56d579755 | ||
|
|
c3a7b6eefd | ||
|
|
42eaed647a | ||
|
|
fe5d18afa7 | ||
|
|
4bfa979ee8 | ||
|
|
f46e53de15 | ||
|
|
5294ab7c78 | ||
|
|
8691a67803 | ||
|
|
b5541d1c48 | ||
|
|
4a7451f5f5 | ||
|
|
1f6beed0d2 | ||
|
|
fe12eb1821 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,74 @@
|
||||
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/*
|
||||
@@ -2,6 +2,9 @@ Promethean/
|
||||
ollama/
|
||||
interface/web/node_modules/
|
||||
interface/web/dist/
|
||||
/.build-check/
|
||||
/dist/
|
||||
/*.egg-info/
|
||||
runtime/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -10,20 +10,43 @@ 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 started manually (sidebar **Start AI** / `nexus-cli.sh
|
||||
start --ai`), not on backend startup.
|
||||
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.
|
||||
|
||||
**Windows (recommended):**
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\install-windows.ps1 # one-time native install
|
||||
ncp web # memory :8001 + backend :8000 + UI
|
||||
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)
|
||||
```
|
||||
|
||||
**Linux full stack (dev):**
|
||||
```bash
|
||||
./launch_nexus.sh
|
||||
```
|
||||
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.
|
||||
This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
|
||||
|
||||
**macOS (community-supported):**
|
||||
```bash
|
||||
./install-macos.sh # one-time: Homebrew packages + venv + web build, via bin/sync.py
|
||||
./launch_nexus.sh # same script as Linux - it's plain bash, no Linux-only calls
|
||||
```
|
||||
No bundled Ollama binary (Linux x86-64 only) and no XFCE desktop branding — both
|
||||
already no-op on macOS via `bin/sync.py`'s `linux_stage()`. Ollama is instead the
|
||||
Homebrew-installed native binary, picked up automatically because
|
||||
`OllamaManager` falls back to `ollama` on PATH when the bundled binary is
|
||||
absent; that gets full Metal GPU acceleration with no extra config.
|
||||
|
||||
**Individual services via CLI:**
|
||||
```bash
|
||||
@@ -31,38 +54,41 @@ This activates the `Promethean` venv and starts the memory service on port 8001
|
||||
source Promethean/bin/activate
|
||||
|
||||
# Backend (serves the built UI at :8000 too)
|
||||
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
|
||||
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --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/.
|
||||
cd interface/web && npm run dev
|
||||
```
|
||||
|
||||
**Management CLI** (`ncp`) — start/stop services with PID tracking, plus terminal
|
||||
access to the same features as the web UI (all via the REST API on `:8000`):
|
||||
**Management CLI** (`nexus` / `ncp`) — start/stop services with PID tracking, plus
|
||||
terminal access to the same features as the web UI (REST API on `:8000`):
|
||||
```bash
|
||||
./management/nexus-cli.sh start # starts backend + frontend
|
||||
./management/nexus-cli.sh stop
|
||||
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
|
||||
|
||||
# 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
|
||||
ncp history [query] # recent conversations
|
||||
# Interactive TUI (Hermes/OpenClaw-style; needs pip install 'nexusos-ai[tui]'):
|
||||
nexus # bare command opens the Textual chat TUI
|
||||
nexus tui # same, explicit
|
||||
|
||||
# Feature one-shots (dispatch to nexusos_cli/nexus_api.py — httpx):
|
||||
nexus chat send "<message>" # stream a reply (POST /chat/stream)
|
||||
nexus memory list|add <text>|rm <id>
|
||||
nexus playbook list|show <id> # first playbook (*) is the active system prompt
|
||||
nexus history [query] # recent conversations
|
||||
nexus monitor # ASCII status dashboard (no prompt)
|
||||
```
|
||||
The 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 interactive TUI lives in `nexusos_cli/tui_app.py` (Textual, optional extra).
|
||||
One-shot subcommands and `nexus monitor` remain for scripts. The CLI package is
|
||||
`nexusos_cli/` (what the wheel ships); `management/` keeps desktop-only pieces —
|
||||
shell wrappers, Tk control panel, 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 (tests/ + management/) + eslint + .ps1 parse check
|
||||
./bin/check.sh # pytest + eslint + frontend tests + .ps1/.sh parse + wheel build
|
||||
```
|
||||
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.
|
||||
@@ -80,13 +106,15 @@ 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, 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.
|
||||
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.
|
||||
|
||||
### 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). After each exchange the stream endpoint calls the Memory Service to auto-extract persistent facts.
|
||||
- `/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.
|
||||
- `/playbooks` — CRUD for playbooks stored as YAML files in `data/playbooks/` via `synapse/playbooks/store.py`.
|
||||
- `/memory` — CRUD for persistent facts (proxies the same SQLite store as the memory service).
|
||||
- `/memory` — CRUD for persistent facts, backed by the same SQLite store the curator writes to.
|
||||
- `/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.
|
||||
@@ -94,8 +122,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 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`).
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
@@ -126,3 +154,5 @@ 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) |
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
# NexusOS
|
||||
|
||||
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
||||
Python/FastAPI backend, a bundled Ollama instance for inference, a persistent
|
||||
memory service, and a React frontend. No external AI provider is called.
|
||||
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.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -15,10 +17,12 @@ memory service, and a React frontend. No external AI provider is called.
|
||||
## What it is
|
||||
|
||||
NexusOS ("Nexus") is a self-hosted assistant you actually own. All inference
|
||||
runs through a **locally bundled Ollama** on `localhost`; conversations, facts,
|
||||
and settings live in local SQLite. It ships with desktop branding (XFCE theme,
|
||||
runs through **Ollama** on `localhost` by default; 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.
|
||||
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.
|
||||
|
||||
Chat with vision and voice, persistent memory, tool-using playbooks, document
|
||||
RAG scoped to Projects, gated action tools, and full model management — see
|
||||
@@ -88,6 +92,29 @@ 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
|
||||
@@ -99,7 +126,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 (memory :8001, backend :8000 — backend also serves the built UI)
|
||||
# 2. Launch (backend :8000 — it also serves the built UI)
|
||||
# The install symlinks ncp into /usr/local/bin (sudo); open a new shell first.
|
||||
ncp web
|
||||
```
|
||||
@@ -107,7 +134,8 @@ 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).
|
||||
`bin/sync.py` picks the right one for the host.
|
||||
macOS uses `requirements-base.txt` directly, no overlay — see the macOS section
|
||||
below. `bin/sync.py` picks the right one for the host.
|
||||
|
||||
`./install.sh` is also the update path — re-run it any time to pull the latest
|
||||
and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme
|
||||
@@ -145,14 +173,41 @@ 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 (backend :8000 — 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
|
||||
# Linux / macOS
|
||||
source Promethean/bin/activate
|
||||
|
||||
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
|
||||
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
|
||||
|
||||
# Frontend dev server (hot-reload) — only needed when editing the UI;
|
||||
# production serves the built dist/ from the backend at :8000.
|
||||
@@ -163,8 +218,7 @@ 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 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
|
||||
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
|
||||
```
|
||||
|
||||
### Promethean
|
||||
@@ -174,7 +228,7 @@ Nexus's dependencies out of the system Python. To add a package, activate it
|
||||
and `pip install` as usual:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
# Linux / macOS
|
||||
source Promethean/bin/activate
|
||||
pip install <package>
|
||||
```
|
||||
@@ -216,7 +270,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 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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
@@ -233,10 +287,11 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
|
||||
|
||||
## Layout
|
||||
|
||||
- `synapse/` — FastAPI backend + memory service + playbook/ollama managers
|
||||
- `synapse/` — FastAPI backend + memory curator + playbook/ollama managers
|
||||
- `modules/` — auto-discovered feature plugins
|
||||
- `interface/web/` — React + Vite frontend
|
||||
- `management/` — nexus-cli.sh, ncp API client, control panel, desktop theme
|
||||
- `nexusos_cli/` — the portable CLI the wheel ships (`nexus`/`ncp`/`nexusos`)
|
||||
- `management/` — nexus-cli.sh wrapper, control panel, desktop theme
|
||||
- `bin/` — install, backup/restore, panel + provisioning scripts
|
||||
- `assets/` — branding: icons, boot splash, XFCE/GTK theme
|
||||
- `data/playbooks/` — active playbook YAML
|
||||
@@ -248,7 +303,9 @@ 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
|
||||
`requirements-windows.txt` = standalone CPU runtime; macOS uses
|
||||
`requirements-base.txt` with no overlay (Ollama, not this venv, does
|
||||
inference — natively, with Metal)
|
||||
|
||||
## Issues and feature requests
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ X-KDE-ServiceTypes=org.kde.kwin.decoration
|
||||
[KWin]
|
||||
Type=Aurorae
|
||||
|
||||
X-KDE-PluginInfo-Author=Jon
|
||||
X-KDE-PluginInfo-Author=NexusOS
|
||||
X-KDE-PluginInfo-Email=
|
||||
X-KDE-PluginInfo-Name=NexusOS
|
||||
X-KDE-PluginInfo-Version=1.0
|
||||
|
||||
@@ -12,8 +12,10 @@ 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"
|
||||
@@ -24,13 +26,11 @@ 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,10 +80,25 @@ else
|
||||
echo " [skip] plasma-apply-desktoptheme not found — apply desktop theme manually"
|
||||
fi
|
||||
|
||||
# ── 8. kscreenlocker ─────────────────────────────────────────────────────
|
||||
# ── 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.
|
||||
if command -v kwriteconfig5 &>/dev/null; then
|
||||
kwriteconfig5 --file kscreenlockerrc --group Greeter --key Theme NexusOS
|
||||
echo " [ok] kscreenlocker theme: NexusOS"
|
||||
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
|
||||
fi
|
||||
|
||||
# ── 9. GTK apps under Plasma ─────────────────────────────────────────────
|
||||
@@ -106,7 +121,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 --group Theme --key Current NexusOS-QML
|
||||
sudo kwriteconfig5 --file /etc/sddm.conf.d/nexusos.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'
|
||||
@@ -125,14 +140,158 @@ 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
|
||||
# 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.
|
||||
if command -v kquitapp5 &>/dev/null && command -v kstart5 &>/dev/null; then
|
||||
kquitapp5 plasmashell 2>/dev/null
|
||||
sleep 1
|
||||
kstart5 plasmashell &
|
||||
setsid kstart5 plasmashell </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo " [ok] plasmashell restarted"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,9 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,268 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[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,6 +45,30 @@ 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
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[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
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
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 +1 @@
|
||||
../../../../../sddm/NexusOS-QML/assets/background.png
|
||||
Regular → Executable
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
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=Jon
|
||||
X-KDE-PluginInfo-Author=NexusOS
|
||||
X-KDE-PluginInfo-Email=
|
||||
X-KDE-PluginInfo-Name=NexusOS
|
||||
X-KDE-PluginInfo-Version=1.0
|
||||
|
||||
@@ -31,7 +31,16 @@ Rectangle {
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: "assets/background.svg"
|
||||
// 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"
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
smooth: true
|
||||
asynchronous: false
|
||||
@@ -71,7 +80,7 @@ Rectangle {
|
||||
|
||||
Text {
|
||||
id: brandLabel
|
||||
text: "NexusOS"
|
||||
text: "Welcome to NexusOS"
|
||||
color: textPrimary
|
||||
font.family: "Sans"
|
||||
font.pixelSize: 28
|
||||
|
||||
Binary file not shown.
|
After 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=Jon
|
||||
Author=NexusOS
|
||||
Version=1.0
|
||||
Website=
|
||||
Screenshot=
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"KPlugin": {
|
||||
"Authors": [{ "Name": "NexusOS" }],
|
||||
"Id": "NexusOS",
|
||||
"License": "GPL",
|
||||
"Name": "NexusOS",
|
||||
"Version": "1.0"
|
||||
},
|
||||
"X-Plasma-APIVersion": "2"
|
||||
}
|
||||
@@ -1,702 +1,120 @@
|
||||
[Icon Theme]
|
||||
Name=NexusOS
|
||||
Comment=NexusOS accent icon theme
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
[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
|
||||
|
||||
+11
-3
@@ -112,8 +112,11 @@ KDE/
|
||||
Main.qml # QML login UI — NexusOS purple/green palette
|
||||
assets/background.svg assets/logo.png
|
||||
|
||||
kscreenlocker/NexusOS/ # Runtime screen lock (Meta+L in KDE)
|
||||
contents/ui/LockScreenUi.qml # matches SDDM aesthetic; kscreenlocker API
|
||||
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)
|
||||
|
||||
konsole/
|
||||
NexusOS.colorscheme # general terminal colors
|
||||
@@ -146,7 +149,12 @@ 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, kscreenlocker.
|
||||
**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.
|
||||
|
||||
### First boot into Plasma
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 249 KiB |
@@ -74,7 +74,12 @@ 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)"
|
||||
@@ -98,7 +103,10 @@ 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"
|
||||
INH="$(grep -i '^Inherits=' "$REPO/assets/themes/NexusOS-icons/index.theme" 2>/dev/null | cut -d= -f2)"
|
||||
# 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)"
|
||||
if [[ -n "$INH" ]]; then
|
||||
INH_OK=0
|
||||
for dir in /usr/share/icons ~/.icons ~/.local/share/icons; do
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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
|
||||
@@ -0,0 +1,28 @@
|
||||
[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
|
||||
@@ -0,0 +1,9 @@
|
||||
[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
|
||||
@@ -0,0 +1,10 @@
|
||||
[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,15 +1,6 @@
|
||||
[Settings]
|
||||
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-cursor-theme-size=24
|
||||
gtk-cursor-theme-name=DMZ-White
|
||||
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,12 +1,6 @@
|
||||
[Settings]
|
||||
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-cursor-theme-size=24
|
||||
gtk-cursor-theme-name=DMZ-White
|
||||
gtk-icon-theme-name=NexusOS
|
||||
gtk-modules=colorreload-gtk-module
|
||||
gtk-primary-button-warps-slider=false
|
||||
gtk-theme-name=NexusOS
|
||||
gtk-xft-dpi=98304
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[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:///home/jon/.local/share/applications/microsoft-edge.desktop
|
||||
Launcher=file:///usr/share/applications/microsoft-edge.desktop
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[PlankDockItemPreferences]
|
||||
Launcher=file:///home/jon/.local/share/applications/nexus-activate.desktop
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.1" encoding="UTF-8"?>
|
||||
<?xml version="1.0" 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="empty"/>
|
||||
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
||||
<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="empty"/>
|
||||
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
||||
<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="empty"/>
|
||||
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
||||
<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="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
|
||||
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
||||
</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="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
|
||||
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
||||
</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="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
|
||||
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
||||
</property>
|
||||
</property>
|
||||
<property name="monitoreDP-1" type="empty">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.1" encoding="UTF-8"?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<channel name="xfce4-panel" version="1.0">
|
||||
<property name="configver" type="int" value="2"/>
|
||||
@@ -38,7 +38,14 @@
|
||||
<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"/>
|
||||
@@ -81,15 +88,11 @@
|
||||
</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"/>
|
||||
@@ -107,6 +110,7 @@
|
||||
<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">
|
||||
@@ -118,8 +122,13 @@
|
||||
<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.1" encoding="UTF-8"?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<channel name="xsettings" version="1.0">
|
||||
<property name="Net" type="empty">
|
||||
@@ -27,6 +27,8 @@
|
||||
<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"/>
|
||||
@@ -35,8 +37,6 @@
|
||||
<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"/>
|
||||
|
||||
@@ -51,6 +51,18 @@ 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.).
|
||||
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/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."
|
||||
@@ -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,5 +49,39 @@ 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"
|
||||
|
||||
+7
-16
@@ -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 (memory -> backend -> ready) via
|
||||
The loading page's status line is updated live (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,7 +24,6 @@ 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
|
||||
@@ -47,7 +46,7 @@ LOADING_HTML = """<!doctype html>
|
||||
<div class="box">
|
||||
<div class="ring"></div>
|
||||
<h1>Starting NexusOS</h1>
|
||||
<p id="status">Starting memory service...</p>
|
||||
<p id="status">Starting backend...</p>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
@@ -56,7 +55,7 @@ FAILED_HTML = LOADING_HTML.replace(
|
||||
).replace(
|
||||
"Starting NexusOS", "Backend did not start"
|
||||
).replace(
|
||||
'id="status">Starting memory service...',
|
||||
'id="status">Starting backend...',
|
||||
'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
|
||||
)
|
||||
|
||||
@@ -80,23 +79,15 @@ def _http_ok(url: str, timeout: float = 2.0) -> bool:
|
||||
|
||||
|
||||
def _wait_for_backend(win, timeout: float = 40.0) -> bool:
|
||||
"""Poll memory, then the backend, updating the on-screen status as each
|
||||
comes up - so the window never loads before the server is up (which shows
|
||||
a localhost error the webview won't retry), and never sits on one sentence
|
||||
while actually moving through two separate services."""
|
||||
"""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."""
|
||||
status_url = URL.rstrip("/") + "/status"
|
||||
deadline = time.time() + timeout
|
||||
|
||||
memory_ready = _http_ok(MEMORY_URL)
|
||||
if memory_ready:
|
||||
_set_status(win, "Starting backend...")
|
||||
_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
|
||||
|
||||
+37
-6
@@ -26,6 +26,9 @@ 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
|
||||
@@ -33,10 +36,22 @@ cp -f "$NEXUS/management/autostart/nm-tray-autostart.desktop" "$AUTOSTART/nm-tra
|
||||
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
|
||||
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.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"
|
||||
# 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"
|
||||
|
||||
# ── Register the genmon applets into the XFCE panel ────────────────────────
|
||||
# The network applet's genmon (plugin-13) was added by hand once; the Nexus
|
||||
@@ -71,8 +86,24 @@ register_genmon() {
|
||||
|
||||
register_genmon 15 before # Nexus applet, left of the network applet
|
||||
register_genmon 16 after # Bluetooth applet, right of the network applet
|
||||
echo "Reloading panel…"
|
||||
xfce4-panel -r >/dev/null 2>&1 || true
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -15,15 +15,13 @@ 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 3 ]; then ICON="$ICON_DIR/nexus-on.png"
|
||||
if [ "$up" -eq 2 ]; 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
|
||||
@@ -31,8 +29,7 @@ fi
|
||||
dot() { [ "$1" = up ] && echo "●" || echo "○"; }
|
||||
|
||||
echo "<img>$ICON</img>"
|
||||
echo "<tool>NexusOS — ${up}/3 services up
|
||||
echo "<tool>NexusOS — ${up}/2 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>"
|
||||
|
||||
@@ -34,7 +34,6 @@ APP_URL = "http://localhost:5173"
|
||||
# name, port, nexus-cli flag
|
||||
SERVICES = [
|
||||
("Synapse backend", 8000, "--backend"),
|
||||
("Memory service", 8001, "--memory"),
|
||||
("Frontend (Vite)", 5173, "--frontend"),
|
||||
]
|
||||
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/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"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
[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
|
||||
+57
-12
@@ -1,17 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# The Linux-only half of a restore: system packages, the bundled Ollama binary,
|
||||
# and the XFCE desktop wiring (panel, wallpaper, theme, terminal, branding).
|
||||
# and the desktop wiring (panel, wallpaper, theme, terminal, branding) for
|
||||
# whichever of XFCE / Plasma is installed.
|
||||
# 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 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.
|
||||
# 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.
|
||||
#
|
||||
# Not meant to be run by hand — use `ncp restore` (python bin/sync.py restore).
|
||||
|
||||
@@ -123,15 +126,28 @@ EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 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 "Not an XFCE session (no xfconf-query) — skipping desktop wiring."
|
||||
exit 0
|
||||
# --- 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.
|
||||
if ! command -v xfconf-query >/dev/null; then
|
||||
echo "No XFCE (no xfconf-query) — skipping the XFCE desktop wiring."
|
||||
else
|
||||
|
||||
# Panel layout, wallpaper, compositing, keybindings and terminal profile all live
|
||||
# in the xfconf channel XMLs — restore them by copying the files back. xfconfd
|
||||
# caches channels in memory and rewrites them on exit, so it has to die first or
|
||||
@@ -163,8 +179,26 @@ 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 ""
|
||||
@@ -196,6 +230,17 @@ 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 ""
|
||||
|
||||
+101
-11
@@ -18,7 +18,9 @@ bin/db-compare.sh could never work there.
|
||||
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
|
||||
@@ -64,9 +66,11 @@ 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, where apt,
|
||||
xfconf, plank and the rest have nothing to act on."""
|
||||
if os.name == "nt":
|
||||
"""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":
|
||||
return
|
||||
path = ROOT / "bin" / script
|
||||
bash = shutil.which("bash")
|
||||
@@ -107,6 +111,13 @@ 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")
|
||||
@@ -118,6 +129,42 @@ 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
|
||||
@@ -125,8 +172,28 @@ def dump_db() -> bool:
|
||||
if not DB.exists():
|
||||
return False
|
||||
try:
|
||||
with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn:
|
||||
DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
|
||||
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")
|
||||
except sqlite3.Error as exc:
|
||||
print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
|
||||
return False
|
||||
@@ -158,26 +225,37 @@ def _extra(a, b) -> int:
|
||||
'one box is simply ahead' from a real divergence."""
|
||||
a_conv, a_mem = a
|
||||
b_conv, b_mem = b
|
||||
newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
|
||||
# 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)
|
||||
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.
|
||||
diverged, no-live, no-dump, unreadable.
|
||||
ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
|
||||
if not dump.exists() or not dump.stat().st_size:
|
||||
return "no-dump"
|
||||
if not db.exists():
|
||||
return "no-live"
|
||||
try:
|
||||
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as live_conn:
|
||||
# closing(), not sqlite3's context manager: that one commits without
|
||||
# closing, and restore_db() unlinks the DB right after calling this -
|
||||
# Windows fails that unlink while any handle is still open.
|
||||
with contextlib.closing(sqlite3.connect(f"file:{db}?mode=ro", uri=True)) as live_conn:
|
||||
live = _state(live_conn)
|
||||
with sqlite3.connect(":memory:") as dump_conn:
|
||||
with contextlib.closing(sqlite3.connect(":memory:")) as dump_conn:
|
||||
dump_conn.executescript(dump.read_text(encoding="utf-8"))
|
||||
backup = _state(dump_conn)
|
||||
except (sqlite3.Error, OSError):
|
||||
return "diverged" # can't tell: fail safe, refuse both directions
|
||||
# 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"
|
||||
ahead, behind = _extra(live, backup), _extra(backup, live)
|
||||
if ahead and behind:
|
||||
return "diverged"
|
||||
@@ -208,6 +286,12 @@ 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")
|
||||
@@ -216,7 +300,7 @@ def restore_db() -> None:
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
Path(str(DB) + suffix).unlink(missing_ok=True)
|
||||
try:
|
||||
with sqlite3.connect(DB) as conn:
|
||||
with contextlib.closing(sqlite3.connect(DB)) as conn, conn:
|
||||
conn.executescript(DB_SQL.read_text(encoding="utf-8"))
|
||||
print("Memory DB restored (conversations + history + facts).")
|
||||
except sqlite3.Error as exc:
|
||||
@@ -318,6 +402,12 @@ 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,22 +1,78 @@
|
||||
id: 0858861d-6c42-48b9-be9f-d7e86cc45586
|
||||
title: main
|
||||
goal: You are Nexus, a helpful local AI assistant. You function as both an assistant and a friend.
|
||||
goal: You are Nexus, a helpful local AI assistant. You function as both an assistant and a friend. You work in Ponyman mode by default — least code, fewest words — but never terse about anything destructive, and never build past the ask.
|
||||
tags: []
|
||||
tools:
|
||||
- read_file
|
||||
- list_files
|
||||
- remember
|
||||
model: ''
|
||||
order: 0
|
||||
instructions: |-
|
||||
Who you are talking to:
|
||||
- Every user message comes from the person running this assistant. Talk TO them, as "you" — never about them in the third person
|
||||
- Stored facts about them are written in the third person because that is how they are saved; that is a storage detail, not how you speak
|
||||
|
||||
Your personality:
|
||||
- Warm, casual, and conversational — treat the user as a friend, not a customer
|
||||
- Confident and direct — give real answers, not hedged corporate-speak
|
||||
- Occasionally witty, but never at the expense of being helpful
|
||||
- Warmth lives in what you say, not in extra words. Short does not mean cold
|
||||
|
||||
Your responsibilities:
|
||||
- Help the user with tasks, questions, planning, research, writing, and problem solving
|
||||
- Remember context within a conversation and refer back to it naturally
|
||||
- Proactively offer suggestions or flag things the user might have missed
|
||||
|
||||
Reading your own codebase:
|
||||
- You have `read_file` and `list_files`, scoped read-only to the NexusOS repo. NexusOS is the app you are running inside, so questions about "the memory extractor", "the chat endpoint" or "your own code" mean THIS repo
|
||||
- `list_files` takes a glob relative to the repo root (`synapse/**/*.py`); `read_file` takes a repo-relative path (`synapse/memory/extractor.py`)
|
||||
- Read the file before you describe it. Never explain a file, function, or path from guesswork, and never invent one — if `list_files` does not show it, say so
|
||||
- You cannot write files, run commands, or switch playbooks. Never claim to have done any of those
|
||||
|
||||
Writing things down:
|
||||
- You have `remember`, which saves a durable fact about the user to persistent memory. It asks them to approve each save
|
||||
- Use it when they tell you to remember something, or when they state a lasting fact about themselves that is clearly worth keeping — not for passing details, moods, or today's plans
|
||||
- Save what they actually said, in one short sentence, third person. Never save a guess, an inference they did not make, or anything you said yourself
|
||||
|
||||
Rules:
|
||||
- Never refer to yourself as an AI or language model
|
||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
||||
- Never restate, echo, rephrase, or summarize the user's own message back to them. Do NOT open with a header or a recap of what they just said. React to it directly — with your own thoughts, a genuine reaction, or a question — the way a friend would in conversation
|
||||
- Keep responses concise unless the user asks for detail
|
||||
- If you don't know something, say so plainly and help find the answer
|
||||
|
||||
---
|
||||
PONYMAN MODE — always on, applies to every answer. 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 they say they still want 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.
|
||||
|
||||
If the user says "normal mode", relax the brevity and voice rules only — write at normal length. RULE 1 and RULE 2 still apply. Nothing turns them off.
|
||||
|
||||
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.
|
||||
|
||||
@@ -9,6 +9,24 @@ tags:
|
||||
- ollama
|
||||
- sqlite
|
||||
- development
|
||||
- nexus
|
||||
- synapse
|
||||
- code
|
||||
- codebase
|
||||
- repo
|
||||
- backend
|
||||
- frontend
|
||||
- playbook
|
||||
- api
|
||||
- endpoint
|
||||
- bug
|
||||
tools:
|
||||
- read_file
|
||||
- list_files
|
||||
- search_history
|
||||
- search_documents
|
||||
- list_models
|
||||
model: ''
|
||||
order: 4
|
||||
instructions: |-
|
||||
Your personality:
|
||||
@@ -20,36 +38,50 @@ instructions: |-
|
||||
- Answer questions about NexusOS with full awareness of its architecture — don't give generic FastAPI/React advice when the specific implementation matters
|
||||
- Help the user reason through feature design, debug behavior, and plan changes before writing code
|
||||
- When something could break another part of the system, flag it — the pieces are tightly coupled in places
|
||||
- Keep in mind that you cannot read the current state of files; your knowledge reflects the architecture as described here
|
||||
|
||||
Architecture overview:
|
||||
- Synapse backend: FastAPI app at synapse/main.py, port 8000. Handles chat, playbooks, memory CRUD, models, conversations, and settings
|
||||
- Memory service: separate FastAPI app at synapse/memory/service.py, port 8001. Runs an Ollama-powered extractor that decides whether to persist facts from each exchange
|
||||
Reading the codebase:
|
||||
- You have `read_file` and `list_files`. They are scoped to the NexusOS repo root and read-only
|
||||
- `list_files` takes a glob relative to the repo root (`synapse/**/*.py`, `interface/web/src/*.jsx`). Use it to confirm a path exists BEFORE quoting it — never invent a file path
|
||||
- `read_file` takes a repo-relative path (`synapse/main.py`). Read the file before describing what it does; the overview below is a map, not the current source
|
||||
- The memory database, `.git`, the venv, `node_modules` and model files are refused — that is expected, not a bug
|
||||
- You cannot write files, run commands, or switch playbooks. Which playbooks are in your context is decided per message by the backend's router, not by you — never claim to have "invoked" or "switched into" one
|
||||
|
||||
Architecture overview (verify against the files before relying on details):
|
||||
- Single process: the Synapse backend on port 8000 also serves the built web UI from interface/web/dist. There is no separate Vite server at runtime
|
||||
- Synapse backend: FastAPI app at synapse/main.py. Chat, playbooks, memory CRUD, models, conversations, documents, projects, logs, settings
|
||||
- Memory: runs IN-PROCESS, not as a service. synapse/memory/curator.py reads what a conversation added since its watermark, synapse/memory/extractor.py asks the chat model which permanent facts it contains, synapse/memory/store.py merges them. The backend schedules it when a conversation goes idle. There is no port 8001 and no second model
|
||||
- Frontend: React 19 + Vite at interface/web/. No router — App.jsx manages page state with a single currentPage useState. All API calls hit localhost:8000
|
||||
- Ollama: bundled binary at ollama/bin/ollama, managed by OllamaManager. GPU selection via vulkaninfo; prefers discrete AMD/NVIDIA. API at localhost:11434
|
||||
- Storage: single SQLite file at synapse/memory/memory.db (WAL mode). Tables: memory, conversations, messages, settings. Playbooks are YAML files, not SQLite
|
||||
- Playbooks: stored as UUID-named YAML files in synapse/playbooks/. PlaybookFileStore owns reads/writes. order=0 is the active system prompt; higher order values are injected as reference context
|
||||
- Ollama: bundled binary at ollama/bin/ollama, managed by OllamaManager. GPU selection via vulkaninfo; prefers discrete AMD/NVIDIA. API at localhost:11434. Not started with the backend — the user starts it from the sidebar or `ncp start --ai`
|
||||
- Storage: single SQLite file at synapse/memory/memory.db (WAL mode). Tables: memory, conversations, messages, message_vectors, documents, projects, settings, plus sqlite-vec virtual tables for embeddings
|
||||
- Playbooks are the exception — they are UUID-named YAML files in data/playbooks/ (PLAYBOOK_DIR), owned by PlaybookFileStore. synapse/playbooks/ is the store code, not the data
|
||||
- Playbook ordering: the FIRST playbook by order is the active system prompt; the rest are candidates for reference context
|
||||
|
||||
System prompt assembly (chat/stream endpoint):
|
||||
- Layer 1: active playbook (order=0) instructions → becomes the base system prompt
|
||||
- Layer 2: all other playbooks injected as "Reference playbooks" block below layer 1
|
||||
- Layer 3: persistent memory facts from store.all(), rendered as grouped ## Section / bullet markdown
|
||||
- Layer 4: up to 2 past conversation matches from store.search_conversations(), injected as "Relevant past exchanges"
|
||||
- Model selection: uses stored settings model if set; otherwise auto-selects by intent (code vs chat keywords), preferring qwen2.5:3b → gemma3:1b on GPU-constrained hardware (e.g. a ~4GB card)
|
||||
System prompt assembly (chat_stream_endpoint in synapse/main.py):
|
||||
- Layer 1: active playbook instructions
|
||||
- Layer 2: per-project instructions for the conversation's project scope
|
||||
- Layer 3: reference playbooks chosen per message by _route_playbooks, injected under "Reference playbooks"
|
||||
- Layer 4: persistent memory facts, filtered to global + the active project, rendered as grouped ## Section / bullet markdown
|
||||
- Layer 5: up to 2 past exchanges from store.semantic_search_conversations (embeddings, falling back to lexical), injected as "Relevant past exchanges"
|
||||
- Layer 6: matching uploaded document chunks (RAG) from store.search_documents
|
||||
- Tools: if the active playbook lists any, their schemas are advertised to Ollama. Action tools (web_search, fetch_url, remember) additionally need the allow_action_tools setting
|
||||
- Model: the stored settings model wins. Defaults live in ONE place — DEFAULT_CHAT_MODEL / DEFAULT_MEMORY_MODEL / DEFAULT_EMBED_MODEL in synapse/nexus_config.py
|
||||
|
||||
Key files:
|
||||
- synapse/main.py — all API routes, system prompt assembly, MindTrace logging, streaming SSE logic
|
||||
- synapse/memory/store.py — PersistentMemoryStore: all SQLite access for memory, conversations, messages, settings
|
||||
- synapse/memory/service.py — memory extraction microservice (port 8001)
|
||||
- synapse/memory/extractor.py — Ollama prompt that decides whether a conversation exchange yields a persistent fact
|
||||
- synapse/main.py — API routes, system prompt assembly, MindTrace logging, streaming SSE
|
||||
- synapse/chat.py — the tool-calling loop
|
||||
- synapse/tools.py — the tool registry, per-playbook allowlist, and action-tool gate
|
||||
- synapse/memory/store.py — PersistentMemoryStore: all SQLite access
|
||||
- synapse/memory/curator.py, synapse/memory/extractor.py — in-process fact extraction
|
||||
- synapse/playbooks/store.py — PlaybookFileStore: YAML read/write, ordering, search
|
||||
- synapse/playbook_manager.py — thin wrapper used by main.py to get active/reference playbooks
|
||||
- synapse/playbook_manager.py — thin wrapper main.py uses for active/reference playbooks
|
||||
- synapse/ollama_manager.py — Ollama lifecycle, GPU detection, model selection
|
||||
- synapse/nexus_config.py — all filesystem paths and the Settings class
|
||||
- interface/web/src/App.jsx — top-level page state and navigation
|
||||
- interface/web/src/Chatbot.jsx — main chat UI, SSE streaming, conversation management
|
||||
- synapse/nexus_config.py — all filesystem paths, model defaults, the Settings class
|
||||
- interface/web/src/ — App.jsx (page state), Chatbot.jsx (chat + SSE), Memory.jsx, Playbook.jsx, Projects.jsx, Models.jsx, Logs.jsx, Settings.jsx
|
||||
- bin/sync.py — cross-platform backup/restore; bin/check.sh — the release gate (pytest + eslint)
|
||||
|
||||
Rules:
|
||||
- If you don't know something or it may have changed since this playbook was written, say so plainly
|
||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
||||
- Never state a file's contents from memory when you can read it — read first, then answer
|
||||
- If a tool call fails or a path doesn't exist, say so plainly instead of guessing at what it would have contained
|
||||
- Never claim to have taken an action you cannot take
|
||||
- Never start a response with "Certainly!", "Of course!", or similar filler
|
||||
- Don't suggest generic solutions when a NexusOS-specific pattern already exists — point the user to the right place in the codebase
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# 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
|
||||
- `tui`: Textual interactive chat UI (`nexus` with no subcommand)
|
||||
- `all`: every optional capability at once
|
||||
|
||||
## Common commands
|
||||
|
||||
```text
|
||||
nexus Interactive chat TUI (needs nexusos-ai[tui])
|
||||
nexus tui Same as bare nexus
|
||||
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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,55 @@
|
||||
"""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."
|
||||
)
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# NexusOS installer, macOS. One painless command:
|
||||
#
|
||||
# git clone <repo> nexus-core && cd nexus-core && ./install-macos.sh
|
||||
#
|
||||
# Mirrors install.sh's philosophy: every portable step - git pull, venv, pip
|
||||
# with the right overlay, npm build - lives in bin/sync.py, shared with Linux
|
||||
# and Windows. This script only does what sync.py can't do for itself on a
|
||||
# bare Mac: install the Homebrew packages needed before Python even exists to
|
||||
# run sync.py with. Re-run any time to update; --check dry-runs it.
|
||||
#
|
||||
# Ollama itself is *not* fetched here - bin/fetch-ollama.sh only ships a Linux
|
||||
# x86-64 binary, and linux_stage() in bin/sync.py already no-ops on macOS, so
|
||||
# that stage is skipped entirely. The Homebrew `ollama` installed below is
|
||||
# picked up automatically instead: synapse/ollama_manager.py prefers the
|
||||
# bundled Linux binary and falls back to whatever `ollama` it finds on PATH,
|
||||
# which on macOS is this one - with full Metal GPU acceleration, no flags
|
||||
# needed. The XFCE desktop branding (theme/panel/splash) is Linux-only and
|
||||
# already gated off macOS the same way; nothing to install for it here.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v brew >/dev/null; then
|
||||
echo "Homebrew is required (it installs Python/Node/git/Ollama)." >&2
|
||||
echo "Install it, then re-run this script: https://brew.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing/checking system packages (python@3.12, node, git, ollama)..."
|
||||
brew install python@3.12 node git ollama
|
||||
|
||||
py="$(brew --prefix python@3.12)/bin/python3.12"
|
||||
if [ ! -x "$py" ]; then
|
||||
echo "python3.12 not found at $py after brew install - check 'brew doctor'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prefer the venv interpreter once it exists, same as install.sh; the brewed
|
||||
# interpreter above is only the bootstrap case on a fresh clone. sync.py is
|
||||
# stdlib-only either way.
|
||||
[ -x "Promethean/bin/python" ] && py="Promethean/bin/python"
|
||||
|
||||
exec "$py" bin/sync.py restore "$@"
|
||||
@@ -24,6 +24,10 @@ 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',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Preview documents use data: URLs. Any later navigation of that child
|
||||
browsing context is denied before a network request is sent. -->
|
||||
<meta http-equiv="Content-Security-Policy" content="frame-src data:;" />
|
||||
<link rel="icon" type="image/svg+xml" href="/n small.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NexusOS</title>
|
||||
|
||||
Generated
+125
-10
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"preact": "^10.29.8",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"sucrase": "^3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -21,6 +23,9 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"vite": "^8.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -524,7 +529,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -546,7 +550,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -556,14 +559,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -990,6 +991,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -1130,6 +1137,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1440,7 +1456,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -2012,6 +2027,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -2065,6 +2086,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
"object-assign": "^4.0.1",
|
||||
"thenify-all": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
@@ -2101,6 +2133,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -2195,7 +2236,6 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2204,6 +2244,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.21",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
|
||||
@@ -2233,6 +2282,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",
|
||||
@@ -2380,6 +2447,28 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"commander": "^4.0.0",
|
||||
"lines-and-columns": "^1.1.6",
|
||||
"mz": "^2.7.0",
|
||||
"pirates": "^4.0.1",
|
||||
"tinyglobby": "^0.2.11",
|
||||
"ts-interface-checker": "^0.1.9"
|
||||
},
|
||||
"bin": {
|
||||
"sucrase": "bin/sucrase",
|
||||
"sucrase-node": "bin/sucrase-node"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -2393,11 +2482,31 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify-all": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -2410,6 +2519,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
@@ -10,11 +10,14 @@
|
||||
"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"
|
||||
"react-dom": "^19.2.4",
|
||||
"sucrase": "^3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
+229
-50
@@ -4,8 +4,9 @@ import { Playbook } from "./Playbook";
|
||||
import { Models } from "./Models";
|
||||
import { Settings } from "./Settings";
|
||||
import { Memory } from "./Memory";
|
||||
import { Documents } from "./Documents";
|
||||
import { Projects } from "./Projects";
|
||||
import { Logs } from "./Logs";
|
||||
import { MODULES } from "./modules/registry";
|
||||
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
@@ -25,6 +26,10 @@ 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);
|
||||
@@ -47,6 +52,36 @@ 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";
|
||||
@@ -81,6 +116,26 @@ 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`)
|
||||
@@ -154,21 +209,53 @@ 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: "documents", icon: "📄", label: "Documents" },
|
||||
{ key: "projects", icon: "📁", label: "Projects" },
|
||||
{ key: "modules", icon: "📦", label: "Modules", isModulesMenu: true },
|
||||
{ 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: "220px",
|
||||
width: "400px",
|
||||
flexShrink: 0,
|
||||
background: "#0a0a0a",
|
||||
borderRight: "1px solid #333",
|
||||
@@ -189,7 +276,24 @@ 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 style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Status Indicator */}
|
||||
@@ -201,7 +305,7 @@ function App() {
|
||||
borderRadius: "50%",
|
||||
background: status === "Offline" || ollamaStatus === "unavailable" ? "#f44336"
|
||||
: ollamaStatus === "running" ? "#4caf50" : "#ff9800",
|
||||
cursor: "pointer",
|
||||
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
|
||||
transition: "all 0.3s"
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
@@ -209,7 +313,11 @@ function App() {
|
||||
setShowStatusTooltip(true);
|
||||
}}
|
||||
onMouseLeave={() => setShowStatusTooltip(false)}
|
||||
title="Hover to refresh"
|
||||
onClick={() => {
|
||||
if (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") return;
|
||||
toggleOllama();
|
||||
}}
|
||||
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Click to start/stop the AI"}
|
||||
/>
|
||||
{showStatusTooltip && (
|
||||
<div style={{
|
||||
@@ -230,58 +338,109 @@ function App() {
|
||||
Synapse: <strong>{status}</strong>
|
||||
</div>
|
||||
<div style={{ color: ollamaStatus === "unavailable" ? "#f44336" : ollamaStatus === "running" ? "#4caf50" : "#aaa" }}>
|
||||
Ollama: <strong>{ollamaStatus}</strong>
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Start/Stop AI moved onto the status dot above — click it to toggle. */}
|
||||
|
||||
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
|
||||
{navItems.map(item => {
|
||||
const isActive = currentPage === item.key;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setCurrentPage(item.key)}
|
||||
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%",
|
||||
}}
|
||||
style={buttonStyle}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
|
||||
>
|
||||
@@ -330,6 +489,22 @@ 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"
|
||||
@@ -385,8 +560,9 @@ function App() {
|
||||
marginBottom: "0.3rem",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "#1c2a3a" : (isHover ? "#161616" : "transparent"),
|
||||
border: `1px solid ${isActive ? "#2a4a6a" : "transparent"}`,
|
||||
background: isHover ? "#161616" : "transparent",
|
||||
border: "1px solid transparent",
|
||||
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
@@ -395,7 +571,8 @@ function App() {
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: "0.82rem",
|
||||
color: isActive ? "#eee" : "#ccc",
|
||||
color: isActive ? "#4aa3e0" : "#ccc",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -485,6 +662,7 @@ function App() {
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<Chatbot
|
||||
visible={currentPage === "chatbot"}
|
||||
conversationId={activeConversationId}
|
||||
setConversationId={setActiveConversationId}
|
||||
onConversationChanged={() => loadConversations(search)}
|
||||
@@ -492,7 +670,8 @@ function App() {
|
||||
</div>
|
||||
{currentPage === "playbook" && <Playbook />}
|
||||
{currentPage === "memory" && <Memory />}
|
||||
{currentPage === "documents" && <Documents />}
|
||||
{currentPage === "projects" && <Projects onOpenChat={selectConversation} onNewChat={startNewChat} />}
|
||||
{MODULES.map(m => currentPage === m.key && <m.Component key={m.key} />)}
|
||||
{currentPage === "logs" && <Logs />}
|
||||
{currentPage === "settings" && <Settings />}
|
||||
</main>
|
||||
|
||||
+117
-63
@@ -3,21 +3,22 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { API_BASE } from "./config";
|
||||
import { Markdown } from "./Markdown";
|
||||
|
||||
export function Chatbot({ conversationId, setConversationId, onConversationChanged }) {
|
||||
export function Chatbot({ visible = true, 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
|
||||
const [pendingApproval, setPendingApproval] = useState(null); // [{name, arguments}] awaiting yes/no
|
||||
const [approvalToken, setApprovalToken] = useState(null); // single-use token authorizing /chat/approve
|
||||
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
|
||||
const [editText, setEditText] = useState("");
|
||||
const [listening, setListening] = useState(false); // mic dictation active
|
||||
@@ -121,18 +122,27 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
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;
|
||||
setMessages(data?.messages?.map(m => ({ role: m.role, content: m.content })) || []);
|
||||
// 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,
|
||||
})) || []);
|
||||
})
|
||||
.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),
|
||||
@@ -144,7 +154,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
setThink(!!settings.think);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPicker) return;
|
||||
@@ -155,6 +165,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
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);
|
||||
@@ -182,8 +202,8 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
const startNewChat = () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
setInput("");
|
||||
setQueue([]);
|
||||
setLoading(false);
|
||||
setLastStats(null);
|
||||
setConversationId(crypto.randomUUID());
|
||||
};
|
||||
|
||||
@@ -258,15 +278,12 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
if (pendingEventType === "meta") {
|
||||
try {
|
||||
const stats = JSON.parse(payload);
|
||||
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;
|
||||
});
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
pendingEventType = null;
|
||||
continue;
|
||||
@@ -286,7 +303,11 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
continue;
|
||||
}
|
||||
if (pendingEventType === "tool_request") {
|
||||
try { setPendingApproval(JSON.parse(payload)); } catch { /* ignore */ }
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
setPendingApproval(parsed.actions || []);
|
||||
setApprovalToken(parsed.token || null);
|
||||
} catch { /* ignore */ }
|
||||
pendingEventType = null;
|
||||
continue;
|
||||
}
|
||||
@@ -360,14 +381,8 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
messages.slice(0, index).filter(m => m.content.trim() !== "")
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
const sendMessage = async () => {
|
||||
if ((!input.trim() && images.length === 0) || loading) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
const outImages = images.map(i => i.b64);
|
||||
setInput("");
|
||||
setImages([]);
|
||||
|
||||
// Shared by an immediate send and an auto-flushed queued message.
|
||||
const doSend = async (userMessage, outImages = []) => {
|
||||
// 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`
|
||||
@@ -378,6 +393,35 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
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");
|
||||
@@ -401,13 +445,15 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
// Approve or deny the pending action tool(s); the open chat stream resumes.
|
||||
const resolveApproval = async (approve) => {
|
||||
const req = pendingApproval || [];
|
||||
const token = approvalToken;
|
||||
setPendingApproval(null);
|
||||
setApprovalToken(null);
|
||||
const decisions = {};
|
||||
req.forEach(a => { decisions[a.name] = approve; });
|
||||
try {
|
||||
await fetch(`${API_BASE}/chat/approve`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ conversation_id: conversationId, decisions }),
|
||||
body: JSON.stringify({ conversation_id: conversationId, token, decisions }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
@@ -419,36 +465,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
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();
|
||||
sendMessage();
|
||||
if (loading) queueMessage();
|
||||
else 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",
|
||||
@@ -561,19 +587,6 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
{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}
|
||||
@@ -691,6 +704,14 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
↻ 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)}
|
||||
@@ -748,6 +769,28 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
))}
|
||||
</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" }}>
|
||||
@@ -765,8 +808,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Shift+Enter for new line)"
|
||||
disabled={loading}
|
||||
placeholder={loading ? "Keep typing — Enter queues it for after this reply..." : "Type your message... (Shift+Enter for new line)"}
|
||||
rows={3}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
@@ -777,9 +819,21 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
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}
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,435 @@ 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
|
||||
// Heartbeat: the parent's watchdog needs a message even when nothing is
|
||||
// changing, or an idle-but-alive frame reads the same as a hung one.
|
||||
setInterval(post, 1000);
|
||||
});
|
||||
})();
|
||||
</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.
|
||||
*/
|
||||
async function buildSrcDoc(lang, value) {
|
||||
const entry = PREVIEW_LANGS[lang];
|
||||
if (!entry) return { doc: null, error: `No preview for '${lang}'.` };
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await 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;
|
||||
|
||||
// A frame that never posts again — a synchronous `while(true)` in the user's
|
||||
// own script, or a runaway re-render loop the bootstrap's own coalescing
|
||||
// can't outpace — has nothing else to signal it. Silence past this long since
|
||||
// mount (or since the last message) is treated as hung and the frame is torn
|
||||
// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never
|
||||
// gets close to this.
|
||||
const _WATCHDOG_MS = 6000;
|
||||
|
||||
// Live preview for a renderable fenced block: a Preview/Code toggle rendered
|
||||
// via a sandboxed iframe whose document is an encoded data: URL.
|
||||
//
|
||||
// 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 blocks resource and script-initiated network access. The embedding
|
||||
// document's `frame-src data:` policy in index.html closes a separate CSP gap:
|
||||
// a child is otherwise allowed to navigate its own browsing context to a URL.
|
||||
// The initial data: document is allowed and inherits the parent policy, while
|
||||
// an http(s) navigation is rejected before its request is sent. Nothing here
|
||||
// substitutes for a general code-execution sandbox (Docker, WASM, etc.);
|
||||
// model-authored code runs only inside the browser's sandboxed frame.
|
||||
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 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 [doc, setDoc] = useState("");
|
||||
const [buildError, setBuildError] = useState("");
|
||||
const [height, setHeight] = useState(240);
|
||||
const [hung, setHung] = useState(false);
|
||||
const frameRef = useRef(null);
|
||||
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
|
||||
const stepsRef = useRef(0);
|
||||
const lastMsgRef = useRef(0); // set for real by the watchdog effect below
|
||||
|
||||
// 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;
|
||||
lastMsgRef.current = Date.now();
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a
|
||||
// synchronous infinite loop in the model's own script, which blocks even
|
||||
// the bootstrap's heartbeat from ever running — gets torn down rather than
|
||||
// left spinning. Checked on an interval rather than a single timeout so a
|
||||
// message arriving late (slow compile, heavy first paint) keeps resetting
|
||||
// the clock instead of tripping early.
|
||||
useEffect(() => {
|
||||
lastMsgRef.current = Date.now();
|
||||
const id = setInterval(() => {
|
||||
if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) {
|
||||
setHung(true);
|
||||
clearInterval(id);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [lang, value]);
|
||||
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setDoc("");
|
||||
setBuildError("");
|
||||
buildSrcDoc(lang, value).then((result) => {
|
||||
if (!current) return;
|
||||
setDoc(result.doc || "");
|
||||
setBuildError(result.error || "");
|
||||
});
|
||||
return () => { current = false; };
|
||||
}, [lang, value]);
|
||||
|
||||
// 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. A hung
|
||||
// frame tears down the same way: dropping frameUrl unmounts the iframe,
|
||||
// which is what actually stops a runaway script from holding the tab.
|
||||
const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
||||
const shown = hung
|
||||
? "Preview stopped responding (likely an infinite loop) and was stopped."
|
||||
: buildError || error;
|
||||
|
||||
return (
|
||||
<>
|
||||
{frameUrl && (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title="rendered output"
|
||||
sandbox="allow-scripts"
|
||||
src={frameUrl}
|
||||
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 = [];
|
||||
|
||||
@@ -44,6 +44,7 @@ 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 {
|
||||
@@ -57,6 +58,15 @@ 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) => {
|
||||
@@ -386,7 +396,16 @@ 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}</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>
|
||||
<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}
|
||||
|
||||
@@ -20,6 +20,8 @@ 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;
|
||||
@@ -224,8 +226,12 @@ 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 => m.name.toLowerCase())),
|
||||
() => new Set(allInstalled.map(m => withTag(m.name))),
|
||||
[allInstalled]
|
||||
);
|
||||
|
||||
@@ -237,7 +243,7 @@ export function Models({ onPullStateChange }) {
|
||||
() => (recommended?.models ?? []).filter(m => !m.required),
|
||||
[recommended]
|
||||
);
|
||||
const requiredMissing = requiredModels.filter(m => !installedNames.has(m.name.toLowerCase()));
|
||||
const requiredMissing = requiredModels.filter(m => !installedNames.has(withTag(m.name)));
|
||||
const requiredSatisfied = recommended != null && requiredMissing.length === 0;
|
||||
|
||||
return (
|
||||
@@ -351,7 +357,7 @@ export function Models({ onPullStateChange }) {
|
||||
<ModelCard
|
||||
key={m.name}
|
||||
model={m}
|
||||
installed={installedNames.has(m.name.toLowerCase())}
|
||||
installed={installedNames.has(withTag(m.name))}
|
||||
pulling={pulling}
|
||||
pullingName={pullingName}
|
||||
locked={false}
|
||||
@@ -372,7 +378,7 @@ export function Models({ onPullStateChange }) {
|
||||
<ModelCard
|
||||
key={m.name}
|
||||
model={m}
|
||||
installed={installedNames.has(m.name.toLowerCase())}
|
||||
installed={installedNames.has(withTag(m.name))}
|
||||
pulling={pulling}
|
||||
pullingName={pullingName}
|
||||
locked={!requiredSatisfied}
|
||||
|
||||
@@ -29,7 +29,6 @@ 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));
|
||||
@@ -360,7 +359,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" }}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,159 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,10 @@
|
||||
// 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));
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* JSX/TSX compiler adapter.
|
||||
*
|
||||
* JSX and TypeScript are parsed by Sucrase rather than by preview-specific
|
||||
* lexer code. The dependency is dynamically imported so ordinary chat and
|
||||
* HTML/SVG previews do not download the compiler chunk. Only this small adapter
|
||||
* stays in the main bundle.
|
||||
*
|
||||
* Sucrase's CommonJS transform is intentional: a preview frame has no module
|
||||
* loader or network access, but languages.js can provide local React/Preact
|
||||
* modules through a tiny `require` shim. Unsupported imports then fail loudly
|
||||
* at evaluation time with the package name that cannot be loaded.
|
||||
*/
|
||||
|
||||
export class TransformError extends Error {
|
||||
constructor(message, options) {
|
||||
super(message, options);
|
||||
this.name = "TransformError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find fallback component declarations for model output that omits an export.
|
||||
*
|
||||
* This is deliberately not syntax transformation. Sucrase owns all parsing;
|
||||
* these names only form guarded `typeof Name !== "undefined"` mount choices.
|
||||
* A false match is therefore ignored at runtime. Default exports and App take
|
||||
* precedence, so this compatibility fallback is used only for a bare component
|
||||
* such as `function Counter() { ... }`.
|
||||
*/
|
||||
function componentCandidates(source) {
|
||||
const names = [];
|
||||
const declarations = /\b(?:function|class|const|let|var)\s+([A-Z][$\w]*)/g;
|
||||
for (const match of source.matchAll(declarations)) {
|
||||
if (!names.includes(match[1])) names.push(match[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Compile a self-contained JSX/TSX component into browser-ready CommonJS. */
|
||||
export async function transform(source) {
|
||||
const input = String(source ?? "");
|
||||
|
||||
try {
|
||||
const { transform: compile } = await import("sucrase");
|
||||
const { code } = compile(input, {
|
||||
transforms: ["typescript", "jsx", "imports"],
|
||||
jsxPragma: "h",
|
||||
jsxFragmentPragma: "Fragment",
|
||||
production: true,
|
||||
filePath: "preview.tsx",
|
||||
});
|
||||
return { code, components: componentCandidates(input) };
|
||||
} catch (error) {
|
||||
const detail = error && error.message ? error.message : String(error);
|
||||
throw new TransformError(`Could not compile JSX/TSX: ${detail}`, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { transform, TransformError } from "./jsx-transform.js";
|
||||
|
||||
async function compile(source) {
|
||||
return transform(source);
|
||||
}
|
||||
|
||||
function assertRunnable(code) {
|
||||
assert.doesNotThrow(() => new Function(
|
||||
"module", "exports", "require", "h", "Fragment", code,
|
||||
));
|
||||
}
|
||||
|
||||
test("compiles elements, attributes, spreads, children, and fragments", async () => {
|
||||
const { code } = await compile(`
|
||||
const view = <>
|
||||
<section {...props} data-id="7">
|
||||
<button disabled onClick={() => go()}>go {name}</button>
|
||||
</section>
|
||||
</>;
|
||||
`);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /h\(Fragment/);
|
||||
assert.match(code, /h\('section'/);
|
||||
assert.doesNotMatch(code, /<section/);
|
||||
});
|
||||
|
||||
test("compiles nested JSX inside expression children", async () => {
|
||||
const { code } = await compile(
|
||||
"const view = <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;",
|
||||
);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /items\.map/);
|
||||
assert.doesNotMatch(code, /<li/);
|
||||
});
|
||||
|
||||
test("does not confuse comparisons with JSX", async () => {
|
||||
const { code } = await compile(
|
||||
"if (xs[0] < 3 && f(i) < n) { const less = a < b; }",
|
||||
);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /xs\[0\] < 3/);
|
||||
assert.match(code, /a < b/);
|
||||
});
|
||||
|
||||
test("does not confuse division with a regular expression", async () => {
|
||||
const { code } = await compile(
|
||||
"const y = Math.sin((i + s) / 6) * 70; const m = xs[0] / total;",
|
||||
);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /\(i \+ s\) \/ 6/);
|
||||
assert.match(code, /xs\[0\] \/ total/);
|
||||
});
|
||||
|
||||
test("preserves angle brackets and slashes in literals", async () => {
|
||||
const { code } = await compile(
|
||||
'const s = "<div>not jsx</div>"; const t = `a <b> c`; const r = /<[a-z]+>/g;',
|
||||
);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /not jsx/);
|
||||
assert.match(code, /\/<\[a-z\]\+>\/g/);
|
||||
});
|
||||
|
||||
test("strips TypeScript annotations, declarations, generics, and assertions", async () => {
|
||||
const { code } = await compile(`
|
||||
interface Props { start: number }
|
||||
type Pair = [number, number];
|
||||
function f({ start }: Props, pair: Pair): number {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
return (pair[0] as number) + ref.current!.width + start;
|
||||
}
|
||||
`);
|
||||
assertRunnable(code);
|
||||
assert.doesNotMatch(code, /interface Props|type Pair|: Props|HTMLCanvasElement|as number|current!/);
|
||||
});
|
||||
|
||||
test("keeps object literals, destructuring, and ternaries intact", async () => {
|
||||
const { code } = await compile(
|
||||
"const f = ({a, b}: Props) => ok ? {value: a} : {value: b};",
|
||||
);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /ok \? \{value: a\} : \{value: b\}/);
|
||||
});
|
||||
|
||||
test("handles TSX generic arrow functions without treating them as elements", async () => {
|
||||
const { code } = await compile(
|
||||
"const identity = <T,>(value: T): T => value; const view = <p>{identity(3)}</p>;",
|
||||
);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /identity = \s*\(value\) => value/);
|
||||
});
|
||||
|
||||
test("converts imports and exports to CommonJS for the frame shim", async () => {
|
||||
const { code } = await compile(`
|
||||
import React, { useState } from "react";
|
||||
export default function App() { const [n] = useState(0); return <p>{n}</p>; }
|
||||
`);
|
||||
assertRunnable(code);
|
||||
assert.match(code, /require\(['"]react['"]\)/);
|
||||
assert.match(code, /exports\.default = App/);
|
||||
assert.doesNotMatch(code, /export default|<p>/);
|
||||
});
|
||||
|
||||
test("keeps unsupported package names in generated require calls", async () => {
|
||||
const { code } = await compile(
|
||||
'import { motion } from "framer-motion"; export default () => <motion.div />;',
|
||||
);
|
||||
assert.match(code, /require\(['"]framer-motion['"]\)/);
|
||||
});
|
||||
|
||||
test("records fallback component declarations without choosing a mount target", async () => {
|
||||
const result = await compile(`
|
||||
function Helper() { return null; }
|
||||
const Counter = () => <button>count</button>;
|
||||
`);
|
||||
assert.deepEqual(result.components, ["Helper", "Counter"]);
|
||||
});
|
||||
|
||||
test("compiles a realistic stateful component end to end", async () => {
|
||||
const result = await compile(`
|
||||
import { useState } from "react";
|
||||
interface Props { start: number }
|
||||
export default function Counter({ start }: Props) {
|
||||
const [n, setN] = useState<number>(start);
|
||||
return <button onClick={() => setN(n + 1)}>{n} clicks</button>;
|
||||
}
|
||||
`);
|
||||
assertRunnable(result.code);
|
||||
assert.match(result.code, /function Counter\(\{ start \}\)/);
|
||||
assert.match(result.code, /useState\(start\)/);
|
||||
assert.doesNotMatch(result.code, /interface|: Props|<number>|<button/);
|
||||
});
|
||||
|
||||
test("reports malformed JSX as a TransformError", async () => {
|
||||
await assert.rejects(
|
||||
() => compile("const view = <div>\n<span>x</div>;"),
|
||||
(error) => error instanceof TransformError && /compile JSX\/TSX/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test("reports malformed TypeScript as a TransformError", async () => {
|
||||
await assert.rejects(
|
||||
() => compile("interface Props { value: string"),
|
||||
TransformError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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:
|
||||
*
|
||||
* await 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)
|
||||
* for tool descriptions and language tags. 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 });
|
||||
|
||||
/**
|
||||
* Build the mount expression. 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 mountExpression(components) {
|
||||
const names = ["App", ...components.slice().reverse()]
|
||||
.filter((name, index, all) => all.indexOf(name) === index);
|
||||
const lexical = names.map(
|
||||
(name) => `(typeof ${name} !== "undefined" ? ${name} : null)`,
|
||||
);
|
||||
return [
|
||||
"module.exports.default",
|
||||
"module.exports.App",
|
||||
...lexical,
|
||||
"Object.values(module.exports).find((value) => typeof value === 'function')",
|
||||
].join(" || ");
|
||||
}
|
||||
|
||||
async function jsxBody(value) {
|
||||
const result = await transform(value);
|
||||
const target = mountExpression(result.components);
|
||||
|
||||
const head =
|
||||
'<div id="root"></div>\n' +
|
||||
`<script>${PREACT_RUNTIME}</script>\n` +
|
||||
"<script>\n" +
|
||||
"const module = { exports: {} }; const exports = module.exports;\n" +
|
||||
"const require = (name) => {\n" +
|
||||
" const modules = { react: React, 'react-dom': ReactDOM, preact, 'preact/hooks': preactHooks };\n" +
|
||||
" if (Object.prototype.hasOwnProperty.call(modules, name)) return modules[name];\n" +
|
||||
" throw new Error(`Cannot import '${name}' — the preview has no module loader or network.`);\n" +
|
||||
"};\n";
|
||||
|
||||
return {
|
||||
html:
|
||||
head +
|
||||
result.code +
|
||||
`\n;const __NexusComponent = ${target};\n` +
|
||||
"if (!__NexusComponent) throw new Error(" +
|
||||
"'No component found to render. Name one `App`, or `export default` it.');\n" +
|
||||
"const __NexusView = typeof __NexusComponent === 'function' " +
|
||||
"? h(__NexusComponent, null) : __NexusComponent;\n" +
|
||||
"render(__NexusView, 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));
|
||||
@@ -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}`;
|
||||
+4
-9
@@ -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 memory service + backend, opening a native app window
|
||||
# this starts only the 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,19 +40,15 @@ 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 memory/backend still
|
||||
# profile cold-start - IMMEDIATELY, in parallel with the backend still
|
||||
# coming up. bin\nexus_window.py opens on its own loading page and polls
|
||||
# :8001 then :8000 itself, updating that page's status line live as each
|
||||
# :8000 itself, updating that page's status line live as it
|
||||
# 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
|
||||
@@ -67,10 +63,9 @@ 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, $memory)) {
|
||||
foreach ($p in @($backend)) {
|
||||
if ($p -and -not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
+6
-7
@@ -1,4 +1,9 @@
|
||||
#!/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"
|
||||
@@ -6,7 +11,6 @@ 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"
|
||||
|
||||
@@ -29,15 +33,10 @@ _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 0.0.0.0 --port 8000 --reload)
|
||||
"$PYTHON" -m uvicorn synapse.main:sio_app --host "$BIND_HOST" --port 8000 --reload)
|
||||
|
||||
sleep 3
|
||||
_check "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG"
|
||||
_check "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG"
|
||||
|
||||
# Frontend last
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""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.
|
||||
"""
|
||||
@@ -0,0 +1,9 @@
|
||||
[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
|
||||
@@ -0,0 +1,6 @@
|
||||
[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
|
||||
+15
-89
@@ -1,4 +1,3 @@
|
||||
# /home/jon/nexus-core/management/controlpanel.py
|
||||
|
||||
import time
|
||||
import tkinter as tk
|
||||
@@ -20,7 +19,9 @@ try:
|
||||
except Exception:
|
||||
VERSION = "0.0.0"
|
||||
|
||||
# Force 0.0.0.0 to ensure the Windows Host bridge works for Project Nexus
|
||||
# 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")
|
||||
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
|
||||
def _find_npm():
|
||||
"""Prefer nvm's newest node. When the panel is launched from the desktop
|
||||
@@ -51,28 +52,15 @@ BACKEND_CMD = [
|
||||
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
|
||||
"-m", "uvicorn",
|
||||
"synapse.main:sio_app",
|
||||
"--host", "0.0.0.0",
|
||||
"--host", BIND_HOST,
|
||||
"--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:
|
||||
@@ -109,10 +97,8 @@ 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()
|
||||
@@ -132,9 +118,6 @@ 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()
|
||||
@@ -167,12 +150,11 @@ class NexusControlPanel:
|
||||
|
||||
|
||||
def _init_log_tags(self):
|
||||
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console, self.mem_console]:
|
||||
for console in [self.sys_console, self.front_console, self.back_console, self.mind_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")
|
||||
@@ -193,7 +175,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.mem_console, self.mind_console]:
|
||||
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console]:
|
||||
console.delete("1.0", tk.END)
|
||||
|
||||
def _copy_selection(self, widget):
|
||||
@@ -260,19 +242,13 @@ 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(4):
|
||||
for col in range(3):
|
||||
bottom_f.columnconfigure(col, weight=1, uniform="term")
|
||||
bottom_f.rowconfigure(0, weight=1)
|
||||
|
||||
@@ -280,10 +256,8 @@ 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=3, sticky="nsew", padx=(4, 0))
|
||||
m_f.grid(row=0, column=2, sticky="nsew", padx=(4, 0))
|
||||
|
||||
def _safe_after(self, delay, func):
|
||||
"""Schedule a Tk callback without ever raising.
|
||||
@@ -305,7 +279,6 @@ 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)
|
||||
@@ -358,16 +331,14 @@ class NexusControlPanel:
|
||||
def update_master_ui(self):
|
||||
if self._closing: # window may be destroyed; stop_* still calls this
|
||||
return
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
@@ -426,10 +397,6 @@ 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
|
||||
@@ -472,10 +439,6 @@ 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
|
||||
@@ -576,47 +539,13 @@ 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", 8001: "MEMORY", 5173: "INTERFACE"}
|
||||
ports = {8000: "SYNAPSE", 5173: "INTERFACE"}
|
||||
for port, name in ports.items():
|
||||
try:
|
||||
r = subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
|
||||
@@ -628,21 +557,18 @@ class NexusControlPanel:
|
||||
subprocess.run(["pkill", "-9", "-f", pat], capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
for pid_file in [FRONTEND_PID, BACKEND_PID, MEMORY_PID]:
|
||||
for pid_file in [FRONTEND_PID, BACKEND_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:
|
||||
@@ -675,13 +601,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, self.memory_process):
|
||||
for proc in (self.frontend_process, self.backend_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, MEMORY_PID):
|
||||
for pf in (FRONTEND_PID, BACKEND_PID):
|
||||
if self._is_running(pf):
|
||||
self._terminate_pid_file(pf)
|
||||
else:
|
||||
|
||||
+7
-4
@@ -12,17 +12,20 @@ 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 ncp.py pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
|
||||
REM The CLI 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 ncp.py with ctypes SetConsoleOutputCP instead.
|
||||
REM from inside the CLI 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"
|
||||
"%PY%" "%ROOT%\management\ncp.py" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
pushd "%ROOT%"
|
||||
"%PY%" -m nexusos_cli.cli %*
|
||||
set "NEXUS_EXIT=%ERRORLEVEL%"
|
||||
popd
|
||||
exit /b %NEXUS_EXIT%
|
||||
|
||||
@@ -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 memory service + backend. The AI (Ollama) does NOT auto-start
|
||||
# only starts the 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: memory + backend only. Backend serves the built UI at
|
||||
# Single-process app: 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# ncp - Linux entry point. The CLI itself is management/ncp.py, which runs
|
||||
# ncp - Linux entry point. The CLI itself is nexusos_cli/cli.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,9 +8,10 @@
|
||||
# directory drives itself instead of reaching into the real install.
|
||||
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
PY="$NEXUS_ROOT/Promethean/bin/python3"
|
||||
[ -x "$PY" ] || PY=python3
|
||||
|
||||
exec "$PY" "$NEXUS_ROOT/management/ncp.py" "$@"
|
||||
cd "$NEXUS_ROOT"
|
||||
exec "$PY" -m nexusos_cli.cli "$@"
|
||||
|
||||
@@ -6,7 +6,15 @@ 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
|
||||
from controlpanel import NexusControlPanel
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _fake(closing, after_raises):
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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"}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""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()]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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)}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""The portable NexusOS command line, shipped in the wheel.
|
||||
|
||||
Kept out of `management/` so the installed distribution does not claim a
|
||||
top-level `management` package name in site-packages. `management/` stays in
|
||||
the source checkout for the desktop-only pieces - the Tk control panel, the
|
||||
shell wrappers, the XFCE panel and .desktop wiring.
|
||||
"""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user