forked from enderofwings/NexusOS
Merge origin/main (v1.2.0: Projects, modules, in-app updates)
Reconciles 17 commits of this session's work (self-alteration tools, vendored Curry, slash-command dispatch, Windows toolchain/gate fixes) against origin/main's v1.2.0 sync (Projects/RAG scoping, a new modules/ system for mail and network, in-app updates, the standalone memory microservice folded into an in-process curator, KDE desktop theme overhaul). Nine real conflicts, each resolved by hand after reading both sides' actual diffs rather than picking one side wholesale: - synapse/tools.py, tests/test_tools.py: origin/main's diff here was small and clean (read_file/list_files, two new tests) despite git's diff3 flagging the whole file as one conflict blob -- reset to this branch's version and hand-spliced their addition in at the same points they used, rather than trying to reconcile a false 800-line conflict. Found and fixed a real bug while verifying: _list_files returned backslash-separated paths on Windows, which don't match the forward-slash glob patterns the tool's own schema documents. - synapse/main.py: kept this branch's cue-based standing advertisement of render_preview/run_snippet (independent of any playbook granting them) AND adopted origin/main's fix for routed reference playbooks not bringing their own tools along -- dropping either would have been a real regression, not just a style difference. Also: the standalone memory service (port 8001) is gone upstream, so its dead CORS/kill- target entries were removed; NEXUS_BACKEND_PORT parameterization and the manage_ollama-conditional kill logic (this branch's remote-Ollama support) were kept over origin/main's hardcoded equivalents. - synapse/memory/store.py: kept this branch's _delete_message_vectors helper (already reused elsewhere, batches to stay under SQLite's variable limit) over origin/main's inline duplicate of the same fix. - synapse/nexus_config.py, nexusos_cli/ncp.py: dropped the now-dead memory-service port/service entries; kept NEXUS_BACKEND_PORT env override and the manage_ollama-conditional kill-target list. - CLAUDE.md, README.md: merged both sides' additions, no real conflict. Found and fixed three more issues while independently verifying the merged tree, none of them mine or origin/main's alone -- only visible once both sides actually ran together: - modules/ (the new mail+network package) was never added to pyproject.toml's wheel `packages` list OR the sdist's `include` allowlist, so `from modules.registry import ROUTERS` in main.py would ImportError on any wheel install. Fixed both; bin/check.sh's packaging gate now asserts modules/ actually ships. tests/ test_packaging_deps.py's FIRST_PARTY/SHIPPED_PACKAGES sets were updated to recognize the new package. - tests/test_mail_creds.py's 0600-mode assertions are POSIX-only -- NTFS has no equivalent permission bits, so os.open(path, 0o600) on Windows just creates a normal file and stat.S_IMODE reports 0o666 regardless. Made the assertions platform-aware rather than skip real coverage (the temp-file-cleanup and password round-trip checks in the same test still run on Windows) or paper over a genuine OS limitation with a fake pass. - tests/test_kde_theme.py used bare Path.read_text() in fifteen places; Windows' default locale encoding (cp1252, not UTF-8) can't decode a real UTF-8 byte in the QML it reads, and did fail on one of the fifteen. Fixed all fifteen, not just the one that happened to trip today, since the other fourteen were equally fragile. Verified: full bin/check.sh reports OK end-to-end on this Windows checkout -- pytest (tests + management): 295 passed, 0 failed, 9 skipped; eslint clean; frontend node:test 57/57; PowerShell/shell parse clean; wheel + sdist pass twine check and now correctly carry modules/ (60 files, up from 52 pre-merge). synapse.main:app builds with 74 routes (up from 54 pre-merge, matching the new Projects/mail/ network endpoints).
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -10,20 +10,32 @@ 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
|
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
|
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
|
at runtime. Ollama is not started on backend process startup itself, but `ncp
|
||||||
start --ai`), not on backend startup.
|
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):**
|
**Windows (recommended):**
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File .\install-windows.ps1 # one-time native install
|
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):**
|
**Linux full stack (dev):**
|
||||||
```bash
|
```bash
|
||||||
./launch_nexus.sh
|
./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):**
|
**macOS (community-supported):**
|
||||||
```bash
|
```bash
|
||||||
@@ -42,10 +54,7 @@ absent; that gets full Metal GPU acceleration with no extra config.
|
|||||||
source Promethean/bin/activate
|
source Promethean/bin/activate
|
||||||
|
|
||||||
# Backend (serves the built UI at :8000 too)
|
# Backend (serves the built UI at :8000 too)
|
||||||
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload
|
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload
|
||||||
|
|
||||||
# Memory service
|
|
||||||
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload
|
|
||||||
|
|
||||||
# Frontend DEV server (hot-reload) — only when editing the UI; production is the
|
# 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/.
|
# built dist/ served by the backend. Run `npm run build` to refresh dist/.
|
||||||
@@ -97,13 +106,15 @@ cd interface/web && npm run build
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Python venv
|
### Python venv
|
||||||
All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core, 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, CPU/Windows, or — via an explicit `sys.platform == "darwin"` check, since `os.name` alone can't tell macOS apart from Linux — `requirements-base.txt` with no overlay at all for macOS, 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). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, CPU/Windows, or — via an explicit `sys.platform == "darwin"` check, since `os.name` alone can't tell macOS apart from Linux — `requirements-base.txt` with no overlay at all for macOS, from 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/`)
|
### Synapse Backend (`synapse/`)
|
||||||
FastAPI app at `synapse/main.py`. Key responsibilities:
|
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`.
|
- `/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.
|
- `/models` — lists, pulls, and deletes Ollama models by proxying Ollama's HTTP API.
|
||||||
- `/settings` and `/ollama` — persist runtime settings and control Ollama lifecycle.
|
- `/settings` and `/ollama` — persist runtime settings and control Ollama lifecycle.
|
||||||
- `/conversations` — persists, retrieves, edits, deletes, and exports full chat history from SQLite.
|
- `/conversations` — persists, retrieves, edits, deletes, and exports full chat history from SQLite.
|
||||||
@@ -111,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`.
|
**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/`)
|
### Memory (`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`).
|
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`)
|
### 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.
|
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.
|
||||||
@@ -171,3 +182,5 @@ A chat message that's nothing but `/tool_name(arg=val, ...)` (Python-call-shaped
|
|||||||
| Python dependencies (AMD/ROCm) | `requirements-amd.txt` |
|
| Python dependencies (AMD/ROCm) | `requirements-amd.txt` |
|
||||||
| Python dependencies (NVIDIA/CUDA) | `requirements-nvidia.txt` (generated by `bin/gen-nvidia-reqs.py`) |
|
| Python dependencies (NVIDIA/CUDA) | `requirements-nvidia.txt` (generated by `bin/gen-nvidia-reqs.py`) |
|
||||||
| Python dependencies (Windows/CPU) | `requirements-windows.txt` |
|
| Python dependencies (Windows/CPU) | `requirements-windows.txt` |
|
||||||
|
| Python dependencies (optional local ML/torch) | `requirements-ml.txt` (opt-in, not installed by default) |
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
# NexusOS
|
# NexusOS
|
||||||
|
|
||||||
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
||||||
Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
|
Python/FastAPI backend, a bundled Ollama instance for inference, persistent
|
||||||
persistent memory service, and a React frontend. Ollama is local by default;
|
memory, and a React frontend. No external AI provider is called. Ollama is
|
||||||
Termux and container installs can explicitly point at a separately managed
|
local by default; Termux and container installs can explicitly point at a
|
||||||
endpoint.
|
separately managed endpoint.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ Linux distro, but I only support the Ubuntu/Linux Mint package base.
|
|||||||
# system packages, Ollama binary and the XFCE desktop wiring.
|
# system packages, Ollama binary and the XFCE desktop wiring.
|
||||||
./install.sh
|
./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.
|
# The install symlinks ncp into /usr/local/bin (sudo); open a new shell first.
|
||||||
ncp web
|
ncp web
|
||||||
```
|
```
|
||||||
@@ -207,8 +207,7 @@ wrapper over `bin/sync.py restore`, the same code Linux and `ncp restore`
|
|||||||
# Linux / macOS
|
# Linux / macOS
|
||||||
source Promethean/bin/activate
|
source Promethean/bin/activate
|
||||||
|
|
||||||
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload # backend (serves the UI too)
|
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
|
||||||
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload # memory
|
|
||||||
|
|
||||||
# Frontend dev server (hot-reload) — only needed when editing the UI;
|
# Frontend dev server (hot-reload) — only needed when editing the UI;
|
||||||
# production serves the built dist/ from the backend at :8000.
|
# production serves the built dist/ from the backend at :8000.
|
||||||
@@ -219,8 +218,7 @@ cd interface/web && npm run dev
|
|||||||
# Windows — needs the RemoteSigned policy from the Quick start step above
|
# Windows — needs the RemoteSigned policy from the Quick start step above
|
||||||
Promethean\Scripts\Activate.ps1
|
Promethean\Scripts\Activate.ps1
|
||||||
|
|
||||||
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload # backend (serves the UI too)
|
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
|
||||||
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload # memory
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Promethean
|
### Promethean
|
||||||
@@ -272,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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **Playbooks** | `synapse/playbooks/` + `data/playbooks/` | Ordered `{id}.yaml` records managed by `PlaybookManager`; each can pin a chat model and a tool list. |
|
||||||
@@ -289,7 +287,7 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `synapse/` — FastAPI backend + memory service + playbook/ollama managers
|
- `synapse/` — FastAPI backend + memory curator + playbook/ollama managers
|
||||||
- `modules/` — auto-discovered feature plugins
|
- `modules/` — auto-discovered feature plugins
|
||||||
- `interface/web/` — React + Vite frontend
|
- `interface/web/` — React + Vite frontend
|
||||||
- `nexusos_cli/` — the portable CLI the wheel ships (`nexus`/`ncp`/`nexusos`)
|
- `nexusos_cli/` — the portable CLI the wheel ships (`nexus`/`ncp`/`nexusos`)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ X-KDE-ServiceTypes=org.kde.kwin.decoration
|
|||||||
[KWin]
|
[KWin]
|
||||||
Type=Aurorae
|
Type=Aurorae
|
||||||
|
|
||||||
X-KDE-PluginInfo-Author=Jon
|
X-KDE-PluginInfo-Author=NexusOS
|
||||||
X-KDE-PluginInfo-Email=
|
X-KDE-PluginInfo-Email=
|
||||||
X-KDE-PluginInfo-Name=NexusOS
|
X-KDE-PluginInfo-Name=NexusOS
|
||||||
X-KDE-PluginInfo-Version=1.0
|
X-KDE-PluginInfo-Version=1.0
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ KDE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
REPO="$(cd "$KDE/../../.." && pwd)"
|
REPO="$(cd "$KDE/../../.." && pwd)"
|
||||||
|
|
||||||
NO_SDDM=0
|
NO_SDDM=0
|
||||||
|
FORCE_PANEL=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
[[ "$arg" == "--no-sddm" ]] && NO_SDDM=1
|
[[ "$arg" == "--no-sddm" ]] && NO_SDDM=1
|
||||||
|
[[ "$arg" == "--panel" ]] && FORCE_PANEL=1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "NexusOS KDE installer — repo: $REPO"
|
echo "NexusOS KDE installer — repo: $REPO"
|
||||||
@@ -24,13 +26,11 @@ mkdir -p \
|
|||||||
~/.local/share/plasma/desktoptheme \
|
~/.local/share/plasma/desktoptheme \
|
||||||
~/.local/share/color-schemes \
|
~/.local/share/color-schemes \
|
||||||
~/.local/share/konsole \
|
~/.local/share/konsole \
|
||||||
~/.local/share/kscreenlocker/themes \
|
|
||||||
~/.config/Kvantum
|
~/.config/Kvantum
|
||||||
|
|
||||||
# ── 2. Symlink theme directories (update automatically on git pull) ──────
|
# ── 2. Symlink theme directories (update automatically on git pull) ──────
|
||||||
ln -sfn "$KDE/aurorae/NexusOS" ~/.local/share/aurorae/themes/NexusOS
|
ln -sfn "$KDE/aurorae/NexusOS" ~/.local/share/aurorae/themes/NexusOS
|
||||||
ln -sfn "$KDE/plasma/NexusOS" ~/.local/share/plasma/desktoptheme/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
|
ln -sfn "$KDE/kvantum/NexusOS" ~/.config/Kvantum/NexusOS
|
||||||
|
|
||||||
echo " [ok] theme symlinks"
|
echo " [ok] theme symlinks"
|
||||||
@@ -80,10 +80,25 @@ else
|
|||||||
echo " [skip] plasma-apply-desktoptheme not found — apply desktop theme manually"
|
echo " [skip] plasma-apply-desktoptheme not found — apply desktop theme manually"
|
||||||
fi
|
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
|
if command -v kwriteconfig5 &>/dev/null; then
|
||||||
kwriteconfig5 --file kscreenlockerrc --group Greeter --key Theme NexusOS
|
kwriteconfig5 --file kscreenlockerrc --group Greeter --key Theme org.kde.breeze.desktop
|
||||||
echo " [ok] kscreenlocker theme: NexusOS"
|
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
|
fi
|
||||||
|
|
||||||
# ── 9. GTK apps under Plasma ─────────────────────────────────────────────
|
# ── 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 -r "$KDE/sddm/NexusOS-QML" /usr/share/sddm/themes/
|
||||||
sudo cp "$REPO/management/sessions/nexusos-kde.desktop" /usr/share/xsessions/
|
sudo cp "$REPO/management/sessions/nexusos-kde.desktop" /usr/share/xsessions/
|
||||||
if command -v kwriteconfig5 &>/dev/null; then
|
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
|
else
|
||||||
# Fallback: write the INI directly
|
# Fallback: write the INI directly
|
||||||
sudo bash -c 'printf "[Theme]\nCurrent=NexusOS-QML\n" > /etc/sddm.conf.d/nexusos.conf'
|
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"
|
echo " [ok] Promethean Terminal launcher updated"
|
||||||
fi
|
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) ────────────────────────
|
# ── 12. Apply live changes (if Plasma is running) ────────────────────────
|
||||||
if qdbus org.kde.KWin /KWin reconfigure 2>/dev/null; then
|
if qdbus org.kde.KWin /KWin reconfigure 2>/dev/null; then
|
||||||
echo " [ok] KWin reconfigured"
|
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
|
if command -v kquitapp5 &>/dev/null && command -v kstart5 &>/dev/null; then
|
||||||
kquitapp5 plasmashell 2>/dev/null
|
kquitapp5 plasmashell 2>/dev/null
|
||||||
sleep 1
|
sleep 1
|
||||||
kstart5 plasmashell &
|
setsid kstart5 plasmashell </dev/null >/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
echo " [ok] plasmashell restarted"
|
echo " [ok] plasmashell restarted"
|
||||||
fi
|
fi
|
||||||
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
|
button.icon.size=16
|
||||||
toolbar.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]
|
[PanelButtonCommand]
|
||||||
inherits=PanelButtonTool
|
inherits=PanelButtonTool
|
||||||
frame=false
|
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]
|
[KPackageStructure]
|
||||||
X-KDE-ServiceTypes=Plasma/Theme
|
X-KDE-ServiceTypes=Plasma/Theme
|
||||||
|
|
||||||
X-KDE-PluginInfo-Author=Jon
|
X-KDE-PluginInfo-Author=NexusOS
|
||||||
X-KDE-PluginInfo-Email=
|
X-KDE-PluginInfo-Email=
|
||||||
X-KDE-PluginInfo-Name=NexusOS
|
X-KDE-PluginInfo-Name=NexusOS
|
||||||
X-KDE-PluginInfo-Version=1.0
|
X-KDE-PluginInfo-Version=1.0
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[SddmGreeterTheme]
|
[SddmGreeterTheme]
|
||||||
Name=NexusOS-QML
|
Name=NexusOS-QML
|
||||||
Description=Dark brushed-metal lockscreen theme with flat N logo, upper-center branding, and centered login box.
|
Description=Dark brushed-metal lockscreen theme with flat N logo, upper-center branding, and centered login box.
|
||||||
Author=Jon
|
Author=NexusOS
|
||||||
Version=1.0
|
Version=1.0
|
||||||
Website=
|
Website=
|
||||||
Screenshot=
|
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]
|
[Icon Theme]
|
||||||
Name=NexusOS
|
Name=NexusOS
|
||||||
Comment=NexusOS accent icon theme
|
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]
|
[16x16/apps]
|
||||||
Context=Applications
|
Context=Applications
|
||||||
Size=16
|
Size=16
|
||||||
Type=Fixed
|
Type=Fixed
|
||||||
|
|
||||||
|
[16x16/places]
|
||||||
|
Context=Places
|
||||||
|
Size=16
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
|
[16x16/actions]
|
||||||
|
Context=Actions
|
||||||
|
Size=16
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
[22x22/apps]
|
[22x22/apps]
|
||||||
Context=Applications
|
Context=Applications
|
||||||
Size=22
|
Size=22
|
||||||
Type=Fixed
|
Type=Fixed
|
||||||
|
|
||||||
|
[22x22/places]
|
||||||
|
Context=Places
|
||||||
|
Size=22
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
|
[22x22/actions]
|
||||||
|
Context=Actions
|
||||||
|
Size=22
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
[24x24/apps]
|
[24x24/apps]
|
||||||
Context=Applications
|
Context=Applications
|
||||||
Size=24
|
Size=24
|
||||||
Type=Fixed
|
Type=Fixed
|
||||||
|
|
||||||
|
[24x24/places]
|
||||||
|
Context=Places
|
||||||
|
Size=24
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
|
[24x24/actions]
|
||||||
|
Context=Actions
|
||||||
|
Size=24
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
[32x32/apps]
|
[32x32/apps]
|
||||||
Context=Applications
|
Context=Applications
|
||||||
Size=32
|
Size=32
|
||||||
Type=Fixed
|
Type=Fixed
|
||||||
|
|
||||||
|
[32x32/places]
|
||||||
|
Context=Places
|
||||||
|
Size=32
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
|
[32x32/actions]
|
||||||
|
Context=Actions
|
||||||
|
Size=32
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
[48x48/apps]
|
[48x48/apps]
|
||||||
Context=Applications
|
Context=Applications
|
||||||
Size=48
|
Size=48
|
||||||
Type=Fixed
|
Type=Fixed
|
||||||
|
|
||||||
|
[48x48/places]
|
||||||
|
Context=Places
|
||||||
|
Size=48
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
|
[48x48/actions]
|
||||||
|
Context=Actions
|
||||||
|
Size=48
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
[64x64/apps]
|
[64x64/apps]
|
||||||
Context=Applications
|
Context=Applications
|
||||||
Size=64
|
Size=64
|
||||||
Type=Fixed
|
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]
|
[64x64/places]
|
||||||
Context=Places
|
Context=Places
|
||||||
Size=64
|
Size=64
|
||||||
Type=Fixed
|
Type=Fixed
|
||||||
|
|
||||||
|
[64x64/actions]
|
||||||
|
Context=Actions
|
||||||
|
Size=64
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
|
[128x128/apps]
|
||||||
|
Context=Applications
|
||||||
|
Size=128
|
||||||
|
Type=Fixed
|
||||||
|
|
||||||
[128x128/places]
|
[128x128/places]
|
||||||
Context=Places
|
Context=Places
|
||||||
Size=128
|
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
|
Type=Fixed
|
||||||
|
|
||||||
[128x128/actions]
|
[128x128/actions]
|
||||||
|
Context=Actions
|
||||||
Size=128
|
Size=128
|
||||||
Context=Actions
|
|
||||||
Type=Fixed
|
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
|
Main.qml # QML login UI — NexusOS purple/green palette
|
||||||
assets/background.svg assets/logo.png
|
assets/background.svg assets/logo.png
|
||||||
|
|
||||||
kscreenlocker/NexusOS/ # Runtime screen lock (Meta+L in KDE)
|
look-and-feel/com.nexusos.desktop/ # Global Theme: selects every component
|
||||||
contents/ui/LockScreenUi.qml # matches SDDM aesthetic; kscreenlocker API
|
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/
|
konsole/
|
||||||
NexusOS.colorscheme # general terminal colors
|
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)
|
# 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
|
### First boot into Plasma
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,12 @@ echo "NexusOS theme — restoring wiring from $REPO"
|
|||||||
|
|
||||||
echo "[1/4] Symlinks"
|
echo "[1/4] Symlinks"
|
||||||
link "$REPO/assets/themes/NexusOS" "$HOME/.themes/NexusOS"
|
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/.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"
|
link "$REPO/assets/themes/gtk3-user-overrides.css" "$HOME/.config/gtk-3.0/gtk.css"
|
||||||
|
|
||||||
echo "[2/4] xfconf (xsettings + xfwm4)"
|
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 \
|
gtk-update-icon-cache -f -t "$HOME/.icons/NexusOS" 2>/dev/null \
|
||||||
&& say "ok NexusOS icon cache rebuilt" \
|
&& say "ok NexusOS icon cache rebuilt" \
|
||||||
|| say "WARN NexusOS icon cache rebuild failed"
|
|| 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
|
if [[ -n "$INH" ]]; then
|
||||||
INH_OK=0
|
INH_OK=0
|
||||||
for dir in /usr/share/icons ~/.icons ~/.local/share/icons; do
|
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,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]
|
[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-font-name=Ubuntu 10
|
||||||
|
gtk-cursor-theme-size=24
|
||||||
|
gtk-cursor-theme-name=DMZ-White
|
||||||
gtk-icon-theme-name=NexusOS
|
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-theme-name=NexusOS
|
||||||
gtk-toolbar-style=3
|
|
||||||
gtk-xft-dpi=98304
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
[Settings]
|
[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-font-name=Ubuntu 10
|
||||||
|
gtk-cursor-theme-size=24
|
||||||
|
gtk-cursor-theme-name=DMZ-White
|
||||||
gtk-icon-theme-name=NexusOS
|
gtk-icon-theme-name=NexusOS
|
||||||
gtk-modules=colorreload-gtk-module
|
|
||||||
gtk-primary-button-warps-slider=false
|
|
||||||
gtk-theme-name=NexusOS
|
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]
|
[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">
|
<channel name="xfce4-desktop" version="1.0">
|
||||||
<property name="backdrop" type="empty">
|
<property name="backdrop" type="empty">
|
||||||
<property name="screen0" type="empty">
|
<property name="screen0" type="empty">
|
||||||
<property name="monitor0" type="empty">
|
<property name="monitor0" type="empty">
|
||||||
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
<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="last-single-image" type="empty"/>
|
||||||
<property name="brightness" type="empty"/>
|
<property name="brightness" type="empty"/>
|
||||||
<property name="image-show" type="bool" value="true"/>
|
<property name="image-show" type="bool" value="true"/>
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
</property>
|
</property>
|
||||||
<property name="monitor1" type="empty">
|
<property name="monitor1" type="empty">
|
||||||
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
<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="last-single-image" type="empty"/>
|
||||||
<property name="brightness" type="empty"/>
|
<property name="brightness" type="empty"/>
|
||||||
</property>
|
</property>
|
||||||
<property name="monitor2" type="empty">
|
<property name="monitor2" type="empty">
|
||||||
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
|
<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="last-single-image" type="empty"/>
|
||||||
<property name="brightness" type="empty"/>
|
<property name="brightness" type="empty"/>
|
||||||
</property>
|
</property>
|
||||||
@@ -32,17 +32,17 @@
|
|||||||
<property name="workspace1" type="empty">
|
<property name="workspace1" type="empty">
|
||||||
<property name="color-style" type="int" value="0"/>
|
<property name="color-style" type="int" value="0"/>
|
||||||
<property name="image-style" type="int" value="5"/>
|
<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="workspace2" type="empty">
|
<property name="workspace2" type="empty">
|
||||||
<property name="color-style" type="int" value="0"/>
|
<property name="color-style" type="int" value="0"/>
|
||||||
<property name="image-style" type="int" value="5"/>
|
<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="workspace3" type="empty">
|
<property name="workspace3" type="empty">
|
||||||
<property name="color-style" type="int" value="0"/>
|
<property name="color-style" type="int" value="0"/>
|
||||||
<property name="image-style" type="int" value="5"/>
|
<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>
|
</property>
|
||||||
<property name="monitoreDP-1" type="empty">
|
<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">
|
<channel name="xfce4-panel" version="1.0">
|
||||||
<property name="configver" type="int" value="2"/>
|
<property name="configver" type="int" value="2"/>
|
||||||
@@ -38,7 +38,14 @@
|
|||||||
<property name="dark-mode" type="bool" value="true"/>
|
<property name="dark-mode" type="bool" value="true"/>
|
||||||
</property>
|
</property>
|
||||||
<property name="plugins" type="empty">
|
<property name="plugins" type="empty">
|
||||||
|
<property name="plugin-1" type="empty"/>
|
||||||
<property name="plugin-2" type="string" value="power-manager-plugin"/>
|
<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="plugin-5" type="string" value="separator">
|
||||||
<property name="items" type="array">
|
<property name="items" type="array">
|
||||||
<value type="string" value="17640946043.desktop"/>
|
<value type="string" value="17640946043.desktop"/>
|
||||||
@@ -81,15 +88,11 @@
|
|||||||
</property>
|
</property>
|
||||||
<property name="button-icon" type="string" value="/home/jon/nexus-core/assets/n-small.png"/>
|
<property name="button-icon" type="string" value="/home/jon/nexus-core/assets/n-small.png"/>
|
||||||
<property name="recent" type="array">
|
<property name="recent" type="array">
|
||||||
|
<value type="string" value="microsoft-edge.desktop"/>
|
||||||
<value type="string" value="xfce4-taskmanager.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.gnome.DiskUtility.desktop"/>
|
||||||
<value type="string" value="org.x.editor.desktop"/>
|
<value type="string" value="org.x.editor.desktop"/>
|
||||||
<value type="string" value="promethean-terminal.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"/>
|
<value type="string" value="code.desktop"/>
|
||||||
</property>
|
</property>
|
||||||
<property name="view-mode" type="int" value="2"/>
|
<property name="view-mode" type="int" value="2"/>
|
||||||
@@ -107,6 +110,7 @@
|
|||||||
<property name="show-notifications" type="bool" value="true"/>
|
<property name="show-notifications" type="bool" value="true"/>
|
||||||
<property name="known-players" type="string" value="Microsoft Edge"/>
|
<property name="known-players" type="string" value="Microsoft Edge"/>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="plugin-9" type="empty"/>
|
||||||
<property name="plugin-10" type="string" value="notification-plugin"/>
|
<property name="plugin-10" type="string" value="notification-plugin"/>
|
||||||
<property name="plugin-11" type="empty"/>
|
<property name="plugin-11" type="empty"/>
|
||||||
<property name="plugin-12" type="string" value="clock">
|
<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-format" type="string" value="%m/%d/%Y"/>
|
||||||
<property name="digital-date-font" type="string" value="Sans 8"/>
|
<property name="digital-date-font" type="string" value="Sans 8"/>
|
||||||
</property>
|
</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-14" type="empty"/>
|
||||||
<property name="plugin-13" type="string" value="genmon"/>
|
|
||||||
<property name="plugin-15" type="string" value="genmon"/>
|
<property name="plugin-15" type="string" value="genmon"/>
|
||||||
<property name="plugin-16" type="string" value="genmon"/>
|
<property name="plugin-16" type="string" value="genmon"/>
|
||||||
</property>
|
</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">
|
<channel name="xsettings" version="1.0">
|
||||||
<property name="Net" type="empty">
|
<property name="Net" type="empty">
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
<property name="MonospaceFontName" type="empty"/>
|
<property name="MonospaceFontName" type="empty"/>
|
||||||
<property name="IconSizes" type="empty"/>
|
<property name="IconSizes" type="empty"/>
|
||||||
<property name="KeyThemeName" 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="MenuImages" type="bool" value="false"/>
|
||||||
<property name="ButtonImages" type="empty"/>
|
<property name="ButtonImages" type="empty"/>
|
||||||
<property name="MenuBarAccel" type="empty"/>
|
<property name="MenuBarAccel" type="empty"/>
|
||||||
@@ -35,8 +37,6 @@
|
|||||||
<property name="DecorationLayout" type="string" value="icon,menu:minimize,maximize,close"/>
|
<property name="DecorationLayout" type="string" value="icon,menu:minimize,maximize,close"/>
|
||||||
<property name="DialogsUseHeader" type="empty"/>
|
<property name="DialogsUseHeader" type="empty"/>
|
||||||
<property name="TitlebarMiddleClick" type="empty"/>
|
<property name="TitlebarMiddleClick" type="empty"/>
|
||||||
<property name="ToolbarStyle" type="empty"/>
|
|
||||||
<property name="ToolbarIconSize" type="empty"/>
|
|
||||||
<property name="IMPreeditStyle" type="empty"/>
|
<property name="IMPreeditStyle" type="empty"/>
|
||||||
<property name="IMStatusStyle" type="empty"/>
|
<property name="IMStatusStyle" type="empty"/>
|
||||||
<property name="IMModule" type="empty"/>
|
<property name="IMModule" type="empty"/>
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ if [ -d "$HOME/.config/plank" ]; then
|
|||||||
mkdir -p "$snap/plank"
|
mkdir -p "$snap/plank"
|
||||||
cp -rf "$HOME/.config/plank/." "$snap/plank/" 2>/dev/null || true
|
cp -rf "$HOME/.config/plank/." "$snap/plank/" 2>/dev/null || true
|
||||||
fi
|
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
|
# 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.).
|
# repo so they're versioned too. Some aren't Nexus-specific (Fusion 360, etc.).
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ if "synapse/curry_core.py" not in names or "synapse/curry_store.py" not in names
|
|||||||
sys.exit("wheel is missing vendored Curry (synapse/curry_core.py / curry_store.py)")
|
sys.exit("wheel is missing vendored Curry (synapse/curry_core.py / curry_store.py)")
|
||||||
if "synapse/slash_commands.py" not in names:
|
if "synapse/slash_commands.py" not in names:
|
||||||
sys.exit("wheel is missing synapse/slash_commands.py")
|
sys.exit("wheel is missing synapse/slash_commands.py")
|
||||||
|
if not any(n.startswith("modules/") for n in names):
|
||||||
|
sys.exit("wheel is missing the modules/ package (mail, network) - check pyproject.toml packages=[...]")
|
||||||
print(f"wheel OK: {len(names)} files")
|
print(f"wheel OK: {len(names)} files")
|
||||||
PY
|
PY
|
||||||
else
|
else
|
||||||
|
|||||||
+6
-15
@@ -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
|
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
|
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.
|
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
|
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
|
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.
|
needing to go read a terminal or a log file to find out.
|
||||||
@@ -24,7 +24,6 @@ import time
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
URL = "http://localhost:8000"
|
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")
|
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
|
# 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="box">
|
||||||
<div class="ring"></div>
|
<div class="ring"></div>
|
||||||
<h1>Starting NexusOS</h1>
|
<h1>Starting NexusOS</h1>
|
||||||
<p id="status">Starting memory service...</p>
|
<p id="status">Starting backend...</p>
|
||||||
</div>
|
</div>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ FAILED_HTML = LOADING_HTML.replace(
|
|||||||
).replace(
|
).replace(
|
||||||
"Starting NexusOS", "Backend did not start"
|
"Starting NexusOS", "Backend did not start"
|
||||||
).replace(
|
).replace(
|
||||||
'id="status">Starting memory service...',
|
'id="status">Starting backend...',
|
||||||
'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
|
'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:
|
def _wait_for_backend(win, timeout: float = 40.0) -> bool:
|
||||||
"""Poll memory, then the backend, updating the on-screen status as each
|
"""Poll the backend, updating the on-screen status as it comes up - so the
|
||||||
comes up - so the window never loads before the server is up (which shows
|
window never loads before the server is up, which shows a localhost error
|
||||||
a localhost error the webview won't retry), and never sits on one sentence
|
the webview won't retry."""
|
||||||
while actually moving through two separate services."""
|
|
||||||
status_url = URL.rstrip("/") + "/status"
|
status_url = URL.rstrip("/") + "/status"
|
||||||
deadline = time.time() + timeout
|
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:
|
while time.time() < deadline:
|
||||||
if not memory_ready:
|
|
||||||
memory_ready = _http_ok(MEMORY_URL)
|
|
||||||
if memory_ready:
|
|
||||||
_set_status(win, "Starting backend...")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 5s, not 2: /status probes Ollama, and a wedged Ollama made it
|
# 5s, not 2: /status probes Ollama, and a wedged Ollama made it
|
||||||
# slower than a 2s ceiling - the backend was up and answering 200
|
# slower than a 2s ceiling - the backend was up and answering 200
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ cp -f "$NEXUS/management/autostart/nm-applet.desktop" "$AUTOSTART/nm-appl
|
|||||||
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
|
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
|
||||||
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.desktop"
|
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.desktop"
|
||||||
|
|
||||||
|
# Suppress the XApp tray icons (Update Manager, System Reports, nvidia-prime).
|
||||||
|
# XApp.StatusIcon only speaks its own org.x.StatusIcon protocol; with no XApp
|
||||||
|
# status monitor on Plasma it falls back to a legacy XEmbed icon, and
|
||||||
|
# xembedsniproxy renders those as solid black silhouettes. Plasma's own
|
||||||
|
# DiscoverNotifier already covers update notifications, and nvidia-prime's
|
||||||
|
# GPU-mux switching is reachable from `prime-select`. Delete the override to
|
||||||
|
# get one back -- it will still render black.
|
||||||
|
for app in mintupdate mintreport nvidia-prime; do
|
||||||
|
printf '[Desktop Entry]\nType=Application\nName=%s\nExec=/bin/true\nHidden=true\nX-XFCE-Autostart-Override=true\n' \
|
||||||
|
"$app" > "$AUTOSTART/$app.desktop"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Plank runs under the primary-follow watcher, which also restarts it if it dies.
|
||||||
|
# Nothing else launches Plank — a restored ~/.config/plank only holds its
|
||||||
|
# launchers, so without this the dock is simply absent on a fresh box.
|
||||||
|
ln -sf "$NEXUS/management/autostart/plank.desktop" "$AUTOSTART/plank.desktop"
|
||||||
|
|
||||||
# ── Register the genmon applets into the XFCE panel ────────────────────────
|
# ── Register the genmon applets into the XFCE panel ────────────────────────
|
||||||
# The network applet's genmon (plugin-13) was added by hand once; the Nexus
|
# The network applet's genmon (plugin-13) was added by hand once; the Nexus
|
||||||
|
|||||||
@@ -15,15 +15,13 @@ port_up() {
|
|||||||
state() { port_up "$1" && echo "up" || echo "down"; }
|
state() { port_up "$1" && echo "up" || echo "down"; }
|
||||||
|
|
||||||
BACKEND=$(state 8000) # Synapse
|
BACKEND=$(state 8000) # Synapse
|
||||||
MEMORY=$(state 8001) # Memory service
|
|
||||||
FRONTEND=$(state 5173) # Vite frontend
|
FRONTEND=$(state 5173) # Vite frontend
|
||||||
|
|
||||||
up=0
|
up=0
|
||||||
[ "$BACKEND" = up ] && up=$((up + 1))
|
[ "$BACKEND" = up ] && up=$((up + 1))
|
||||||
[ "$MEMORY" = up ] && up=$((up + 1))
|
|
||||||
[ "$FRONTEND" = 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"
|
elif [ "$up" -eq 0 ]; then ICON="$ICON_DIR/nexus-off.png"
|
||||||
else ICON="$ICON_DIR/nexus-partial.png"
|
else ICON="$ICON_DIR/nexus-partial.png"
|
||||||
fi
|
fi
|
||||||
@@ -31,8 +29,7 @@ fi
|
|||||||
dot() { [ "$1" = up ] && echo "●" || echo "○"; }
|
dot() { [ "$1" = up ] && echo "●" || echo "○"; }
|
||||||
|
|
||||||
echo "<img>$ICON</img>"
|
echo "<img>$ICON</img>"
|
||||||
echo "<tool>NexusOS — ${up}/3 services up
|
echo "<tool>NexusOS — ${up}/2 services up
|
||||||
$(dot "$BACKEND") Synapse backend :8000
|
$(dot "$BACKEND") Synapse backend :8000
|
||||||
$(dot "$MEMORY") Memory service :8001
|
|
||||||
$(dot "$FRONTEND") Frontend (Vite) :5173</tool>"
|
$(dot "$FRONTEND") Frontend (Vite) :5173</tool>"
|
||||||
echo "<click>$HOME/.local/bin/nexus-popup.py</click>"
|
echo "<click>$HOME/.local/bin/nexus-popup.py</click>"
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ APP_URL = "http://localhost:5173"
|
|||||||
# name, port, nexus-cli flag
|
# name, port, nexus-cli flag
|
||||||
SERVICES = [
|
SERVICES = [
|
||||||
("Synapse backend", 8000, "--backend"),
|
("Synapse backend", 8000, "--backend"),
|
||||||
("Memory service", 8001, "--memory"),
|
|
||||||
("Frontend (Vite)", 5173, "--frontend"),
|
("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
|
||||||
+48
-12
@@ -1,17 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# The Linux-only half of a restore: system packages, the bundled Ollama binary,
|
# 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
|
# 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:
|
# 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 prep Before the rebuild: system packages the build needs.
|
||||||
# restore-linux.sh runtime After: Ollama binary + shell wiring. Any Linux box
|
# restore-linux.sh runtime After: Ollama binary + shell wiring. Any Linux box
|
||||||
# needs these to actually run NexusOS.
|
# needs these to actually run NexusOS.
|
||||||
# restore-linux.sh desktop After: the XFCE look (panel, wallpaper, theme,
|
# restore-linux.sh desktop After: the desktop look (Plasma global theme,
|
||||||
# branding). Cosmetic, this box's setup, and the only
|
# XFCE panel/wallpaper/theme, boot + distro
|
||||||
# stage that rewrites files outside the repo and $HOME.
|
# branding). Cosmetic, this box's setup, and the
|
||||||
# Skippable with `--no-desktop`; auto-skipped off XFCE.
|
# 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).
|
# Not meant to be run by hand — use `ncp restore` (python bin/sync.py restore).
|
||||||
|
|
||||||
@@ -123,15 +126,28 @@ EOF
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- desktop stage: XFCE only, and it writes outside the repo -----------------
|
# --- desktop stage: reconfigures the logged-in user's desktop ------------------
|
||||||
# Everything below reconfigures the logged-in user's desktop. On any other DE it
|
# Keyed on what is installed, not on one either/or: XFCE and Plasma can both be
|
||||||
# would be actively wrong (copying XFCE channel XMLs onto a GNOME box does
|
# present (this box runs both and picks at the SDDM greeter), and each branch
|
||||||
# nothing good), so bail rather than make a mess of someone else's machine.
|
# writes only to its own config. Anything neither branch matches falls through
|
||||||
if ! command -v xfconf-query >/dev/null; then
|
# to the boot/distro branding at the bottom, which is DE-independent.
|
||||||
echo "Not an XFCE session (no xfconf-query) — skipping desktop wiring."
|
|
||||||
exit 0
|
# 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
|
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
|
# Panel layout, wallpaper, compositing, keybindings and terminal profile all live
|
||||||
# in the xfconf channel XMLs — restore them by copying the files back. xfconfd
|
# 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
|
# 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
|
fi
|
||||||
plank_snap="$NEXUS_ROOT/assets/themes/restore-snapshot/plank"
|
plank_snap="$NEXUS_ROOT/assets/themes/restore-snapshot/plank"
|
||||||
if [ -d "$plank_snap" ]; then
|
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"
|
mkdir -p "$HOME/.config/plank"
|
||||||
cp -rf "$plank_snap/." "$HOME/.config/plank/" # /. = contents, else it nests
|
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
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -196,6 +230,8 @@ else
|
|||||||
echo "Warning: $panel_installer not found — skipping panel install."
|
echo "Warning: $panel_installer not found — skipping panel install."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
fi
|
||||||
|
|
||||||
# Boot branding — the Plymouth splash. Not XFCE-specific, but it's cosmetic and
|
# 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
|
# 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).
|
# below. Self-skips when the theme is already installed (initramfs rebuild is slow).
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"preact": "^10.29.8",
|
"preact": "^10.29.8",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19"
|
"node": ">=20.19"
|
||||||
|
|||||||
+169
-10
@@ -4,8 +4,9 @@ import { Playbook } from "./Playbook";
|
|||||||
import { Models } from "./Models";
|
import { Models } from "./Models";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { Memory } from "./Memory";
|
import { Memory } from "./Memory";
|
||||||
import { Documents } from "./Documents";
|
import { Projects } from "./Projects";
|
||||||
import { Logs } from "./Logs";
|
import { Logs } from "./Logs";
|
||||||
|
import { MODULES } from "./modules/registry";
|
||||||
|
|
||||||
import { API_BASE } from "./config";
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ function App() {
|
|||||||
const [ollamaStatus, setOllamaStatus] = useState("checking");
|
const [ollamaStatus, setOllamaStatus] = useState("checking");
|
||||||
const [ollamaBusy, setOllamaBusy] = useState(false);
|
const [ollamaBusy, setOllamaBusy] = useState(false);
|
||||||
const [showStatusTooltip, setShowStatusTooltip] = 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 [activeConversationId, setActiveConversationId] = useState(() => crypto.randomUUID());
|
||||||
const [isModelPulling, setIsModelPulling] = useState(false);
|
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.
|
// Manual AI control — Ollama does not auto-start with the app.
|
||||||
const toggleOllama = async () => {
|
const toggleOllama = async () => {
|
||||||
const action = ollamaStatus === "running" ? "stop" : "start";
|
const action = ollamaStatus === "running" ? "stop" : "start";
|
||||||
@@ -81,6 +116,26 @@ function App() {
|
|||||||
return () => clearInterval(interval);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch(`${API_BASE}/conversations`)
|
fetch(`${API_BASE}/conversations`)
|
||||||
@@ -174,13 +229,30 @@ function App() {
|
|||||||
{ key: "playbook", icon: "📖", label: "Playbooks" },
|
{ key: "playbook", icon: "📖", label: "Playbooks" },
|
||||||
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
|
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
|
||||||
{ key: "memory", icon: "🧠", label: "Memory" },
|
{ 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: "logs", icon: "📜", label: "Logs" },
|
||||||
{ key: "settings", icon: "⚙️", label: "Settings" },
|
{ key: "settings", icon: "⚙️", label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
|
<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 */}
|
{/* Sidebar */}
|
||||||
<aside style={{
|
<aside style={{
|
||||||
width: "400px",
|
width: "400px",
|
||||||
@@ -204,7 +276,24 @@ function App() {
|
|||||||
<img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
|
<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>
|
<h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
|
||||||
{version && (
|
{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 */}
|
{/* Status Indicator */}
|
||||||
@@ -261,12 +350,10 @@ function App() {
|
|||||||
|
|
||||||
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
|
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
const isActive = currentPage === item.key;
|
const isActive = item.isModulesMenu
|
||||||
return (
|
? MODULES.some(m => m.key === currentPage)
|
||||||
<button
|
: currentPage === item.key;
|
||||||
key={item.key}
|
const buttonStyle = {
|
||||||
onClick={() => setCurrentPage(item.key)}
|
|
||||||
style={{
|
|
||||||
padding: "0.32rem 0.5rem",
|
padding: "0.32rem 0.5rem",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color: isActive ? "#4aa3e0" : "#bbb",
|
color: isActive ? "#4aa3e0" : "#bbb",
|
||||||
@@ -282,7 +369,78 @@ function App() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.45rem",
|
gap: "0.45rem",
|
||||||
width: "100%",
|
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={buttonStyle}
|
||||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
|
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
|
||||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
|
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
|
||||||
>
|
>
|
||||||
@@ -512,7 +670,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
{currentPage === "playbook" && <Playbook />}
|
{currentPage === "playbook" && <Playbook />}
|
||||||
{currentPage === "memory" && <Memory />}
|
{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 === "logs" && <Logs />}
|
||||||
{currentPage === "settings" && <Settings />}
|
{currentPage === "settings" && <Settings />}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
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 [modelList, setModelList] = useState([]);
|
||||||
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
|
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
|
||||||
const [autoModel, setAutoModel] = useState(null);
|
const [autoModel, setAutoModel] = useState(null);
|
||||||
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
|
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
const [copiedIdx, setCopiedIdx] = useState(null);
|
const [copiedIdx, setCopiedIdx] = useState(null);
|
||||||
const [lastStats, setLastStats] = useState(null);
|
|
||||||
const [memoryToast, setMemoryToast] = useState(null);
|
const [memoryToast, setMemoryToast] = useState(null);
|
||||||
const [images, setImages] = useState([]); // {name, b64} for vision models
|
const [images, setImages] = useState([]); // {name, b64} for vision models
|
||||||
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
|
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
|
||||||
@@ -123,12 +123,15 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
if (abortRef.current) abortRef.current.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLastStats(null);
|
|
||||||
fetch(`${API_BASE}/conversations/${conversationId}`)
|
fetch(`${API_BASE}/conversations/${conversationId}`)
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return;
|
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([]); });
|
.catch(() => { if (!cancelled) setMessages([]); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
@@ -137,7 +140,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
// Keyed on `visible`, not []: this component stays mounted while other pages
|
// Keyed on `visible`, not []: this component stays mounted while other pages
|
||||||
// show (App hides it with display:none so an in-flight reply survives
|
// 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
|
// 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
|
// installed when the tab first opened — a model pulled on the Models page
|
||||||
// didn't appear here until a full browser reload.
|
// didn't appear here until a full browser reload.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
@@ -200,8 +203,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
const startNewChat = () => {
|
const startNewChat = () => {
|
||||||
if (abortRef.current) abortRef.current.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setQueue([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLastStats(null);
|
|
||||||
setConversationId(crypto.randomUUID());
|
setConversationId(crypto.randomUUID());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,15 +279,12 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
if (pendingEventType === "meta") {
|
if (pendingEventType === "meta") {
|
||||||
try {
|
try {
|
||||||
const stats = JSON.parse(payload);
|
const stats = JSON.parse(payload);
|
||||||
setLastStats(stats);
|
// Tag this message with the model that answered and its token stats
|
||||||
// Tag this message with the model that answered
|
|
||||||
if (stats.model) {
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model };
|
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model, stats };
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
pendingEventType = null;
|
pendingEventType = null;
|
||||||
continue;
|
continue;
|
||||||
@@ -382,14 +382,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
messages.slice(0, index).filter(m => m.content.trim() !== "")
|
messages.slice(0, index).filter(m => m.content.trim() !== "")
|
||||||
.map(m => ({ role: m.role, content: m.content }));
|
.map(m => ({ role: m.role, content: m.content }));
|
||||||
|
|
||||||
const sendMessage = async () => {
|
// Shared by an immediate send and an auto-flushed queued message.
|
||||||
if ((!input.trim() && images.length === 0) || loading) return;
|
const doSend = async (userMessage, outImages = []) => {
|
||||||
|
|
||||||
const userMessage = input.trim();
|
|
||||||
const outImages = images.map(i => i.b64);
|
|
||||||
setInput("");
|
|
||||||
setImages([]);
|
|
||||||
|
|
||||||
// Note any attached images so image-only turns aren't blank
|
// Note any attached images so image-only turns aren't blank
|
||||||
const shownContent = outImages.length
|
const shownContent = outImages.length
|
||||||
? `${userMessage}${userMessage ? "\n\n" : ""}📷 ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached`
|
? `${userMessage}${userMessage ? "\n\n" : ""}📷 ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached`
|
||||||
@@ -400,6 +394,35 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
await streamAssistant({ message: userMessage, history, images: outImages, assistantIndex });
|
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 () => {
|
const regenerate = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const lastUserIdx = messages.map(m => m.role).lastIndexOf("user");
|
const lastUserIdx = messages.map(m => m.role).lastIndexOf("user");
|
||||||
@@ -446,25 +469,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
if (loading) queueMessage();
|
||||||
|
else sendMessage();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
|
<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={{
|
<div style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
background: "#161616",
|
background: "#161616",
|
||||||
@@ -577,19 +588,6 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
{think ? "on" : "off"}
|
{think ? "on" : "off"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={startNewChat}
|
onClick={startNewChat}
|
||||||
@@ -707,6 +705,14 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
↻ Regenerate
|
↻ Regenerate
|
||||||
</button>
|
</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 && (
|
{msg.role === "assistant" && ttsSupported && (
|
||||||
<button
|
<button
|
||||||
onClick={() => speak(idx, msg.content)}
|
onClick={() => speak(idx, msg.content)}
|
||||||
@@ -767,6 +773,28 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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" }}>
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "stretch" }}>
|
||||||
<label title="Attach image (needs a vision model)"
|
<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" }}>
|
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" }}>
|
||||||
@@ -784,8 +812,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type your message... (Shift+Enter for new line)"
|
placeholder={loading ? "Keep typing — Enter queues it for after this reply..." : "Type your message... (Shift+Enter for new line)"}
|
||||||
disabled={loading}
|
|
||||||
rows={3}
|
rows={3}
|
||||||
style={{
|
style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
@@ -796,9 +823,21 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
|||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
fontFamily: "system-ui",
|
fontFamily: "system-ui",
|
||||||
resize: "none",
|
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
|
<button
|
||||||
onClick={loading ? stopGeneration : sendMessage}
|
onClick={loading ? stopGeneration : sendMessage}
|
||||||
disabled={!loading && !input.trim() && images.length === 0}
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -44,6 +44,7 @@ export function Memory() {
|
|||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState(null);
|
||||||
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
|
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
|
||||||
|
const [projectNames, setProjectNames] = useState({}); // id -> name, for the scope badge
|
||||||
|
|
||||||
const loadItems = useCallback(async () => {
|
const loadItems = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -57,6 +58,15 @@ export function Memory() {
|
|||||||
|
|
||||||
useEffect(() => { loadItems(); }, [loadItems]);
|
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 flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); };
|
||||||
|
|
||||||
const saveNew = async (section, text) => {
|
const saveNew = async (section, text) => {
|
||||||
@@ -386,7 +396,16 @@ export function Memory() {
|
|||||||
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
|
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
|
||||||
>
|
>
|
||||||
<span style={{ color: "#aaa", marginTop: "0.1rem", flexShrink: 0 }}>-</span>
|
<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 }}
|
<div style={{ display: "flex", gap: "0.3rem", flexShrink: 0, opacity: 0.4 }}
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = 1}
|
onMouseEnter={e => e.currentTarget.style.opacity = 1}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
|
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
|
||||||
|
|||||||
@@ -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));
|
||||||
+4
-9
@@ -1,7 +1,7 @@
|
|||||||
# NexusOS launcher for Windows (native, no Vite).
|
# NexusOS launcher for Windows (native, no Vite).
|
||||||
#
|
#
|
||||||
# Single-process app: the backend on :8000 serves the built web UI itself, so
|
# 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
|
# (bin\nexus_window.py, pywebview/WebView2) immediately alongside them with its
|
||||||
# own live-updating loading screen. The AI (Ollama) does NOT auto-start - turn
|
# 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
|
# 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
|
# can be squatting the port without ever responding, and treating that as
|
||||||
# "already running" skips starting a real service - the window then sits on
|
# "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.
|
# 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
|
$backend = $null
|
||||||
if (-not (Test-ServiceUp "http://127.0.0.1:8000/status")) {
|
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")
|
$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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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.
|
# shortcut runs hidden, where a prompt no one can answer would hang forever.
|
||||||
if ($app) { $app.WaitForExit() }
|
if ($app) { $app.WaitForExit() }
|
||||||
elseif ($backend) { $backend.WaitForExit() }
|
elseif ($backend) { $backend.WaitForExit() }
|
||||||
elseif ($memory) { $memory.WaitForExit() }
|
|
||||||
|
|
||||||
# Shut the services down.
|
# Shut the services down.
|
||||||
Write-Host "Stopping NexusOS..." -ForegroundColor Cyan
|
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 }
|
if ($p -and -not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-7
@@ -1,4 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/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)"
|
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PYTHON="$PROJECT_ROOT/Promethean/bin/python3"
|
PYTHON="$PROJECT_ROOT/Promethean/bin/python3"
|
||||||
FRONTEND_DIR="$PROJECT_ROOT/interface/web"
|
FRONTEND_DIR="$PROJECT_ROOT/interface/web"
|
||||||
@@ -6,7 +11,6 @@ LOG_DIR="$PROJECT_ROOT/runtime"
|
|||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
MEMORY_LOG="$LOG_DIR/memory.log"
|
|
||||||
BACKEND_LOG="$LOG_DIR/backend.log"
|
BACKEND_LOG="$LOG_DIR/backend.log"
|
||||||
FRONTEND_LOG="$LOG_DIR/frontend.log"
|
FRONTEND_LOG="$LOG_DIR/frontend.log"
|
||||||
|
|
||||||
@@ -29,15 +33,10 @@ _check() {
|
|||||||
fi
|
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" \
|
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
|
sleep 3
|
||||||
_check "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG"
|
|
||||||
_check "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG"
|
_check "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG"
|
||||||
|
|
||||||
# Frontend last
|
# Frontend last
|
||||||
|
|||||||
@@ -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 time
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -20,7 +19,9 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
VERSION = "0.0.0"
|
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"
|
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
|
||||||
def _find_npm():
|
def _find_npm():
|
||||||
"""Prefer nvm's newest node. When the panel is launched from the desktop
|
"""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"),
|
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
|
||||||
"-m", "uvicorn",
|
"-m", "uvicorn",
|
||||||
"synapse.main:sio_app",
|
"synapse.main:sio_app",
|
||||||
"--host", "0.0.0.0",
|
"--host", BIND_HOST,
|
||||||
"--port", "8000",
|
"--port", "8000",
|
||||||
"--reload",
|
"--reload",
|
||||||
"--reload-dir", str(PROJECT_ROOT / "synapse"),
|
"--reload-dir", str(PROJECT_ROOT / "synapse"),
|
||||||
"--log-level", "info"
|
"--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"
|
FRONTEND_PID = PROJECT_ROOT / "runtime" / "pids" / "frontend.pid"
|
||||||
BACKEND_PID = PROJECT_ROOT / "runtime" / "pids" / "backend.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"
|
CHAT_LOG = PROJECT_ROOT / "runtime" / "logs" / "chat.log"
|
||||||
|
|
||||||
class NexusControlPanel:
|
class NexusControlPanel:
|
||||||
@@ -109,10 +97,8 @@ class NexusControlPanel:
|
|||||||
|
|
||||||
self.frontend_process = None
|
self.frontend_process = None
|
||||||
self.backend_process = None
|
self.backend_process = None
|
||||||
self.memory_process = None
|
|
||||||
self._frontend_starting = False
|
self._frontend_starting = False
|
||||||
self._backend_starting = False
|
self._backend_starting = False
|
||||||
self._memory_starting = False
|
|
||||||
self._closing = False
|
self._closing = False
|
||||||
|
|
||||||
self._setup_custom_titlebar()
|
self._setup_custom_titlebar()
|
||||||
@@ -132,9 +118,6 @@ class NexusControlPanel:
|
|||||||
if self._is_running(frontend_pid):
|
if self._is_running(frontend_pid):
|
||||||
threading.Thread(target=self._monitor_frontend, daemon=True).start()
|
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()
|
threading.Thread(target=self._monitor_synapses, daemon=True).start()
|
||||||
|
|
||||||
self.update_master_ui()
|
self.update_master_ui()
|
||||||
@@ -167,12 +150,11 @@ class NexusControlPanel:
|
|||||||
|
|
||||||
|
|
||||||
def _init_log_tags(self):
|
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("SYSTEM", foreground="#ff00ea")
|
||||||
console.tag_config("FRONTEND", foreground="#61dbfb")
|
console.tag_config("FRONTEND", foreground="#61dbfb")
|
||||||
console.tag_config("BACKEND", foreground="#ffd43b")
|
console.tag_config("BACKEND", foreground="#ffd43b")
|
||||||
console.tag_config("MINDTRACE", foreground="#cc88ff")
|
console.tag_config("MINDTRACE", foreground="#cc88ff")
|
||||||
console.tag_config("MEMORY", foreground="#78e08f")
|
|
||||||
console.tag_config("ERROR", foreground="#ff0000")
|
console.tag_config("ERROR", foreground="#ff0000")
|
||||||
console.tag_config("GPU_NVIDIA", foreground="#76b900")
|
console.tag_config("GPU_NVIDIA", foreground="#76b900")
|
||||||
console.tag_config("GPU_AMD", foreground="#ed1c24")
|
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)}")
|
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):
|
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)
|
console.delete("1.0", tk.END)
|
||||||
|
|
||||||
def _copy_selection(self, widget):
|
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 = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_backend)
|
||||||
self.btn_back_stop.pack(side=tk.LEFT, padx=2)
|
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
|
# Terminal Displays
|
||||||
sys_f, self.sys_console = self._create_terminal(self.root, " >> SYSTEM STATUS", height=5, text_color="#ff00ea")
|
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))
|
sys_f.pack(fill=tk.X, padx=10, pady=(0, 8))
|
||||||
|
|
||||||
bottom_f = tk.Frame(self.root, bg="#1e1e1e")
|
bottom_f = tk.Frame(self.root, bg="#1e1e1e")
|
||||||
bottom_f.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 8))
|
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.columnconfigure(col, weight=1, uniform="term")
|
||||||
bottom_f.rowconfigure(0, weight=1)
|
bottom_f.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
@@ -280,10 +256,8 @@ class NexusControlPanel:
|
|||||||
f_f.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
|
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, self.back_console = self._create_terminal(bottom_f, " >> SYNAPSE (UVICORN)", text_color="#ffd43b")
|
||||||
b_f.grid(row=0, column=1, sticky="nsew", padx=4)
|
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, 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):
|
def _safe_after(self, delay, func):
|
||||||
"""Schedule a Tk callback without ever raising.
|
"""Schedule a Tk callback without ever raising.
|
||||||
@@ -305,7 +279,6 @@ class NexusControlPanel:
|
|||||||
con = self.sys_console
|
con = self.sys_console
|
||||||
if tag == "FRONTEND": con = self.front_console
|
if tag == "FRONTEND": con = self.front_console
|
||||||
elif tag == "BACKEND": con = self.back_console
|
elif tag == "BACKEND": con = self.back_console
|
||||||
elif tag == "MEMORY": con = self.mem_console
|
|
||||||
elif tag == "MINDTRACE": con = self.mind_console
|
elif tag == "MINDTRACE": con = self.mind_console
|
||||||
con.insert(tk.END, f"[{tag}] {message}\n", tag)
|
con.insert(tk.END, f"[{tag}] {message}\n", tag)
|
||||||
con.see(tk.END)
|
con.see(tk.END)
|
||||||
@@ -358,16 +331,14 @@ class NexusControlPanel:
|
|||||||
def update_master_ui(self):
|
def update_master_ui(self):
|
||||||
if self._closing: # window may be destroyed; stop_* still calls this
|
if self._closing: # window may be destroyed; stop_* still calls this
|
||||||
return
|
return
|
||||||
f, b, m = self._frontend_is_active(), self._backend_is_active(), self._memory_is_active()
|
f, b = self._frontend_is_active(), self._backend_is_active()
|
||||||
self.btn_master_start.config(state=tk.DISABLED if (f and b and m) else tk.NORMAL)
|
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 or m) else tk.DISABLED)
|
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_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_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_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_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_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):
|
def open_frontend_view(self):
|
||||||
webbrowser.open_new_tab(FRONTEND_URL)
|
webbrowser.open_new_tab(FRONTEND_URL)
|
||||||
@@ -426,10 +397,6 @@ class NexusControlPanel:
|
|||||||
self._frontend_starting = False
|
self._frontend_starting = False
|
||||||
self.frontend_process = p
|
self.frontend_process = p
|
||||||
self._write_pid(FRONTEND_PID, p.pid)
|
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:
|
else:
|
||||||
self._backend_starting = False
|
self._backend_starting = False
|
||||||
self.backend_process = p
|
self.backend_process = p
|
||||||
@@ -472,10 +439,6 @@ class NexusControlPanel:
|
|||||||
self._frontend_starting = False
|
self._frontend_starting = False
|
||||||
self.frontend_process = None
|
self.frontend_process = None
|
||||||
FRONTEND_PID.unlink(missing_ok=True)
|
FRONTEND_PID.unlink(missing_ok=True)
|
||||||
elif name == "MEMORY":
|
|
||||||
self._memory_starting = False
|
|
||||||
self.memory_process = None
|
|
||||||
MEMORY_PID.unlink(missing_ok=True)
|
|
||||||
else:
|
else:
|
||||||
self._backend_starting = False
|
self._backend_starting = False
|
||||||
self.backend_process = None
|
self.backend_process = None
|
||||||
@@ -576,47 +539,13 @@ class NexusControlPanel:
|
|||||||
self._terminate_pid_file(BACKEND_PID)
|
self._terminate_pid_file(BACKEND_PID)
|
||||||
self.update_master_ui()
|
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):
|
def start_all(self):
|
||||||
self.start_memory()
|
|
||||||
self.start_frontend()
|
self.start_frontend()
|
||||||
self.start_backend()
|
self.start_backend()
|
||||||
|
|
||||||
def kill_all(self):
|
def kill_all(self):
|
||||||
self.log("SYSTEM", "FORCE KILL — terminating all Nexus processes by port...")
|
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():
|
for port, name in ports.items():
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
|
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)
|
subprocess.run(["pkill", "-9", "-f", pat], capture_output=True, timeout=5)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
pid_file.unlink(missing_ok=True)
|
||||||
self.frontend_process = None
|
self.frontend_process = None
|
||||||
self.backend_process = None
|
self.backend_process = None
|
||||||
self.memory_process = None
|
|
||||||
self._frontend_starting = False
|
self._frontend_starting = False
|
||||||
self._backend_starting = False
|
self._backend_starting = False
|
||||||
self._memory_starting = False
|
|
||||||
self._safe_after(0, self.update_master_ui)
|
self._safe_after(0, self.update_master_ui)
|
||||||
self.log("SYSTEM", "Force kill complete.")
|
self.log("SYSTEM", "Force kill complete.")
|
||||||
|
|
||||||
def stop_all(self):
|
def stop_all(self):
|
||||||
self.stop_frontend()
|
self.stop_frontend()
|
||||||
self.stop_backend()
|
self.stop_backend()
|
||||||
self.stop_memory()
|
|
||||||
|
|
||||||
def on_close(self):
|
def on_close(self):
|
||||||
if self._closing:
|
if self._closing:
|
||||||
@@ -675,13 +601,13 @@ class NexusControlPanel:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Services the panel spawned itself (each in its own setsid group):
|
# 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:
|
if proc:
|
||||||
self._terminate_process(proc)
|
self._terminate_process(proc)
|
||||||
# Services attached via pid file (ncp-started, one shared group);
|
# Services attached via pid file (ncp-started, one shared group);
|
||||||
# _terminate_pid_file verifies ownership and unlinks. Guarantee the
|
# _terminate_pid_file verifies ownership and unlinks. Guarantee the
|
||||||
# unlink even for a pid that's already dead so no stale files remain.
|
# 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):
|
if self._is_running(pf):
|
||||||
self._terminate_pid_file(pf)
|
self._terminate_pid_file(pf)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Launch NexusOS as a standalone Edge app window. Single-process mode: the
|
# 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
|
# 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
|
# — 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").
|
# services back down (matches the launcher's "closing the window shuts it down").
|
||||||
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
|
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
|
OWN_SERVICES=0
|
||||||
else
|
else
|
||||||
OWN_SERVICES=1
|
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.
|
# :8000; no frontend/Vite process, no AI auto-start.
|
||||||
"$NEXUS_ROOT/management/nexus-cli.sh" start -m
|
"$NEXUS_ROOT/management/nexus-cli.sh" start -m
|
||||||
"$NEXUS_ROOT/management/nexus-cli.sh" start -b
|
"$NEXUS_ROOT/management/nexus-cli.sh" start -b
|
||||||
|
|||||||
@@ -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()
|
||||||
+54
-39
@@ -25,6 +25,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from synapse import proc_util
|
from synapse import proc_util
|
||||||
@@ -103,12 +104,20 @@ def _psutil():
|
|||||||
|
|
||||||
# -- services ------------------------------------------------------------------
|
# -- services ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class Service:
|
class Service:
|
||||||
def __init__(self, key, label, port, cwd, patterns, argv=None):
|
key: str
|
||||||
self.key, self.label, self.port = key, label, port
|
label: str
|
||||||
self.cwd, self.patterns, self._argv = cwd, patterns, argv
|
port: int
|
||||||
self.pid_file = PID_DIR / f"{key}.pid"
|
cwd: Path
|
||||||
self.log_file = LOG_DIR / f"{key}.log"
|
patterns: list
|
||||||
|
_argv: object = None
|
||||||
|
pid_file: Path = field(init=False)
|
||||||
|
log_file: Path = field(init=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.pid_file = PID_DIR / f"{self.key}.pid"
|
||||||
|
self.log_file = LOG_DIR / f"{self.key}.log"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
@@ -118,7 +127,7 @@ class Service:
|
|||||||
return self._argv() if callable(self._argv) else self._argv
|
return self._argv() if callable(self._argv) else self._argv
|
||||||
|
|
||||||
|
|
||||||
# Bind loopback by default: the backend/memory REST APIs are unauthenticated, so
|
# Bind loopback by default: the backend REST API is unauthenticated, so
|
||||||
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
|
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
|
||||||
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
|
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
|
||||||
BIND_HOST = settings.bind_host
|
BIND_HOST = settings.bind_host
|
||||||
@@ -137,10 +146,7 @@ def _uvicorn(app: str, port: int):
|
|||||||
|
|
||||||
|
|
||||||
SERVICES = {
|
SERVICES = {
|
||||||
"memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
|
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, ROOT,
|
||||||
["uvicorn synapse.memory"],
|
|
||||||
lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
|
|
||||||
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
|
|
||||||
["uvicorn synapse.main"],
|
["uvicorn synapse.main"],
|
||||||
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
||||||
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
||||||
@@ -233,10 +239,10 @@ _POLL_STEP = 0.25
|
|||||||
|
|
||||||
def wait_for_port(svc: Service, timeout: int = 30) -> bool:
|
def wait_for_port(svc: Service, timeout: int = 30) -> bool:
|
||||||
if http_ok(svc.url):
|
if http_ok(svc.url):
|
||||||
# "READY", not "already running": `ncp start` launches memory and backend
|
# "READY", not "already running": `ncp start` launches its services and
|
||||||
# together and only then waits on each, so by the time the backend's turn
|
# only then waits on each, so by the time a service's turn comes it is
|
||||||
# comes it is normally up - and reporting "already running" for a service
|
# normally up - and reporting "already running" for a service this same
|
||||||
# this same command started two seconds ago reads like a stale process.
|
# command started two seconds ago reads like a stale process.
|
||||||
print(f" {svc.label} READY (:{svc.port})")
|
print(f" {svc.label} READY (:{svc.port})")
|
||||||
return True
|
return True
|
||||||
for _ in range(int(timeout / _POLL_STEP)):
|
for _ in range(int(timeout / _POLL_STEP)):
|
||||||
@@ -400,9 +406,7 @@ def stop_ollama() -> None:
|
|||||||
# -- commands ------------------------------------------------------------------
|
# -- commands ------------------------------------------------------------------
|
||||||
|
|
||||||
def cmd_start(target) -> None:
|
def cmd_start(target) -> None:
|
||||||
if target in ("--memory", "-m"):
|
if target in ("--backend", "-b"):
|
||||||
launch(SERVICES["memory"]); wait_for_port(SERVICES["memory"])
|
|
||||||
elif target in ("--backend", "-b"):
|
|
||||||
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
|
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
|
||||||
start_ollama()
|
start_ollama()
|
||||||
elif target in ("--frontend", "-f"):
|
elif target in ("--frontend", "-f"):
|
||||||
@@ -427,9 +431,7 @@ def cmd_start(target) -> None:
|
|||||||
# and falls through to the ~30s force-kill path). Still available on
|
# and falls through to the ~30s force-kill path). Still available on
|
||||||
# demand via `ncp start --frontend`.
|
# demand via `ncp start --frontend`.
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
launch(SERVICES["memory"])
|
|
||||||
launch(SERVICES["backend"])
|
launch(SERVICES["backend"])
|
||||||
wait_for_port(SERVICES["memory"])
|
|
||||||
wait_for_port(SERVICES["backend"])
|
wait_for_port(SERVICES["backend"])
|
||||||
t_services = time.perf_counter()
|
t_services = time.perf_counter()
|
||||||
start_ollama(background=True)
|
start_ollama(background=True)
|
||||||
@@ -437,7 +439,7 @@ def cmd_start(target) -> None:
|
|||||||
launch(SERVICES["frontend"])
|
launch(SERVICES["frontend"])
|
||||||
t_bg = time.perf_counter()
|
t_bg = time.perf_counter()
|
||||||
print("\nBoot timing:")
|
print("\nBoot timing:")
|
||||||
print(f" services (memory+backend) : {t_services - t0:5.1f}s")
|
print(f" backend : {t_services - t0:5.1f}s")
|
||||||
print(f" ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
|
print(f" ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
|
||||||
print(f" total to interactive : {t_bg - t0:5.1f}s")
|
print(f" total to interactive : {t_bg - t0:5.1f}s")
|
||||||
else:
|
else:
|
||||||
@@ -445,16 +447,13 @@ def cmd_start(target) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_stop(target) -> None:
|
def cmd_stop(target) -> None:
|
||||||
if target in ("--memory", "-m"):
|
if target in ("--backend", "-b"):
|
||||||
stop_service(SERVICES["memory"])
|
|
||||||
elif target in ("--backend", "-b"):
|
|
||||||
stop_ollama(); stop_service(SERVICES["backend"])
|
stop_ollama(); stop_service(SERVICES["backend"])
|
||||||
elif target in ("--frontend", "-f"):
|
elif target in ("--frontend", "-f"):
|
||||||
stop_service(SERVICES["frontend"])
|
stop_service(SERVICES["frontend"])
|
||||||
elif target in ("--ai", "-a"):
|
elif target in ("--ai", "-a"):
|
||||||
stop_ollama()
|
stop_ollama()
|
||||||
elif target in (None, "", "all"):
|
elif target in (None, "", "all"):
|
||||||
stop_service(SERVICES["memory"])
|
|
||||||
stop_ollama()
|
stop_ollama()
|
||||||
stop_service(SERVICES["backend"])
|
stop_service(SERVICES["backend"])
|
||||||
stop_service(SERVICES["frontend"])
|
stop_service(SERVICES["frontend"])
|
||||||
@@ -466,7 +465,6 @@ def cmd_kill() -> None:
|
|||||||
print("Force-killing all Nexus processes...")
|
print("Force-killing all Nexus processes...")
|
||||||
targets = [
|
targets = [
|
||||||
(settings.backend_port, "SYNAPSE"),
|
(settings.backend_port, "SYNAPSE"),
|
||||||
(settings.memory_port, "MEMORY"),
|
|
||||||
(5173, "INTERFACE"),
|
(5173, "INTERFACE"),
|
||||||
]
|
]
|
||||||
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
|
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
|
||||||
@@ -498,7 +496,6 @@ def cmd_status() -> None:
|
|||||||
|
|
||||||
print("Backend:")
|
print("Backend:")
|
||||||
one("Synapse ", SERVICES["backend"])
|
one("Synapse ", SERVICES["backend"])
|
||||||
one("Memory service", SERVICES["memory"])
|
|
||||||
print("\nFrontend:")
|
print("\nFrontend:")
|
||||||
one("Vite ", SERVICES["frontend"])
|
one("Vite ", SERVICES["frontend"])
|
||||||
print("\nModel server:")
|
print("\nModel server:")
|
||||||
@@ -515,26 +512,25 @@ def _tail(path: Path, n: int) -> None:
|
|||||||
print(f" (no log at {path})")
|
print(f" (no log at {path})")
|
||||||
|
|
||||||
|
|
||||||
LOG_HEADINGS = {"memory": "MEMORY SERVICE", "backend": "BACKEND", "frontend": "FRONTEND"}
|
LOG_HEADINGS = {"backend": "BACKEND", "frontend": "FRONTEND"}
|
||||||
|
|
||||||
|
|
||||||
def cmd_logs(target) -> None:
|
def cmd_logs(target) -> None:
|
||||||
named = {"--frontend": "frontend", "-f": "frontend",
|
named = {"--frontend": "frontend", "-f": "frontend",
|
||||||
"--backend": "backend", "-b": "backend",
|
"--backend": "backend", "-b": "backend"}
|
||||||
"--memory": "memory", "-m": "memory"}
|
|
||||||
if target in named:
|
if target in named:
|
||||||
key = named[target]
|
key = named[target]
|
||||||
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
|
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
|
||||||
_tail(SERVICES[key].log_file, 50)
|
_tail(SERVICES[key].log_file, 50)
|
||||||
elif target in (None, "", "all"):
|
elif target in (None, "", "all"):
|
||||||
for i, key in enumerate(("memory", "backend", "frontend")):
|
for i, key in enumerate(("backend", "frontend")):
|
||||||
if i:
|
if i:
|
||||||
print()
|
print()
|
||||||
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
|
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
|
||||||
_tail(SERVICES[key].log_file, 30)
|
_tail(SERVICES[key].log_file, 30)
|
||||||
else:
|
else:
|
||||||
print(f"Unknown logs target: '{target}'")
|
print(f"Unknown logs target: '{target}'")
|
||||||
print("Usage: ncp logs [frontend|backend|memory|all]")
|
print("Usage: ncp logs [frontend|backend|all]")
|
||||||
|
|
||||||
|
|
||||||
def _apply_fixes() -> None:
|
def _apply_fixes() -> None:
|
||||||
@@ -642,11 +638,11 @@ def cmd_doctor(fix: bool = False) -> None:
|
|||||||
mark(importable("from synapse.main import sio_app"),
|
mark(importable("from synapse.main import sio_app"),
|
||||||
"Backend module importable", "Backend module failed to import")
|
"Backend module importable", "Backend module failed to import")
|
||||||
|
|
||||||
print("\nChecking memory service...")
|
print("\nChecking memory...")
|
||||||
mark((ROOT / "synapse" / "memory").is_dir(),
|
mark((ROOT / "synapse" / "memory").is_dir(),
|
||||||
"Memory module directory found", "Missing memory module directory")
|
"Memory module directory found", "Missing memory module directory")
|
||||||
mark(importable("from synapse.memory.service import app"),
|
mark(importable("from synapse.memory.curator import extract_for_conversation"),
|
||||||
"Memory service module importable", "Memory service module failed to import")
|
"Memory curator importable", "Memory curator failed to import")
|
||||||
mark(os.access(ROOT / "synapse" / "memory", os.W_OK),
|
mark(os.access(ROOT / "synapse" / "memory", os.W_OK),
|
||||||
"Memory database directory writable", "Memory database directory not writable")
|
"Memory database directory writable", "Memory database directory not writable")
|
||||||
|
|
||||||
@@ -687,6 +683,26 @@ def cmd_update() -> None:
|
|||||||
cmd_doctor()
|
cmd_doctor()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_upgrade() -> int:
|
||||||
|
"""Pull + rebuild + restart the backend. This is what the web UI's "install
|
||||||
|
update" button runs, so it must be spawned DETACHED from the backend - it
|
||||||
|
stops the very server that asked for it.
|
||||||
|
|
||||||
|
Ollama is deliberately left alone (stop_service, not cmd_stop): it is a
|
||||||
|
separate process on :11434, the restore does not touch a present binary, and
|
||||||
|
reloading a multi-GB model is the slowest part of a restart.
|
||||||
|
"""
|
||||||
|
print("Stopping backend...")
|
||||||
|
stop_service(SERVICES["backend"])
|
||||||
|
# --no-desktop: an in-app update should not rewrite XFCE panels/theme.
|
||||||
|
rc = sync_py("restore", "--no-desktop")
|
||||||
|
print("\nRestarting backend...")
|
||||||
|
launch(SERVICES["backend"])
|
||||||
|
ok = wait_for_port(SERVICES["backend"])
|
||||||
|
print("Backend is back up." if ok else "Backend did not come back - see runtime/backend.log")
|
||||||
|
return rc if ok else 1
|
||||||
|
|
||||||
|
|
||||||
def cmd_clean() -> None:
|
def cmd_clean() -> None:
|
||||||
print("Cleaning Nexus runtime files...\n")
|
print("Cleaning Nexus runtime files...\n")
|
||||||
print("Removing PID files...")
|
print("Removing PID files...")
|
||||||
@@ -787,7 +803,6 @@ def cmd_web() -> int:
|
|||||||
started. Windows uses the pywebview window launch_nexus.ps1 opens."""
|
started. Windows uses the pywebview window launch_nexus.ps1 opens."""
|
||||||
if WINDOWS:
|
if WINDOWS:
|
||||||
if not http_ok(SERVICES["backend"].url):
|
if not http_ok(SERVICES["backend"].url):
|
||||||
launch(SERVICES["memory"])
|
|
||||||
launch(SERVICES["backend"])
|
launch(SERVICES["backend"])
|
||||||
wait_for_port(SERVICES["backend"])
|
wait_for_port(SERVICES["backend"])
|
||||||
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode
|
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode
|
||||||
@@ -811,14 +826,12 @@ Commands:
|
|||||||
show <id> Print a playbook's goal + instructions
|
show <id> Print a playbook's goal + instructions
|
||||||
history [query] Recent conversations (optional keyword)
|
history [query] Recent conversations (optional keyword)
|
||||||
|
|
||||||
start Start ALL Nexus services (memory + backend + frontend)
|
start Start ALL Nexus services (backend + frontend)
|
||||||
--memory, -m Start only the memory service
|
|
||||||
--frontend,-f Start only the frontend
|
--frontend,-f Start only the frontend
|
||||||
--backend, -b Start only the backend
|
--backend, -b Start only the backend
|
||||||
--ai, -a Start only the AI (Ollama); `start`/`start -b` already include it
|
--ai, -a Start only the AI (Ollama); `start`/`start -b` already include it
|
||||||
|
|
||||||
stop Stop ALL Nexus services
|
stop Stop ALL Nexus services
|
||||||
--memory, -m Stop only the memory service
|
|
||||||
--frontend,-f Stop only the frontend
|
--frontend,-f Stop only the frontend
|
||||||
--backend, -b Stop only the backend
|
--backend, -b Stop only the backend
|
||||||
|
|
||||||
@@ -827,12 +840,12 @@ Commands:
|
|||||||
|
|
||||||
status Show service status
|
status Show service status
|
||||||
logs Show logs for all services
|
logs Show logs for all services
|
||||||
--memory, -m Memory service logs
|
|
||||||
--frontend,-f Frontend logs
|
--frontend,-f Frontend logs
|
||||||
--backend, -b Backend logs
|
--backend, -b Backend logs
|
||||||
|
|
||||||
doctor [--fix] Run Nexus diagnostics (--fix applies safe repairs)
|
doctor [--fix] Run Nexus diagnostics (--fix applies safe repairs)
|
||||||
update Update Nexus dependencies
|
update Update Nexus dependencies
|
||||||
|
upgrade Pull, rebuild and restart the backend (what the UI's update button runs)
|
||||||
clean Remove runtime files and caches
|
clean Remove runtime files and caches
|
||||||
|
|
||||||
models
|
models
|
||||||
@@ -880,6 +893,8 @@ def main(argv) -> int:
|
|||||||
cmd_doctor(fix="--fix" in rest)
|
cmd_doctor(fix="--fix" in rest)
|
||||||
elif cmd == "update":
|
elif cmd == "update":
|
||||||
cmd_update()
|
cmd_update()
|
||||||
|
elif cmd == "upgrade":
|
||||||
|
return cmd_upgrade()
|
||||||
elif cmd == "clean":
|
elif cmd == "clean":
|
||||||
cmd_clean()
|
cmd_clean()
|
||||||
elif cmd == "nvidia-reqs":
|
elif cmd == "nvidia-reqs":
|
||||||
|
|||||||
+2
-1
@@ -97,7 +97,7 @@ skip-excluded-dirs = true
|
|||||||
path = "hatch_build.py"
|
path = "hatch_build.py"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["synapse", "nexusos_cli"]
|
packages = ["synapse", "nexusos_cli", "modules"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
"VERSION" = "synapse/_resources/VERSION"
|
"VERSION" = "synapse/_resources/VERSION"
|
||||||
@@ -114,6 +114,7 @@ include = [
|
|||||||
"/data/playbooks",
|
"/data/playbooks",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/management",
|
"/management",
|
||||||
|
"/modules",
|
||||||
"/nexusos_cli",
|
"/nexusos_cli",
|
||||||
"/scripts",
|
"/scripts",
|
||||||
"/synapse",
|
"/synapse",
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ python-docx
|
|||||||
sqlite-vec
|
sqlite-vec
|
||||||
# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
|
# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
|
||||||
faster-whisper
|
faster-whisper
|
||||||
|
# Email client: IMAP read (SMTP send is stdlib). Pure-Python, no native deps.
|
||||||
|
imap-tools
|
||||||
|
|
||||||
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
|
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
|
||||||
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
|
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
|
||||||
pywebview
|
pywebview
|
||||||
|
|
||||||
|
# Testing (bin/check.sh is the release gate; it shells out to pytest)
|
||||||
|
pytest
|
||||||
|
|||||||
+240
-58
@@ -13,6 +13,7 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
import os as _os
|
import os as _os
|
||||||
import platform as _platform
|
import platform as _platform
|
||||||
|
import sys as _sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, HTTPException, Body, Request
|
from fastapi import FastAPI, HTTPException, Body, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -29,8 +30,8 @@ from . import chat as _chat
|
|||||||
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
||||||
from . import frontend_manager as _frontend_manager
|
from . import frontend_manager as _frontend_manager
|
||||||
from . import playbook_manager
|
from . import playbook_manager
|
||||||
from .playbook_manager import PlaybookManager
|
|
||||||
from . import tools as _tools
|
from . import tools as _tools
|
||||||
|
from .memory.curator import extract_for_conversation
|
||||||
|
|
||||||
def _render_memory_block(facts) -> str:
|
def _render_memory_block(facts) -> str:
|
||||||
"""Render memory items as grouped ## Section / - bullet markdown.
|
"""Render memory items as grouped ## Section / - bullet markdown.
|
||||||
@@ -62,8 +63,11 @@ def _render_memory_block(facts) -> str:
|
|||||||
# the user. (A minimal header is kept so the facts have context; drop it entirely
|
# the user. (A minimal header is kept so the facts have context; drop it entirely
|
||||||
# to inject the raw facts block with no framing.)
|
# to inject the raw facts block with no framing.)
|
||||||
_MEMORY_PREAMBLE = (
|
_MEMORY_PREAMBLE = (
|
||||||
"\n\n---\nWhat you know about the user — use these facts freely and naturally to "
|
"\n\n---\nThe user is the person you are talking to right now — every user turn in "
|
||||||
"inform and personalize your replies:\n\n"
|
"this conversation is his. The facts below are about him, written in the third "
|
||||||
|
"person only because that is how they are stored; address him as \"you\", never "
|
||||||
|
"discuss him as an absent third party. Use them freely and naturally to inform "
|
||||||
|
"and personalize your replies:\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Static capability hint, appended to every system prompt. The live Preview UI
|
# Static capability hint, appended to every system prompt. The live Preview UI
|
||||||
@@ -211,8 +215,6 @@ from .curry_store import curry_db # noqa: F401 - import triggers Curry's own pr
|
|||||||
from .search import needs_web_search, web_search
|
from .search import needs_web_search, web_search
|
||||||
from . import slash_commands as _slash_commands
|
from . import slash_commands as _slash_commands
|
||||||
|
|
||||||
MEMORY_SERVICE = settings.memory_url
|
|
||||||
|
|
||||||
app = FastAPI(title="Synapse Backend", version=VERSION)
|
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||||
|
|
||||||
# Alias for startup scripts
|
# Alias for startup scripts
|
||||||
@@ -237,6 +239,13 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Modules ---
|
||||||
|
# Installed feature modules (mail, and whatever comes next) live under the
|
||||||
|
# repo-root modules/ package and mount their own APIRouter here.
|
||||||
|
from modules.registry import ROUTERS as _MODULE_ROUTERS
|
||||||
|
for _module_router in _MODULE_ROUTERS:
|
||||||
|
app.include_router(_module_router)
|
||||||
|
|
||||||
|
|
||||||
# --- Request-size cap ---
|
# --- Request-size cap ---
|
||||||
# Reject oversized bodies before they are buffered/decoded (a base64 upload or a
|
# Reject oversized bodies before they are buffered/decoded (a base64 upload or a
|
||||||
@@ -295,7 +304,6 @@ _CHAT_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_CHATS, "chat")
|
|||||||
_UPLOAD_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_UPLOADS, "document ingest")
|
_UPLOAD_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_UPLOADS, "document ingest")
|
||||||
|
|
||||||
# --- GLOBALS ---
|
# --- GLOBALS ---
|
||||||
playbooks = PlaybookManager()
|
|
||||||
ollama = None
|
ollama = None
|
||||||
|
|
||||||
|
|
||||||
@@ -316,6 +324,17 @@ async def startup_event():
|
|||||||
print("[Synapse] Ready. Ollama not auto-started (manual control).")
|
print("[Synapse] Ready. Ollama not auto-started (manual control).")
|
||||||
|
|
||||||
|
|
||||||
|
async def _ollama_status_async() -> Optional[str]:
|
||||||
|
"""to_thread, not a direct call: get_status() does blocking IO (a sync httpx
|
||||||
|
request and, once, a subprocess). Awaiting it inline stalls the whole event
|
||||||
|
loop for its duration - and both callers below are polled continuously by
|
||||||
|
the UI, so an inline call froze the entire server in lockstep with its own
|
||||||
|
health check."""
|
||||||
|
if ollama is None or not hasattr(ollama, "get_status"):
|
||||||
|
return None
|
||||||
|
return await _asyncio.to_thread(ollama.get_status)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Status
|
# Status
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -323,17 +342,150 @@ async def startup_event():
|
|||||||
@app.get("/status")
|
@app.get("/status")
|
||||||
async def root():
|
async def root():
|
||||||
try:
|
try:
|
||||||
# to_thread, not a direct call: get_status() does blocking IO (an httpx
|
status = await _ollama_status_async()
|
||||||
# request and, once, a subprocess). Awaiting it inline stalled the whole
|
|
||||||
# event loop on every poll - and the UI polls /status continuously, so
|
|
||||||
# the server froze in lockstep with its own health check.
|
|
||||||
status = (await _asyncio.to_thread(ollama.get_status)
|
|
||||||
if (ollama is not None and hasattr(ollama, "get_status")) else None)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
status = None
|
status = None
|
||||||
return {"status": "online", "version": VERSION, "ollama": status, "platform": _platform.system().lower()}
|
return {"status": "online", "version": VERSION, "ollama": status, "platform": _platform.system().lower()}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Update check
|
||||||
|
# -------------------------
|
||||||
|
# The update mechanism is git: `git pull` + rebuild, i.e. `python bin/sync.py
|
||||||
|
# restore` (./install.sh on Linux). So "is a newer build out there" is just
|
||||||
|
# "how many commits is origin/main ahead of HEAD" — no version server needed.
|
||||||
|
def _git(*args: str, timeout: int = 30) -> str:
|
||||||
|
import subprocess
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", *args], cwd=str(settings.project_root),
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
if out.returncode:
|
||||||
|
raise RuntimeError((out.stderr or out.stdout).strip() or "git failed")
|
||||||
|
return out.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_update() -> Dict[str, Any]:
|
||||||
|
_git("fetch", "--quiet", "origin", timeout=60)
|
||||||
|
behind = int(_git("rev-list", "--count", "HEAD..origin/main") or 0)
|
||||||
|
remote_version = VERSION
|
||||||
|
if behind:
|
||||||
|
with _contextlib.suppress(Exception):
|
||||||
|
remote_version = _git("show", "origin/main:VERSION").strip() or VERSION
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"remote_version": remote_version,
|
||||||
|
"behind": behind,
|
||||||
|
"latest": _git("log", "-1", "--format=%h %s", "origin/main") if behind else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/update/check")
|
||||||
|
async def update_check():
|
||||||
|
"""Compare this checkout against origin/main. Network call + subprocess, so
|
||||||
|
it runs off the event loop like the Ollama health check does."""
|
||||||
|
try:
|
||||||
|
return await _asyncio.to_thread(_check_update)
|
||||||
|
except Exception as e:
|
||||||
|
return {"version": VERSION, "behind": 0, "error": str(e)[:300]}
|
||||||
|
|
||||||
|
|
||||||
|
_update_running = False
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/update/apply")
|
||||||
|
async def update_apply():
|
||||||
|
"""Run `ncp upgrade` (git pull, rebuild, restart) fully detached.
|
||||||
|
|
||||||
|
It stops this very process, so it cannot be a child of it: a child would be
|
||||||
|
killed halfway through its own upgrade. Output goes to runtime/logs/update.log
|
||||||
|
because the UI cannot read /logs while the backend is down - that file is the
|
||||||
|
only record if the restart fails.
|
||||||
|
"""
|
||||||
|
global _update_running
|
||||||
|
import subprocess
|
||||||
|
if _update_running:
|
||||||
|
return {"started": False, "error": "An update is already running."}
|
||||||
|
log_path = settings.logs_dir / "update.log"
|
||||||
|
kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0)}
|
||||||
|
if _os.name == "nt" else {"start_new_session": True})
|
||||||
|
try:
|
||||||
|
log = open(log_path, "wb")
|
||||||
|
subprocess.Popen(
|
||||||
|
[_sys.executable, str(settings.project_root / "management" / "ncp.py"), "upgrade"],
|
||||||
|
cwd=str(settings.project_root), stdout=log, stderr=subprocess.STDOUT,
|
||||||
|
stdin=subprocess.DEVNULL, **kwargs,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return {"started": False, "error": str(e)[:300]}
|
||||||
|
_update_running = True
|
||||||
|
return {"started": True, "log": str(log_path)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Deferred memory extraction ------------------------------------------
|
||||||
|
# The curator reads a conversation when it has been quiet for a while, rather
|
||||||
|
# than after every exchange. Each new message reschedules, so "idle" means the
|
||||||
|
# user actually stopped — which is also the only reliable signal that a
|
||||||
|
# half-said fact is now complete.
|
||||||
|
_pending_extractions: Dict[str, _asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_idle_seconds() -> float:
|
||||||
|
try:
|
||||||
|
return max(5.0, float(store.get_settings().get("memory_extract_idle", 120)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 120.0
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_after_idle(conversation_id: str, project_id: str, delay: float) -> None:
|
||||||
|
try:
|
||||||
|
await _asyncio.sleep(delay)
|
||||||
|
saved = await extract_for_conversation(conversation_id, project_id)
|
||||||
|
if saved:
|
||||||
|
_synapse_trace(
|
||||||
|
f"\n◆ CURATOR (idle sweep): {len(saved)} fact(s) — "
|
||||||
|
+ "; ".join(f"[{i['section']}] {i['text']}" for i in saved) + "\n"
|
||||||
|
)
|
||||||
|
except _asyncio.CancelledError:
|
||||||
|
raise # rescheduled by a newer message; the watermark still has the work
|
||||||
|
except Exception as e:
|
||||||
|
_synapse_trace(f"\n⚠ idle extraction failed: {e}\n")
|
||||||
|
finally:
|
||||||
|
_pending_extractions.pop(conversation_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_extraction(conversation_id: str, project_id: str) -> None:
|
||||||
|
"""(Re)arm the idle sweep for one conversation."""
|
||||||
|
previous = _pending_extractions.pop(conversation_id, None)
|
||||||
|
if previous:
|
||||||
|
previous.cancel()
|
||||||
|
_pending_extractions[conversation_id] = _asyncio.create_task(
|
||||||
|
_extract_after_idle(conversation_id, project_id, _extract_idle_seconds())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _resume_dropped_extractions() -> None:
|
||||||
|
"""Pick up conversations whose sweep was lost to a restart. The watermark
|
||||||
|
makes this idempotent, so a conversation already read is skipped without
|
||||||
|
ever reaching the model."""
|
||||||
|
async def _bg():
|
||||||
|
await _asyncio.sleep(5) # let the app finish coming up first
|
||||||
|
try:
|
||||||
|
stale = store.conversations_awaiting_extraction(_extract_idle_seconds())
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for cid in stale:
|
||||||
|
try:
|
||||||
|
# One at a time: Ollama serialises anyway, and a burst here would
|
||||||
|
# sit in front of the user's first message of the session.
|
||||||
|
await extract_for_conversation(cid, store.conversation_project(cid) or "")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_asyncio.create_task(_bg())
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Chat (streaming)
|
# Chat (streaming)
|
||||||
async def _slash_command_stream(
|
async def _slash_command_stream(
|
||||||
@@ -414,7 +566,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
|
|
||||||
app_settings = store.get_settings()
|
app_settings = store.get_settings()
|
||||||
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
||||||
_active_pb = playbooks.get_main_playbook()
|
_active_pb = playbook_manager.get_main_playbook()
|
||||||
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
||||||
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
||||||
context = payload.get("context", {})
|
context = payload.get("context", {})
|
||||||
@@ -425,14 +577,28 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
||||||
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
||||||
|
|
||||||
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
|
# Resolve the project scope: an existing conversation keeps its bound project;
|
||||||
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
# a brand-new one inherits the current workspace (active_project setting).
|
||||||
|
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
||||||
|
_conv_proj = store.conversation_project(conversation_id)
|
||||||
|
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
|
||||||
|
|
||||||
# Fetch memory facts once — used for both playbook routing and system prompt injection
|
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
|
||||||
memory_facts = store.all()
|
system_prompt = playbook_manager.get_system_prompt() or app_settings.get("system_prompt", "")
|
||||||
|
|
||||||
|
# Fetch memory facts once — used for both playbook routing and system prompt
|
||||||
|
# injection. Global facts ("") always apply; the rest only inside their project.
|
||||||
|
memory_facts = [m for m in store.all() if m.project_id in ("", rag_scope)]
|
||||||
|
|
||||||
|
# Per-project instructions sit right under the playbook: they say how the
|
||||||
|
# assistant should behave for this project specifically.
|
||||||
|
project_instructions = store.project_instructions(rag_scope)
|
||||||
|
if project_instructions:
|
||||||
|
separator = "\n\n---\nProject instructions (follow these for this project):\n\n"
|
||||||
|
system_prompt = (system_prompt + separator + project_instructions) if system_prompt else project_instructions
|
||||||
|
|
||||||
# Append the best-matching reference playbook(s) to the system prompt
|
# Append the best-matching reference playbook(s) to the system prompt
|
||||||
context_pbs = _route_playbooks(rendered_message, playbooks.get_context_playbooks())
|
context_pbs = _route_playbooks(rendered_message, playbook_manager.get_context_playbooks())
|
||||||
if context_pbs:
|
if context_pbs:
|
||||||
refs = "\n\n".join(
|
refs = "\n\n".join(
|
||||||
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
|
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
|
||||||
@@ -463,11 +629,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
||||||
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
||||||
|
|
||||||
# Resolve the RAG scope: an existing conversation keeps its bound project;
|
|
||||||
# a brand-new one inherits the current workspace (active_project setting).
|
|
||||||
_conv_proj = store.conversation_project(conversation_id)
|
|
||||||
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
|
|
||||||
|
|
||||||
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
||||||
doc_hits = await store.search_documents(
|
doc_hits = await store.search_documents(
|
||||||
message, get_ollama_manager().embed,
|
message, get_ollama_manager().embed,
|
||||||
@@ -525,7 +686,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
else:
|
else:
|
||||||
_synapse_trace(f" INTENT: chat\n")
|
_synapse_trace(f" INTENT: chat\n")
|
||||||
|
|
||||||
_main_pb = playbooks.get_main_playbook()
|
_main_pb = playbook_manager.get_main_playbook()
|
||||||
if _main_pb:
|
if _main_pb:
|
||||||
_synapse_trace(f" PLAYBOOK: {_main_pb.title}\n")
|
_synapse_trace(f" PLAYBOOK: {_main_pb.title}\n")
|
||||||
if _main_pb.goal:
|
if _main_pb.goal:
|
||||||
@@ -553,6 +714,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
if past_context:
|
if past_context:
|
||||||
_synapse_trace(f" CONTEXT : {len(past_context)} past conversation match(es) injected\n")
|
_synapse_trace(f" CONTEXT : {len(past_context)} past conversation match(es) injected\n")
|
||||||
|
|
||||||
|
if rag_scope:
|
||||||
|
_synapse_trace(f" PROJECT : {rag_scope}{' [+instructions]' if project_instructions else ''}\n")
|
||||||
_synapse_trace(f" SYS LEN : {len(system_prompt)} chars\n")
|
_synapse_trace(f" SYS LEN : {len(system_prompt)} chars\n")
|
||||||
_synapse_trace(f"{'─' * 55}\n")
|
_synapse_trace(f"{'─' * 55}\n")
|
||||||
# ── end MindTrace pre-flight ──────────────────────────────────────
|
# ── end MindTrace pre-flight ──────────────────────────────────────
|
||||||
@@ -564,10 +727,18 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
if images:
|
if images:
|
||||||
metadata["images"] = images
|
metadata["images"] = images
|
||||||
|
|
||||||
# Tools: playbook allowlist, plus render_preview only when this turn
|
# Tools: playbook allowlist (main playbook AND the reference playbooks
|
||||||
# looks like a visual ask (always advertising it forced a non-stream
|
# _route_playbooks picked for this message — a routed playbook's
|
||||||
# tool round on every chat and felt like "stuck thinking").
|
# instructions are already in the prompt, so its abilities have to come
|
||||||
_pb_tools = list(getattr(_main_pb, "tools", None) or []) if _main_pb else []
|
# with them or the model narrates tools it was never given), plus
|
||||||
|
# render_preview/run_snippet on a cue even when no playbook grants them
|
||||||
|
# (always advertising render_preview forced a non-stream tool round on
|
||||||
|
# every chat and felt like "stuck thinking"). _policy/allow_actions were
|
||||||
|
# already computed above, in step with the capability-hint injection.
|
||||||
|
_pb_tools = list(dict.fromkeys(
|
||||||
|
(getattr(_main_pb, "tools", None) or [] if _main_pb else [])
|
||||||
|
+ [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])]
|
||||||
|
))
|
||||||
schemas_by_name: dict = {}
|
schemas_by_name: dict = {}
|
||||||
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
|
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
|
||||||
for s in _tools.standing_schemas():
|
for s in _tools.standing_schemas():
|
||||||
@@ -670,24 +841,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_synapse_trace(f"⚠ conversation titling step failed: {e}\n")
|
_synapse_trace(f"⚠ conversation titling step failed: {e}\n")
|
||||||
|
|
||||||
# Ask the memory service curator to evaluate this exchange
|
# Hand the conversation to the curator once it goes quiet. Not now:
|
||||||
|
# the curator is the chat model, and Ollama runs one request at a
|
||||||
|
# time (OLLAMA_NUM_PARALLEL=1), so extracting here would put the
|
||||||
|
# next message in a queue behind it.
|
||||||
if response_chunks:
|
if response_chunks:
|
||||||
try:
|
_schedule_extraction(conversation_id, rag_scope)
|
||||||
async with httpx.AsyncClient(timeout=310.0) as _mc:
|
|
||||||
r = await _mc.post(
|
|
||||||
f"{MEMORY_SERVICE}/memories/extract",
|
|
||||||
json={
|
|
||||||
"user_message": rendered_message,
|
|
||||||
"assistant_response": "".join(response_chunks),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()
|
|
||||||
for it in data.get("items", []):
|
|
||||||
mem_result = {"section": it["section"], "text": it["text"]}
|
|
||||||
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
|
|
||||||
except Exception as e:
|
|
||||||
_synapse_trace(f"\n⚠ memory extraction call failed: {e}\n")
|
|
||||||
|
|
||||||
# Hold the concurrency slot for the stream's lifetime, then release it
|
# Hold the concurrency slot for the stream's lifetime, then release it
|
||||||
# exactly once when the generator is exhausted or closed (client
|
# exactly once when the generator is exhausted or closed (client
|
||||||
@@ -795,8 +954,13 @@ async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
|
|||||||
# Memory
|
# Memory
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@app.get("/memory")
|
@app.get("/memory")
|
||||||
async def get_memory():
|
async def get_memory(project: Optional[str] = None):
|
||||||
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags} for m in store.all()]}
|
"""All facts, or — with ?project=<id> — just that scope's ('' = global)."""
|
||||||
|
items = store.all()
|
||||||
|
if project is not None:
|
||||||
|
items = [m for m in items if m.project_id == project]
|
||||||
|
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags,
|
||||||
|
"project_id": m.project_id} for m in items]}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/memory")
|
@app.post("/memory")
|
||||||
@@ -811,9 +975,11 @@ async def add_memory(payload: Dict[str, Any] = Body(...)):
|
|||||||
section=(payload.get("section") or "General").strip(),
|
section=(payload.get("section") or "General").strip(),
|
||||||
text=text,
|
text=text,
|
||||||
tags=payload.get("tags", []),
|
tags=payload.get("tags", []),
|
||||||
|
project_id=payload.get("project_id") or "",
|
||||||
)
|
)
|
||||||
store.add(item)
|
store.add(item)
|
||||||
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
|
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags,
|
||||||
|
"project_id": item.project_id}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/memory/{item_id}")
|
@app.patch("/memory/{item_id}")
|
||||||
@@ -827,9 +993,11 @@ async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
|
|||||||
section=(payload.get("section") or existing.section or "General").strip(),
|
section=(payload.get("section") or existing.section or "General").strip(),
|
||||||
text=(payload.get("text") or existing.text).rstrip(),
|
text=(payload.get("text") or existing.text).rstrip(),
|
||||||
tags=payload.get("tags", existing.tags),
|
tags=payload.get("tags", existing.tags),
|
||||||
|
project_id=payload.get("project_id", existing.project_id),
|
||||||
)
|
)
|
||||||
store.update(updated)
|
store.update(updated)
|
||||||
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
|
return {"id": updated.id, "section": updated.section, "text": updated.text,
|
||||||
|
"tags": updated.tags, "project_id": updated.project_id}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/memory/{item_id}")
|
@app.delete("/memory/{item_id}")
|
||||||
@@ -942,10 +1110,7 @@ async def delete_model(name: str):
|
|||||||
@app.get("/ollama/status")
|
@app.get("/ollama/status")
|
||||||
async def ollama_status_endpoint():
|
async def ollama_status_endpoint():
|
||||||
try:
|
try:
|
||||||
if ollama is not None and hasattr(ollama, "get_status"):
|
status = await _ollama_status_async()
|
||||||
status = ollama.get_status()
|
|
||||||
else:
|
|
||||||
status = None
|
|
||||||
return {"status": status}
|
return {"status": status}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -1249,6 +1414,8 @@ async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
|
|||||||
return {"text": text}
|
return {"text": text}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Projects / workspaces
|
# Projects / workspaces
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -1270,6 +1437,15 @@ async def create_project(payload: Dict[str, Any] = Body(...)):
|
|||||||
return store.create_project(name)
|
return store.create_project(name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/projects/{project_id}")
|
||||||
|
async def update_project(project_id: str, payload: Dict[str, Any] = Body(...)):
|
||||||
|
"""Set the project's instructions — layered into the system prompt of every
|
||||||
|
chat bound to this project."""
|
||||||
|
if not store.set_project_instructions(project_id, payload.get("instructions") or ""):
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return {"id": project_id, "instructions": payload.get("instructions") or ""}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/projects/{project_id}")
|
@app.delete("/projects/{project_id}")
|
||||||
async def delete_project(project_id: str):
|
async def delete_project(project_id: str):
|
||||||
if not store.delete_project(project_id):
|
if not store.delete_project(project_id):
|
||||||
@@ -1382,9 +1558,11 @@ async def delete_document(doc_id: str):
|
|||||||
# Conversations
|
# Conversations
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@app.get("/conversations")
|
@app.get("/conversations")
|
||||||
async def get_conversations(q: Optional[str] = None):
|
async def get_conversations(q: Optional[str] = None, project: Optional[str] = None):
|
||||||
try:
|
try:
|
||||||
conversations = store.all_conversations()
|
conversations = store.all_conversations()
|
||||||
|
if project is not None:
|
||||||
|
conversations = [c for c in conversations if c.project_id == project]
|
||||||
if q:
|
if q:
|
||||||
q_lower = q.lower()
|
q_lower = q.lower()
|
||||||
conversations = [
|
conversations = [
|
||||||
@@ -1400,6 +1578,7 @@ async def get_conversations(q: Optional[str] = None):
|
|||||||
"updated_at": c.updated_at,
|
"updated_at": c.updated_at,
|
||||||
"preview": c.preview,
|
"preview": c.preview,
|
||||||
"title": c.title,
|
"title": c.title,
|
||||||
|
"project_id": c.project_id,
|
||||||
}
|
}
|
||||||
for c in conversations
|
for c in conversations
|
||||||
]
|
]
|
||||||
@@ -1477,17 +1656,20 @@ async def get_conversation(conversation_id: str):
|
|||||||
|
|
||||||
@app.patch("/conversations/{conversation_id}")
|
@app.patch("/conversations/{conversation_id}")
|
||||||
async def rename_conversation(conversation_id: str, payload: Dict[str, Any] = Body(...)):
|
async def rename_conversation(conversation_id: str, payload: Dict[str, Any] = Body(...)):
|
||||||
"""Manually set a conversation's title."""
|
"""Set a conversation's title and/or move it into a project."""
|
||||||
try:
|
try:
|
||||||
conv = store.get_conversation(conversation_id)
|
conv = store.get_conversation(conversation_id)
|
||||||
if not conv:
|
if not conv:
|
||||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
if "project_id" in payload:
|
||||||
|
store.set_conversation_project(conversation_id, payload.get("project_id") or "")
|
||||||
title = (payload.get("title") or "").strip()
|
title = (payload.get("title") or "").strip()
|
||||||
if not title:
|
if title:
|
||||||
raise HTTPException(status_code=400, detail="Missing 'title'")
|
|
||||||
title = " ".join(title.split())[:120]
|
title = " ".join(title.split())[:120]
|
||||||
store.set_conversation_title(conversation_id, title)
|
store.set_conversation_title(conversation_id, title)
|
||||||
return {"id": conversation_id, "title": title}
|
elif "project_id" not in payload:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing 'title'")
|
||||||
|
return {"id": conversation_id, "title": title or conv.title}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Curated memory extraction — read a finished conversation, save what's new.
|
||||||
|
|
||||||
|
This is the whole memory-writing path: pick up the messages the curator has not
|
||||||
|
read yet, ask the model which permanent facts they contain, merge each result
|
||||||
|
against what is already stored, and move the conversation's watermark.
|
||||||
|
|
||||||
|
It runs in-process. Extraction used to sit behind an HTTP call to a separate
|
||||||
|
service on :8001 holding a second, smaller model, because that model could not
|
||||||
|
share the GPU with the chat model. The curator is the chat model now — already
|
||||||
|
resident, already warm — so the extra process bought nothing but a hop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .store import store, MemoryItem
|
||||||
|
from .extractor import extract_memory
|
||||||
|
from ..ollama_manager import get_ollama_manager
|
||||||
|
|
||||||
|
_EXTRACT_TIMEOUT = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine(a: list, b: list) -> float:
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
na = math.sqrt(sum(x * x for x in a))
|
||||||
|
nb = math.sqrt(sum(y * y for y in b))
|
||||||
|
return dot / (na * nb) if na and nb else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_for_conversation(conversation_id: str, project_id: str = "") -> list[dict]:
|
||||||
|
"""Extract and save facts from a conversation's unread messages.
|
||||||
|
|
||||||
|
Returns the saved/updated items. Safe to call more than once: the watermark
|
||||||
|
means a second run with no new messages does nothing and never reaches the
|
||||||
|
model. Never raises — memory extraction must not take the caller down.
|
||||||
|
"""
|
||||||
|
messages, last_id = store.pending_extraction(conversation_id)
|
||||||
|
if not messages:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Only global facts and this project's are in scope: another project's facts
|
||||||
|
# must not be shown to the curator as "already known", or as a merge target.
|
||||||
|
existing = [m for m in store.all() if m.project_id in ("", project_id)]
|
||||||
|
existing_sections = list({i.section for i in existing})
|
||||||
|
existing_texts = [i.text for i in existing]
|
||||||
|
|
||||||
|
settings = store.get_settings()
|
||||||
|
mgr = get_ollama_manager()
|
||||||
|
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||||
|
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", -1), model)
|
||||||
|
try:
|
||||||
|
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
merge_threshold = 0.88
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await asyncio.wait_for(
|
||||||
|
extract_memory(
|
||||||
|
messages, existing_sections, existing_texts, mgr,
|
||||||
|
model=model, num_gpu=num_gpu,
|
||||||
|
),
|
||||||
|
timeout=_EXTRACT_TIMEOUT,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Leave the watermark alone so the next sweep retries these messages.
|
||||||
|
return []
|
||||||
|
|
||||||
|
saved = []
|
||||||
|
try:
|
||||||
|
# Embed existing facts once so each new fact can be matched against them.
|
||||||
|
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
||||||
|
# rather than appending a copy. Best effort: if embeddings are down we
|
||||||
|
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
||||||
|
existing_embeds: dict = {}
|
||||||
|
if results and 0 < merge_threshold < 1:
|
||||||
|
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
||||||
|
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
||||||
|
match_id, best = None, 0.0
|
||||||
|
if new_vec:
|
||||||
|
for eid, ev in existing_embeds.items():
|
||||||
|
sim = _cosine(new_vec, ev)
|
||||||
|
if sim > best:
|
||||||
|
best, match_id = sim, eid
|
||||||
|
if best < merge_threshold:
|
||||||
|
match_id = None
|
||||||
|
|
||||||
|
target = store.get(match_id) if match_id else None
|
||||||
|
if target:
|
||||||
|
# Near-duplicate of an existing fact — overwrite with the newer
|
||||||
|
# statement, keeping the original id/section/position.
|
||||||
|
updated = MemoryItem(id=target.id, section=target.section,
|
||||||
|
text=result["text"], tags=target.tags,
|
||||||
|
project_id=target.project_id)
|
||||||
|
store.update(updated)
|
||||||
|
if new_vec:
|
||||||
|
existing_embeds[updated.id] = new_vec
|
||||||
|
saved.append({"id": updated.id, "section": updated.section,
|
||||||
|
"text": updated.text, "updated": True})
|
||||||
|
else:
|
||||||
|
item = MemoryItem(id=str(uuid.uuid4()), section=result["section"],
|
||||||
|
text=result["text"], project_id=project_id)
|
||||||
|
store.add(item)
|
||||||
|
if new_vec:
|
||||||
|
existing_embeds[item.id] = new_vec
|
||||||
|
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Watermark even when nothing was saved — the model has read these messages
|
||||||
|
# and re-reading them would just spend another call to reach the same "no".
|
||||||
|
store.set_extracted_through(conversation_id, last_id)
|
||||||
|
return saved
|
||||||
+55
-20
@@ -1,5 +1,12 @@
|
|||||||
"""Memory extraction — asks Mistral to evaluate a conversation exchange and
|
"""Memory extraction — asks the curator model to read a finished conversation
|
||||||
decide if it contains a new permanent personal fact worth saving."""
|
and decide which new permanent personal facts it contains.
|
||||||
|
|
||||||
|
Reads the whole transcript, not a single exchange. A fact is rarely complete in
|
||||||
|
the turn that introduces it ("I have a Honda" at turn 3 becomes a 2006 Accord
|
||||||
|
with 312k miles by turn 7), and the store is append-only for the curator, so
|
||||||
|
extracting per-exchange could only ever accumulate fragments of the same fact.
|
||||||
|
Waiting until the conversation is idle is also the only reliable signal that a
|
||||||
|
statement is finished rather than half-said."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,13 +24,19 @@ _TR = "┅" * 55
|
|||||||
_PROMPT = """\
|
_PROMPT = """\
|
||||||
You are a memory curator for a personal AI assistant named Nexus.
|
You are a memory curator for a personal AI assistant named Nexus.
|
||||||
|
|
||||||
Extract EVERY new, permanent personal fact the USER stated in this exchange.
|
Extract EVERY new, permanent personal fact the USER stated in this conversation.
|
||||||
There may be SEVERAL facts in one message — output one JSON object for each.
|
There may be SEVERAL facts across the conversation — output one JSON object for
|
||||||
|
each.
|
||||||
|
|
||||||
ONLY the USER line is a source of facts. The ASSISTANT line and the existing
|
ONLY the USER lines are a source of facts. The ASSISTANT lines and the existing
|
||||||
memory below are context to help you understand the USER line — never extract
|
memory below are context to help you understand the USER lines — never extract
|
||||||
anything from them. If the assistant said it and the user did not, it is NOT a
|
anything from them. If the assistant said it and the user did not, it is NOT a
|
||||||
fact. Copy what the user actually said; do not infer, embellish, or add a
|
fact.
|
||||||
|
|
||||||
|
You are reading the WHOLE conversation, so record the FINAL, most complete form
|
||||||
|
of each fact. If the user gives a detail early and refines it later, output one
|
||||||
|
object with the refined version — never one per stage. If the user corrects or
|
||||||
|
retracts something, keep only what they ended up saying. Copy what the user actually said; do not infer, embellish, or add a
|
||||||
judgement the user did not make (never call something their "favorite",
|
judgement the user did not make (never call something their "favorite",
|
||||||
"main", or "best" unless the user used that word).
|
"main", or "best" unless the user used that word).
|
||||||
|
|
||||||
@@ -56,8 +69,7 @@ Only invent a new section if none fit, and make it a SHORT single word
|
|||||||
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
|
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
|
||||||
|
|
||||||
---
|
---
|
||||||
USER: {user_message}
|
{transcript}
|
||||||
ASSISTANT: {assistant_response}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Respond with JSON only — no prose, no markdown fences. Output one object PER
|
Respond with JSON only — no prose, no markdown fences. Output one object PER
|
||||||
@@ -95,11 +107,11 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
|||||||
prompt is worded (verified against mistral:7b):
|
prompt is worded (verified against mistral:7b):
|
||||||
|
|
||||||
1. Absence claims. It reads the existing-memory block and writes things like
|
1. Absence claims. It reads the existing-memory block and writes things like
|
||||||
"Jon does not have any pets" - which contradicted four cats already on
|
"the user does not have any pets" - which contradicted four cats already on
|
||||||
file. Silence is not a fact.
|
file. Silence is not a fact.
|
||||||
2. Assistant-sourced specifics. It lifts names the ASSISTANT said and
|
2. Assistant-sourced specifics. It lifts names the ASSISTANT said and
|
||||||
attributes them to the user: a reply that echoed a stale memory row
|
attributes them to the user: a reply that echoed a stale memory row
|
||||||
produced "Jon's main development machine is a MacBook Pro" off the user
|
produced "the user's main development machine is a MacBook Pro" off the user
|
||||||
message "What am I developing on?".
|
message "What am I developing on?".
|
||||||
|
|
||||||
The grounding test only fires when a fact carries distinctive tokens and
|
The grounding test only fires when a fact carries distinctive tokens and
|
||||||
@@ -114,20 +126,44 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def render_transcript(messages: list[dict]) -> str:
|
||||||
|
"""The conversation as the curator sees it. Assistant turns are truncated
|
||||||
|
hard: they are context for reading the user's lines, never a fact source,
|
||||||
|
and a long reply would otherwise crowd out the lines that matter."""
|
||||||
|
lines = []
|
||||||
|
for m in messages:
|
||||||
|
role = (m.get("role") or "").upper()
|
||||||
|
if role not in ("USER", "ASSISTANT"):
|
||||||
|
continue
|
||||||
|
text = (m.get("content") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
lines.append(f"{role}: {text[:3000] if role == 'USER' else text[:600]}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def extract_memory(
|
async def extract_memory(
|
||||||
user_message: str,
|
messages: list[dict],
|
||||||
assistant_response: str,
|
|
||||||
existing_sections: list[str],
|
existing_sections: list[str],
|
||||||
existing_texts: list[str],
|
existing_texts: list[str],
|
||||||
ollama_manager,
|
ollama_manager,
|
||||||
model: str = DEFAULT_MEMORY_MODEL,
|
model: str = DEFAULT_MEMORY_MODEL,
|
||||||
num_gpu: int | None = 0,
|
num_gpu: int | None = 0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
|
"""Extract saveable memory facts from a whole conversation.
|
||||||
|
|
||||||
Returns a list of {"section": ..., "text": ...} — possibly empty. A single
|
`messages` is the transcript as [{role, content}]. Returns a list of
|
||||||
exchange can hold several facts, and Mistral emits one JSON object per fact.
|
{"section": ..., "text": ...} — possibly empty. One conversation can hold
|
||||||
|
several facts, and the model emits one JSON object per fact.
|
||||||
"""
|
"""
|
||||||
|
transcript = render_transcript(messages)
|
||||||
|
if not transcript:
|
||||||
|
return []
|
||||||
|
# Grounding is checked against everything the user actually typed, so a fact
|
||||||
|
# assembled from details spread across several of their turns still passes.
|
||||||
|
user_text = "\n".join(
|
||||||
|
(m.get("content") or "") for m in messages if (m.get("role") or "") == "user"
|
||||||
|
)
|
||||||
if existing_texts:
|
if existing_texts:
|
||||||
# ponytail: only the 12 most-recent facts go in the dedup context, not all
|
# ponytail: only the 12 most-recent facts go in the dedup context, not all
|
||||||
# ~40. On a CPU-bound curator (num_gpu=0) prompt-eval dominates, and 40
|
# ~40. On a CPU-bound curator (num_gpu=0) prompt-eval dominates, and 40
|
||||||
@@ -148,8 +184,7 @@ async def extract_memory(
|
|||||||
prompt = _PROMPT.format(
|
prompt = _PROMPT.format(
|
||||||
existing_texts=texts_block,
|
existing_texts=texts_block,
|
||||||
existing_sections=sections_line,
|
existing_sections=sections_line,
|
||||||
user_message=user_message[:3000],
|
transcript=transcript,
|
||||||
assistant_response=assistant_response[:800],
|
|
||||||
)
|
)
|
||||||
# MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
|
# MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
|
||||||
# the same console as the frontline model, not just Python warnings on failure.
|
# the same console as the frontline model, not just Python warnings on failure.
|
||||||
@@ -194,7 +229,7 @@ async def extract_memory(
|
|||||||
def _keep(o):
|
def _keep(o):
|
||||||
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
|
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
|
||||||
fact = str(o["text"]).strip()
|
fact = str(o["text"]).strip()
|
||||||
reason = _reject_reason(fact, user_message)
|
reason = _reject_reason(fact, user_text)
|
||||||
if reason:
|
if reason:
|
||||||
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
|
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
|
||||||
return
|
return
|
||||||
@@ -217,7 +252,7 @@ async def extract_memory(
|
|||||||
else:
|
else:
|
||||||
_keep(obj)
|
_keep(obj)
|
||||||
if not results:
|
if not results:
|
||||||
_log.warning("memory: nothing saved. mistral said: %.300r", text)
|
_log.warning("memory: nothing saved. curator said: %.300r", text)
|
||||||
_synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
|
_synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
|
||||||
else:
|
else:
|
||||||
_facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
|
_facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
"""Dedicated memory curator service — run alongside Synapse on port 8001.
|
|
||||||
|
|
||||||
Endpoints:
|
|
||||||
GET / health check
|
|
||||||
GET /memories list all memory items (optional ?section= filter)
|
|
||||||
POST /memories direct write — no LLM, saves immediately
|
|
||||||
PATCH /memories/{id} update a memory item
|
|
||||||
DELETE /memories/{id} delete a memory item
|
|
||||||
POST /memories/extract LLM-curated: evaluate a conversation exchange and
|
|
||||||
optionally save a new permanent fact
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import math
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Body
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from .store import store, MemoryItem
|
|
||||||
from .extractor import extract_memory
|
|
||||||
from ..ollama_manager import get_ollama_manager
|
|
||||||
from ..nexus_config import ALLOWED_HOSTS, ALLOWED_ORIGINS
|
|
||||||
|
|
||||||
app = FastAPI(title="Nexus Memory Service", version="1.0")
|
|
||||||
|
|
||||||
# Same unauthenticated-local-only posture as the main backend: reject foreign
|
|
||||||
# Host headers (anti DNS-rebind) and scope CORS to known local origins rather
|
|
||||||
# than "*". See nexus_config.ALLOWED_HOSTS / ALLOWED_ORIGINS for env overrides.
|
|
||||||
if "*" not in ALLOWED_HOSTS:
|
|
||||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=ALLOWED_ORIGINS,
|
|
||||||
allow_credentials=False,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def _warm_curator():
|
|
||||||
"""Preload the curator model (in RAM, num_gpu=0 by default) so the first
|
|
||||||
extraction isn't a cold load that blows the timeout. Runs in the background
|
|
||||||
so it never delays startup. keep_alive then holds it warm between messages."""
|
|
||||||
async def _bg():
|
|
||||||
try:
|
|
||||||
mgr = get_ollama_manager()
|
|
||||||
# Ollama is started by the Synapse backend (a separate process), so
|
|
||||||
# at our startup it usually isn't reachable yet. Wait for it before
|
|
||||||
# warming instead of failing with "All connection attempts failed" —
|
|
||||||
# which leaves the curator cold and makes the first extraction slow.
|
|
||||||
for _ in range(60): # up to ~2 min
|
|
||||||
if await asyncio.to_thread(mgr.is_running):
|
|
||||||
break
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
settings = store.get_settings()
|
|
||||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
|
||||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
|
||||||
await mgr.warm(model, num_gpu=num_gpu)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
asyncio.create_task(_bg())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok", "count": len(store.all())}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/memories")
|
|
||||||
async def list_memories(section: Optional[str] = None):
|
|
||||||
items = store.all()
|
|
||||||
if section:
|
|
||||||
items = [i for i in items if i.section.lower() == section.lower()]
|
|
||||||
return {"items": [{"id": i.id, "section": i.section, "text": i.text, "tags": i.tags} for i in items]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/memories")
|
|
||||||
async def add_memory(payload: Dict[str, Any] = Body(...)):
|
|
||||||
text = (payload.get("text") or "").strip()
|
|
||||||
if not text:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing 'text'")
|
|
||||||
item = MemoryItem(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
section=(payload.get("section") or "General").strip(),
|
|
||||||
text=text,
|
|
||||||
tags=payload.get("tags", []),
|
|
||||||
)
|
|
||||||
store.add(item)
|
|
||||||
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/memories/{item_id}")
|
|
||||||
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
|
|
||||||
existing = store.get(item_id)
|
|
||||||
if not existing:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
updated = MemoryItem(
|
|
||||||
id=item_id,
|
|
||||||
section=(payload.get("section") or existing.section).strip(),
|
|
||||||
text=(payload.get("text") or existing.text).strip(),
|
|
||||||
tags=payload.get("tags", existing.tags),
|
|
||||||
)
|
|
||||||
store.update(updated)
|
|
||||||
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/memories/{item_id}")
|
|
||||||
async def delete_memory(item_id: str):
|
|
||||||
if not store.get(item_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
store.delete(item_id)
|
|
||||||
return {"status": "deleted"}
|
|
||||||
|
|
||||||
|
|
||||||
def _cosine(a: list, b: list) -> float:
|
|
||||||
dot = sum(x * y for x, y in zip(a, b))
|
|
||||||
na = math.sqrt(sum(x * x for x in a))
|
|
||||||
nb = math.sqrt(sum(y * y for y in b))
|
|
||||||
return dot / (na * nb) if na and nb else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class ExtractRequest(BaseModel):
|
|
||||||
user_message: str
|
|
||||||
assistant_response: str
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/memories/extract")
|
|
||||||
async def extract_and_save(req: ExtractRequest):
|
|
||||||
"""LLM-curated extraction — asks Mistral to evaluate the exchange against all
|
|
||||||
existing memory items and save only new, permanent personal facts."""
|
|
||||||
existing = store.all()
|
|
||||||
existing_sections = list({i.section for i in existing})
|
|
||||||
existing_texts = [i.text for i in existing]
|
|
||||||
|
|
||||||
# Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
|
|
||||||
# which model does extraction, and whether it runs on CPU/RAM or the GPU.
|
|
||||||
settings = store.get_settings()
|
|
||||||
mgr = get_ollama_manager()
|
|
||||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
|
||||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
|
||||||
try:
|
|
||||||
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
merge_threshold = 0.88
|
|
||||||
|
|
||||||
try:
|
|
||||||
results = await asyncio.wait_for(
|
|
||||||
extract_memory(
|
|
||||||
req.user_message,
|
|
||||||
req.assistant_response,
|
|
||||||
existing_sections,
|
|
||||||
existing_texts,
|
|
||||||
mgr,
|
|
||||||
model=model,
|
|
||||||
num_gpu=num_gpu,
|
|
||||||
),
|
|
||||||
timeout=300.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Embed existing facts once so each new fact can be matched against them.
|
|
||||||
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
|
||||||
# rather than appending a copy. Best effort: if embeddings are down we
|
|
||||||
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
|
||||||
existing_embeds: dict = {}
|
|
||||||
if results and 0 < merge_threshold < 1:
|
|
||||||
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
|
||||||
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
|
||||||
|
|
||||||
saved = []
|
|
||||||
for result in results:
|
|
||||||
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
|
||||||
match_id, best = None, 0.0
|
|
||||||
if new_vec:
|
|
||||||
for eid, ev in existing_embeds.items():
|
|
||||||
sim = _cosine(new_vec, ev)
|
|
||||||
if sim > best:
|
|
||||||
best, match_id = sim, eid
|
|
||||||
if best < merge_threshold:
|
|
||||||
match_id = None
|
|
||||||
|
|
||||||
target = store.get(match_id) if match_id else None
|
|
||||||
if target:
|
|
||||||
# Near-duplicate of an existing fact — overwrite with the newer
|
|
||||||
# statement, keeping the original id/section/position.
|
|
||||||
updated = MemoryItem(id=target.id, section=target.section,
|
|
||||||
text=result["text"], tags=target.tags)
|
|
||||||
store.update(updated)
|
|
||||||
if new_vec:
|
|
||||||
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
|
|
||||||
saved.append({"id": updated.id, "section": updated.section,
|
|
||||||
"text": updated.text, "updated": True})
|
|
||||||
else:
|
|
||||||
item = MemoryItem(id=str(uuid.uuid4()),
|
|
||||||
section=result["section"], text=result["text"])
|
|
||||||
store.add(item)
|
|
||||||
if new_vec:
|
|
||||||
existing_embeds[item.id] = new_vec
|
|
||||||
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
|
||||||
if saved:
|
|
||||||
first = {k: saved[0][k] for k in ("id", "section", "text")}
|
|
||||||
return {"saved": True, "items": saved, **first}
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"saved": False}
|
|
||||||
+143
-14
@@ -30,6 +30,7 @@ class MemoryItem(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
position: int = 0
|
position: int = 0
|
||||||
|
project_id: str = "" # "" = global: injected into every chat
|
||||||
|
|
||||||
class MessageItem(BaseModel):
|
class MessageItem(BaseModel):
|
||||||
role: str # "user" or "assistant"
|
role: str # "user" or "assistant"
|
||||||
@@ -44,6 +45,7 @@ class ConversationItem(BaseModel):
|
|||||||
created_at: float
|
created_at: float
|
||||||
updated_at: float
|
updated_at: float
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
project_id: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def preview(self) -> str:
|
def preview(self) -> str:
|
||||||
@@ -127,6 +129,12 @@ class PersistentMemoryStore:
|
|||||||
if cur.fetchone()[0] == 0:
|
if cur.fetchone()[0] == 0:
|
||||||
cur.execute("UPDATE memory SET position = rowid")
|
cur.execute("UPDATE memory SET position = rowid")
|
||||||
|
|
||||||
|
# Migrate: scope a fact to a project ("" = global, applies everywhere).
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE memory ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -145,6 +153,24 @@ class PersistentMemoryStore:
|
|||||||
cur.execute("ALTER TABLE conversations ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
cur.execute("ALTER TABLE conversations ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Migrate: high-water mark for memory extraction — the id of the last
|
||||||
|
# message the curator has already read. Extraction runs once the
|
||||||
|
# conversation goes idle rather than after every exchange, so this is
|
||||||
|
# what makes it idempotent and restart-safe: a backend that dies with a
|
||||||
|
# pending sweep resumes from here instead of re-reading the whole
|
||||||
|
# transcript and re-saving facts it already saved.
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE conversations ADD COLUMN extracted_through INTEGER NOT NULL DEFAULT 0")
|
||||||
|
# Only reached the first time the column is added. Everything already
|
||||||
|
# in the database was extracted per-exchange under the old design, so
|
||||||
|
# watermark it as read — without this the first idle sweep would
|
||||||
|
# re-read every historical conversation and re-save its facts.
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE conversations SET extracted_through = "
|
||||||
|
"COALESCE((SELECT MAX(id) FROM messages WHERE conversation_id = conversations.id), 0)"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -204,6 +230,11 @@ class PersistentMemoryStore:
|
|||||||
created_at REAL NOT NULL
|
created_at REAL NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# projects.instructions — per-project system prompt ("" = none).
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE projects ADD COLUMN instructions TEXT NOT NULL DEFAULT ''")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# documents.project_id — "" (or missing) means unscoped / All.
|
# documents.project_id — "" (or missing) means unscoped / All.
|
||||||
try:
|
try:
|
||||||
cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||||
@@ -231,7 +262,7 @@ class PersistentMemoryStore:
|
|||||||
def _load_all_memory(self) -> Dict[str, MemoryItem]:
|
def _load_all_memory(self) -> Dict[str, MemoryItem]:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -247,6 +278,7 @@ class PersistentMemoryStore:
|
|||||||
text=row["text"],
|
text=row["text"],
|
||||||
tags=tags,
|
tags=tags,
|
||||||
position=row["position"] or 0,
|
position=row["position"] or 0,
|
||||||
|
project_id=row["project_id"] or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cache
|
return cache
|
||||||
@@ -273,8 +305,10 @@ class PersistentMemoryStore:
|
|||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT OR REPLACE INTO memory (id, section, text, tags, position) VALUES (?, ?, ?, ?, ?)",
|
"INSERT OR REPLACE INTO memory (id, section, text, tags, position, project_id)"
|
||||||
(item.id, item.section or "General", item.text, json.dumps(item.tags), item.position)
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(item.id, item.section or "General", item.text, json.dumps(item.tags),
|
||||||
|
item.position, item.project_id or "")
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
@@ -320,12 +354,12 @@ class PersistentMemoryStore:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
def all(self) -> List[MemoryItem]:
|
def all(self) -> List[MemoryItem]:
|
||||||
# Always read from DB — the memory service and backend run in separate processes
|
# Always read from DB, never the cache: the CLI, the control panel and
|
||||||
# with separate caches, so the cache can be stale for facts extracted by the
|
# the backend are separate processes with separate caches, so a fact
|
||||||
# memory service after this process started.
|
# written by one is invisible to another's cache.
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
items = []
|
items = []
|
||||||
@@ -340,6 +374,7 @@ class PersistentMemoryStore:
|
|||||||
text=row["text"],
|
text=row["text"],
|
||||||
tags=tags,
|
tags=tags,
|
||||||
position=row["position"] or 0,
|
position=row["position"] or 0,
|
||||||
|
project_id=row["project_id"] or "",
|
||||||
))
|
))
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -407,6 +442,18 @@ class PersistentMemoryStore:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def set_conversation_project(self, conversation_id: str, project_id: str):
|
||||||
|
"""Move a conversation into a project ('' = unscoped)."""
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE conversations SET project_id = ? WHERE id = ?",
|
||||||
|
(project_id or "", conversation_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def add_message(self, conversation_id: str, role: str, content: str, model: Optional[str] = None, tokens: Optional[int] = None):
|
def add_message(self, conversation_id: str, role: str, content: str, model: Optional[str] = None, tokens: Optional[int] = None):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
@@ -454,7 +501,7 @@ class PersistentMemoryStore:
|
|||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT c.id, c.created_at, c.updated_at, c.title,
|
SELECT c.id, c.created_at, c.updated_at, c.title, c.project_id,
|
||||||
m.role, m.content, m.timestamp, m.model, m.tokens
|
m.role, m.content, m.timestamp, m.model, m.tokens
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
LEFT JOIN messages m ON m.conversation_id = c.id
|
LEFT JOIN messages m ON m.conversation_id = c.id
|
||||||
@@ -473,6 +520,7 @@ class PersistentMemoryStore:
|
|||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
title=row["title"],
|
title=row["title"],
|
||||||
|
project_id=row["project_id"] or "",
|
||||||
)
|
)
|
||||||
order.append(cid)
|
order.append(cid)
|
||||||
if row["role"] is not None:
|
if row["role"] is not None:
|
||||||
@@ -481,10 +529,62 @@ class PersistentMemoryStore:
|
|||||||
)
|
)
|
||||||
return [convs[cid] for cid in order]
|
return [convs[cid] for cid in order]
|
||||||
|
|
||||||
|
def pending_extraction(self, conversation_id: str) -> tuple[list, int]:
|
||||||
|
"""Messages the curator has not read yet, and the id to watermark to.
|
||||||
|
|
||||||
|
Returns ([{role, content}], last_id). An empty list means nothing new,
|
||||||
|
so callers can skip the model call entirely.
|
||||||
|
"""
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT extracted_through FROM conversations WHERE id = ?", (conversation_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return [], 0
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, role, content FROM messages "
|
||||||
|
"WHERE conversation_id = ? AND id > ? ORDER BY id ASC",
|
||||||
|
(conversation_id, row["extracted_through"] or 0),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not rows:
|
||||||
|
return [], 0
|
||||||
|
return ([{"role": r["role"], "content": r["content"]} for r in rows], rows[-1]["id"])
|
||||||
|
|
||||||
|
def set_extracted_through(self, conversation_id: str, message_id: int) -> None:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE conversations SET extracted_through = ? WHERE id = ?",
|
||||||
|
(int(message_id), conversation_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def conversations_awaiting_extraction(self, idle_seconds: float) -> list[str]:
|
||||||
|
"""Conversations with unread messages that have been quiet long enough
|
||||||
|
to count as finished. Used to resume sweeps dropped by a restart."""
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT c.id FROM conversations c JOIN messages m ON m.conversation_id = c.id "
|
||||||
|
"WHERE m.id > c.extracted_through GROUP BY c.id "
|
||||||
|
"HAVING MAX(m.timestamp) < ?",
|
||||||
|
(time.time() - idle_seconds,),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return [r["id"] for r in rows]
|
||||||
|
|
||||||
def delete_conversation(self, conversation_id: str):
|
def delete_conversation(self, conversation_id: str):
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
# Drop the embeddings first, while the message ids still resolve -
|
||||||
|
# the delete-side counterpart of _vec_upsert_msg (see its docstring).
|
||||||
ids = [r["id"] for r in cur.execute(
|
ids = [r["id"] for r in cur.execute(
|
||||||
"SELECT id FROM messages WHERE conversation_id = ?", (conversation_id,)
|
"SELECT id FROM messages WHERE conversation_id = ?", (conversation_id,)
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
@@ -851,17 +951,40 @@ class PersistentMemoryStore:
|
|||||||
def list_projects(self) -> List[dict]:
|
def list_projects(self) -> List[dict]:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT p.id, p.name, p.created_at,
|
SELECT p.id, p.name, p.created_at, p.instructions,
|
||||||
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs
|
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs,
|
||||||
|
(SELECT COUNT(*) FROM conversations c WHERE c.project_id = p.id) AS chats
|
||||||
FROM projects p ORDER BY p.created_at ASC
|
FROM projects p ORDER BY p.created_at ASC
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
def delete_project(self, project_id: str) -> bool:
|
def set_project_instructions(self, project_id: str, instructions: str) -> bool:
|
||||||
"""Delete a project; its documents survive but become unscoped ("")."""
|
"""Per-project system prompt, layered into chats bound to the project."""
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
conn.execute("UPDATE documents SET project_id = '' WHERE project_id = ?", (project_id,))
|
try:
|
||||||
|
cur = conn.execute("UPDATE projects SET instructions = ? WHERE id = ?",
|
||||||
|
(instructions or "", project_id))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def project_instructions(self, project_id: str) -> str:
|
||||||
|
"""'' when the project has none, or doesn't exist."""
|
||||||
|
if not project_id:
|
||||||
|
return ""
|
||||||
|
conn = self._connect()
|
||||||
|
row = conn.execute("SELECT instructions FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return (row["instructions"] or "") if row else ""
|
||||||
|
|
||||||
|
def delete_project(self, project_id: str) -> bool:
|
||||||
|
"""Delete a project; its documents, chats and facts survive but become
|
||||||
|
unscoped ("")."""
|
||||||
|
conn = self._connect()
|
||||||
|
for table in ("documents", "conversations", "memory"):
|
||||||
|
conn.execute(f"UPDATE {table} SET project_id = '' WHERE project_id = ?", (project_id,))
|
||||||
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||||
deleted = cur.rowcount
|
deleted = cur.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -1065,7 +1188,10 @@ class PersistentMemoryStore:
|
|||||||
# Curator CPU/GPU offload — same scale as gpu_offload above. Default 0
|
# Curator CPU/GPU offload — same scale as gpu_offload above. Default 0
|
||||||
# (all CPU/RAM): OS-neutral and never evicts the chat model from a small
|
# (all CPU/RAM): OS-neutral and never evicts the chat model from a small
|
||||||
# GPU. Boxes with spare VRAM can set -1 (Auto) or a percent to use the GPU.
|
# GPU. Boxes with spare VRAM can set -1 (Auto) or a percent to use the GPU.
|
||||||
"memory_gpu_offload": 0,
|
# -1 = Auto (let Ollama fit it). The curator is the resident chat model
|
||||||
|
# now, so there is nothing to keep off the GPU; pinning it to CPU (0)
|
||||||
|
# only bought coexistence with a second, separate curator model.
|
||||||
|
"memory_gpu_offload": -1,
|
||||||
# Similar-fact merge: when a newly extracted fact's embedding is at least
|
# Similar-fact merge: when a newly extracted fact's embedding is at least
|
||||||
# this cosine-similar to an existing fact, UPDATE that fact in place
|
# this cosine-similar to an existing fact, UPDATE that fact in place
|
||||||
# instead of appending a duplicate ("edit with new info"). 0 disables
|
# instead of appending a duplicate ("edit with new info"). 0 disables
|
||||||
@@ -1074,6 +1200,9 @@ class PersistentMemoryStore:
|
|||||||
# top out ~0.61 — so 0.80 catches updates and never merges unrelated
|
# top out ~0.61 — so 0.80 catches updates and never merges unrelated
|
||||||
# facts. Lower to catch looser rephrases; raise toward 1.0 to be stricter.
|
# facts. Lower to catch looser rephrases; raise toward 1.0 to be stricter.
|
||||||
"memory_merge_threshold": 0.80,
|
"memory_merge_threshold": 0.80,
|
||||||
|
# Seconds of quiet before the curator reads a conversation. This is the
|
||||||
|
# "conversation is over" signal; each new message restarts the clock.
|
||||||
|
"memory_extract_idle": 120,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_settings(self) -> Dict[str, Any]:
|
def get_settings(self) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -106,12 +106,17 @@ except Exception:
|
|||||||
# Chat: llama3.1:8b - a strong non-reasoning instruct model (~4.9 GB; overflows a
|
# Chat: llama3.1:8b - a strong non-reasoning instruct model (~4.9 GB; overflows a
|
||||||
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
|
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
|
||||||
# smart only with its slow <think> step, weak without it.
|
# smart only with its slow <think> step, weak without it.
|
||||||
# Memory: mistral - the curator that extracts facts and titles conversations.
|
# Memory: the curator that extracts facts and titles conversations. It is the
|
||||||
|
# CHAT model on purpose, not a second one: the chat model is already resident in
|
||||||
|
# VRAM and warm, so extraction costs no extra load. A distinct curator (mistral)
|
||||||
|
# did not fit alongside it and had to be pinned to CPU (num_gpu=0), which made
|
||||||
|
# every extraction a slow prompt-eval on a model too small to follow the
|
||||||
|
# curator prompt's negative rules reliably.
|
||||||
# Embed: nomic-embed-text - powers semantic recall of past conversations
|
# Embed: nomic-embed-text - powers semantic recall of past conversations
|
||||||
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
|
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
|
||||||
# recall silently degrades to lexical substring matching.
|
# recall silently degrades to lexical substring matching.
|
||||||
DEFAULT_CHAT_MODEL = "llama3.1:8b"
|
DEFAULT_CHAT_MODEL = "llama3.1:8b"
|
||||||
DEFAULT_MEMORY_MODEL = "mistral:latest"
|
DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
|
||||||
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
||||||
|
|
||||||
# --- CORE DIRECTORIES ---
|
# --- CORE DIRECTORIES ---
|
||||||
@@ -368,11 +373,7 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
|
|||||||
_LOCAL_ORIGINS = [
|
_LOCAL_ORIGINS = [
|
||||||
f"http://{h}:{p}"
|
f"http://{h}:{p}"
|
||||||
for h in ("localhost", "127.0.0.1")
|
for h in ("localhost", "127.0.0.1")
|
||||||
for p in (
|
for p in (_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000), 5173)
|
||||||
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
|
|
||||||
_int_value("memory_port", "NEXUS_MEMORY_PORT", 8001),
|
|
||||||
5173,
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
||||||
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
||||||
|
|||||||
+10
-12
@@ -55,26 +55,24 @@ def make_main(playbook_id: str) -> None:
|
|||||||
playbook_store.reorder_playbooks(ordered)
|
playbook_store.reorder_playbooks(ordered)
|
||||||
|
|
||||||
|
|
||||||
class PlaybookManager:
|
def _all() -> List[PlaybookItem]:
|
||||||
@classmethod
|
|
||||||
def _all(cls) -> List[PlaybookItem]:
|
|
||||||
"""Return all playbooks sorted by order (position 0 is always main)."""
|
"""Return all playbooks sorted by order (position 0 is always main)."""
|
||||||
return playbook_store.all_playbooks()
|
return playbook_store.all_playbooks()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_main_playbook(cls) -> PlaybookItem | None:
|
def get_main_playbook() -> PlaybookItem | None:
|
||||||
playbooks = cls._all()
|
playbooks = _all()
|
||||||
return playbooks[0] if playbooks else None
|
return playbooks[0] if playbooks else None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_context_playbooks(cls) -> List[PlaybookItem]:
|
def get_context_playbooks() -> List[PlaybookItem]:
|
||||||
"""All playbooks after the first — injected as reference context."""
|
"""All playbooks after the first — injected as reference context."""
|
||||||
playbooks = cls._all()
|
playbooks = _all()
|
||||||
return playbooks[1:] if len(playbooks) > 1 else []
|
return playbooks[1:] if len(playbooks) > 1 else []
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_system_prompt(cls) -> str:
|
def get_system_prompt() -> str:
|
||||||
playbook = cls.get_main_playbook()
|
playbook = get_main_playbook()
|
||||||
if not playbook:
|
if not playbook:
|
||||||
return ""
|
return ""
|
||||||
goal = (getattr(playbook, "goal", "") or "").strip()
|
goal = (getattr(playbook, "goal", "") or "").strip()
|
||||||
|
|||||||
@@ -169,6 +169,73 @@ async def _remember(text: str = "", section: str = "General", **_) -> str:
|
|||||||
return json.dumps({"saved": text, "section": section or "General"})
|
return json.dumps({"saved": text, "section": section or "General"})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
|
||||||
|
# Paths never leave the repo: every request is resolve()d and checked against
|
||||||
|
# PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of
|
||||||
|
# the tree that are either secrets, private data, or multi-GB noise. Read-only
|
||||||
|
# counterpart to edit_source's write path (self_edit.py) — a model reading
|
||||||
|
# real source before proposing an edit is the point.
|
||||||
|
_DENIED = {
|
||||||
|
".git", ".env", "Promethean", "node_modules", "models", "ollama",
|
||||||
|
"runtime", "dist", "__pycache__", ".git-credentials",
|
||||||
|
}
|
||||||
|
_READ_MAX = 60_000
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_path(rel: str) -> "tuple[object, str | None]":
|
||||||
|
"""Resolve a repo-relative path. Returns (path, error-string)."""
|
||||||
|
from .nexus_config import PROJECT_ROOT
|
||||||
|
rel = (rel or "").strip().lstrip("/")
|
||||||
|
if not rel:
|
||||||
|
return None, "path is required"
|
||||||
|
target = (PROJECT_ROOT / rel).resolve()
|
||||||
|
if not target.is_relative_to(PROJECT_ROOT):
|
||||||
|
return None, "path escapes the project root"
|
||||||
|
parts = set(target.relative_to(PROJECT_ROOT).parts)
|
||||||
|
if parts & _DENIED or target.name.endswith((".db", ".db.sql", ".pem", ".key")):
|
||||||
|
return None, f"{rel} is not readable"
|
||||||
|
return target, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_file(path: str = "", **_) -> str:
|
||||||
|
target, err = _repo_path(path)
|
||||||
|
if err:
|
||||||
|
return json.dumps({"error": err})
|
||||||
|
if not target.is_file():
|
||||||
|
return json.dumps({"error": f"{path} does not exist"})
|
||||||
|
try:
|
||||||
|
text = target.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError as e:
|
||||||
|
return json.dumps({"error": f"cannot read {path}: {e}"})
|
||||||
|
return json.dumps({
|
||||||
|
"path": path,
|
||||||
|
"truncated": len(text) > _READ_MAX,
|
||||||
|
"content": text[:_READ_MAX],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_files(pattern: str = "", **_) -> str:
|
||||||
|
"""Glob the repo so the model discovers real paths instead of inventing them."""
|
||||||
|
from .nexus_config import PROJECT_ROOT
|
||||||
|
pattern = (pattern or "**/*.py").strip().lstrip("/")
|
||||||
|
hits = []
|
||||||
|
for f in PROJECT_ROOT.glob(pattern):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
rel_posix = f.relative_to(PROJECT_ROOT).as_posix()
|
||||||
|
target, err = _repo_path(rel_posix)
|
||||||
|
if err:
|
||||||
|
continue
|
||||||
|
# .as_posix(), not str(): a bare str() gives backslash-separated paths
|
||||||
|
# on Windows, which don't match the forward-slash patterns this tool's
|
||||||
|
# own schema documents (e.g. "synapse/**/*.py") and that read_file
|
||||||
|
# expects back.
|
||||||
|
hits.append(rel_posix)
|
||||||
|
if len(hits) >= 200:
|
||||||
|
break
|
||||||
|
return json.dumps(sorted(hits))
|
||||||
|
|
||||||
|
|
||||||
# Canvas drawing APIs a real visualization must use — resizing width/height alone
|
# Canvas drawing APIs a real visualization must use — resizing width/height alone
|
||||||
# clears the buffer and draws nothing (a failure mode small models hit often).
|
# clears the buffer and draws nothing (a failure mode small models hit often).
|
||||||
_CANVAS_DRAW_APIS = (
|
_CANVAS_DRAW_APIS = (
|
||||||
@@ -1139,6 +1206,35 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|||||||
},
|
},
|
||||||
_list_models,
|
_list_models,
|
||||||
),
|
),
|
||||||
|
"read_file": (
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a source file from the NexusOS repository. Path is relative to the project root, e.g. 'synapse/main.py'.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"path": {"type": "string", "description": "repo-relative file path"}},
|
||||||
|
"required": ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_read_file,
|
||||||
|
),
|
||||||
|
"list_files": (
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_files",
|
||||||
|
"description": "List files in the NexusOS repository matching a glob, e.g. 'synapse/**/*.py' or 'interface/web/src/*.jsx'. Use this to find real paths before reading.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"pattern": {"type": "string", "description": "glob relative to the project root"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_list_files,
|
||||||
|
),
|
||||||
"search_documents": (
|
"search_documents": (
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
|||||||
@@ -68,6 +68,45 @@ def test_conversation_project_binding():
|
|||||||
assert s.conversation_project("c2") == "" # unscoped
|
assert s.conversation_project("c2") == "" # unscoped
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversations_move_between_projects():
|
||||||
|
# The Projects page lists chats by project_id and moves them with a PATCH;
|
||||||
|
# if all_conversations() drops the column the list is silently empty.
|
||||||
|
s = _store()
|
||||||
|
s.create_conversation("c1", "projX")
|
||||||
|
s.create_conversation("c2")
|
||||||
|
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "projX", "c2": ""}
|
||||||
|
|
||||||
|
s.set_conversation_project("c2", "projX")
|
||||||
|
s.set_conversation_project("c1", "") # removed from the project
|
||||||
|
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "", "c2": "projX"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_instructions_and_scoped_memory():
|
||||||
|
# The chat system prompt takes the project's instructions plus global facts
|
||||||
|
# and this project's facts only — another project's must never leak in.
|
||||||
|
from synapse.memory.store import MemoryItem
|
||||||
|
|
||||||
|
s = _store()
|
||||||
|
p = s.create_project("Roof rebuild")
|
||||||
|
assert s.project_instructions(p["id"]) == "" # default: no instructions
|
||||||
|
assert s.project_instructions("ghost") == "" # unknown project
|
||||||
|
assert s.set_project_instructions(p["id"], "answer as a roofer")
|
||||||
|
assert not s.set_project_instructions("ghost", "x")
|
||||||
|
assert s.project_instructions(p["id"]) == "answer as a roofer"
|
||||||
|
|
||||||
|
s.add(MemoryItem(id="g", text="lives in Ohio")) # global
|
||||||
|
s.add(MemoryItem(id="a", text="uses metal panels", project_id=p["id"])) # this project
|
||||||
|
s.add(MemoryItem(id="b", text="prefers Lua", project_id="other")) # elsewhere
|
||||||
|
in_scope = [m.id for m in s.all() if m.project_id in ("", p["id"])]
|
||||||
|
assert in_scope == ["g", "a"]
|
||||||
|
|
||||||
|
# Deleting a project keeps its chats and facts, unscoped.
|
||||||
|
s.create_conversation("c1", p["id"])
|
||||||
|
s.delete_project(p["id"])
|
||||||
|
assert s.conversation_project("c1") == ""
|
||||||
|
assert s.get("a").project_id == ""
|
||||||
|
|
||||||
|
|
||||||
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
||||||
s = _store()
|
s = _store()
|
||||||
if not s.vec_enabled:
|
if not s.vec_enabled:
|
||||||
@@ -233,3 +272,64 @@ def test_extract_text_by_type():
|
|||||||
out = _extract_text("blank.pdf", buf.getvalue())
|
out = _extract_text("blank.pdf", buf.getvalue())
|
||||||
assert isinstance(out, str) # blank page -> "" or whitespace, never raises
|
assert isinstance(out, str) # blank page -> "" or whitespace, never raises
|
||||||
assert PdfReader(io.BytesIO(buf.getvalue())).pages # sanity: it was a valid PDF
|
assert PdfReader(io.BytesIO(buf.getvalue())).pages # sanity: it was a valid PDF
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_conversation_takes_its_embeddings_with_it(tmp_path, monkeypatch):
|
||||||
|
"""Stale vectors are inert — the search joins messages — but they still
|
||||||
|
occupy slots in the ANN over-fetch, so recall of the surviving
|
||||||
|
conversations quietly thins out as deleted ones pile up."""
|
||||||
|
import sqlite3
|
||||||
|
from synapse.memory.store import PersistentMemoryStore
|
||||||
|
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
s = PersistentMemoryStore(db)
|
||||||
|
s.create_conversation("keep", "")
|
||||||
|
s.create_conversation("drop", "")
|
||||||
|
kept = s.add_message("keep", "user", "hello")
|
||||||
|
doomed = s.add_message("drop", "user", "goodbye")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
for mid in (kept, doomed):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
|
||||||
|
(mid, "[0.0, 1.0]"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
s.delete_conversation("drop")
|
||||||
|
|
||||||
|
left = {r[0] for r in conn.execute("SELECT message_id FROM message_vectors")}
|
||||||
|
assert left == {kept}, left
|
||||||
|
|
||||||
|
|
||||||
|
def test_extraction_watermark_is_idempotent(tmp_path):
|
||||||
|
"""The curator reads a conversation when it goes idle, so the watermark is
|
||||||
|
what stops a restart (or a second sweep) from re-reading messages and
|
||||||
|
re-saving the facts it already saved."""
|
||||||
|
from synapse.memory.store import PersistentMemoryStore
|
||||||
|
|
||||||
|
s = PersistentMemoryStore(tmp_path / "t.db")
|
||||||
|
s.create_conversation("c", "")
|
||||||
|
s.add_message("c", "user", "i bought a bike")
|
||||||
|
last = s.add_message("c", "assistant", "nice")
|
||||||
|
|
||||||
|
pending, mark = s.pending_extraction("c")
|
||||||
|
assert [m["role"] for m in pending] == ["user", "assistant"]
|
||||||
|
assert mark == last
|
||||||
|
|
||||||
|
s.set_extracted_through("c", mark)
|
||||||
|
assert s.pending_extraction("c") == ([], 0) # nothing new -> no model call
|
||||||
|
|
||||||
|
s.add_message("c", "user", "a 2019 trek")
|
||||||
|
pending, _ = s.pending_extraction("c")
|
||||||
|
assert [m["content"] for m in pending] == ["a 2019 trek"] # only the unread tail
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_sweep_only_claims_quiet_conversations(tmp_path):
|
||||||
|
from synapse.memory.store import PersistentMemoryStore
|
||||||
|
|
||||||
|
s = PersistentMemoryStore(tmp_path / "t.db")
|
||||||
|
s.create_conversation("fresh", "")
|
||||||
|
s.add_message("fresh", "user", "still typing")
|
||||||
|
assert s.conversations_awaiting_extraction(3600) == [] # too recent to be "over"
|
||||||
|
assert s.conversations_awaiting_extraction(0) == ["fresh"]
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Guards for the KDE theme's silent-failure modes.
|
||||||
|
|
||||||
|
Every check here corresponds to something that broke without producing an error
|
||||||
|
message. They are static reads of the scripts and packages because the failures
|
||||||
|
are configuration-shaped -- there is nothing to import and nothing that raises.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
KDE = REPO / "assets" / "themes" / "KDE"
|
||||||
|
INSTALLER = KDE / "install-plasma.sh"
|
||||||
|
LNF = KDE / "look-and-feel" / "com.nexusos.desktop"
|
||||||
|
ICONS = REPO / "assets" / "themes" / "NexusOS-icons"
|
||||||
|
THEME_INSTALLER = REPO / "assets" / "themes" / "install-theme.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_look_and_feel_is_copied_never_symlinked():
|
||||||
|
"""KPackage skips symlinked package directories without a word, so a
|
||||||
|
symlinked Global Theme simply never appears in System Settings."""
|
||||||
|
text = INSTALLER.read_text(encoding="utf-8")
|
||||||
|
assert "cp -rL" in text, "look-and-feel/wallpaper must be copied into place"
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.strip().startswith("ln -s"):
|
||||||
|
assert "look-and-feel" not in line and "wallpapers" not in line, \
|
||||||
|
f"KPackage package dir must not be symlinked: {line.strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plasmashell_restart_is_detached_from_the_callers_stdout():
|
||||||
|
"""The restarted shell outlives the script. Inheriting stdout keeps the
|
||||||
|
caller's pipe open forever, which hangs `ncp restore` after a successful
|
||||||
|
apply."""
|
||||||
|
text = INSTALLER.read_text(encoding="utf-8")
|
||||||
|
restart = [l for l in text.splitlines()
|
||||||
|
if "kstart5 plasmashell" in l and not l.strip().startswith("#")]
|
||||||
|
assert restart, "no plasmashell restart found"
|
||||||
|
for line in restart:
|
||||||
|
assert "setsid" in line, f"restart not detached: {line.strip()}"
|
||||||
|
assert "</dev/null" in line and ">/dev/null" in line, \
|
||||||
|
f"restart still holds the caller's stdio: {line.strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_splash_renders_without_the_stage_signal():
|
||||||
|
"""A splash gated on `stage == 2` shows a blank coloured screen if that
|
||||||
|
signal never arrives -- what `ksplashqml --test` does."""
|
||||||
|
qml = (LNF / "contents" / "splash" / "Splash.qml").read_text(encoding="utf-8")
|
||||||
|
content = qml[qml.index("id: content"):]
|
||||||
|
body = content[:content.index("OpacityAnimator")]
|
||||||
|
assert "opacity: 0" not in body, "splash content starts invisible"
|
||||||
|
assert "introAnimation" not in qml, "visibility still gated on a stage change"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sddm_theme_is_configured_in_exactly_one_place():
|
||||||
|
"""boot-branding.sh and install-plasma.sh both deploy the SDDM theme; two
|
||||||
|
different config files meant the setting could disagree with itself."""
|
||||||
|
for script in (INSTALLER, REPO / "bin" / "boot-branding.sh"):
|
||||||
|
text = script.read_text(encoding="utf-8")
|
||||||
|
stray = re.findall(r"/etc/sddm\.conf(?!\.d)", text)
|
||||||
|
assert not stray, f"{script.name} writes bare /etc/sddm.conf; use conf.d"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
|
||||||
|
"""The desktop stage used to bail out entirely without xfconf-query, so a
|
||||||
|
Plasma box got no theme back from `ncp restore` at all."""
|
||||||
|
text = (REPO / "bin" / "restore-linux.sh").read_text(encoding="utf-8")
|
||||||
|
assert "install-plasma.sh" in text, "restore never invokes the Plasma installer"
|
||||||
|
assert "--no-sddm" in text, "restore should leave SDDM to boot-branding.sh"
|
||||||
|
# The XFCE check must not be able to skip the Plasma branch or the branding.
|
||||||
|
assert "exit 0\nfi\n" not in text.split("desktop stage")[1][:900], \
|
||||||
|
"XFCE guard still exits the whole stage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_theme_package_is_well_formed():
|
||||||
|
meta = json.loads((LNF / "metadata.json").read_text(encoding="utf-8"))
|
||||||
|
assert meta["KPlugin"]["Id"] == LNF.name, "package Id must match its directory"
|
||||||
|
assert "Plasma/LookAndFeel" in meta["KPlugin"]["ServiceTypes"]
|
||||||
|
|
||||||
|
defaults = (LNF / "contents" / "defaults").read_text(encoding="utf-8")
|
||||||
|
# Every component the Global Theme selects has to exist in the repo.
|
||||||
|
assert "ColorScheme=NexusOS" in defaults
|
||||||
|
assert (KDE / "plasma" / "NexusOS").is_dir()
|
||||||
|
assert (KDE / "aurorae" / "NexusOS").is_dir()
|
||||||
|
assert (KDE / "wallpaper" / "NexusOS" / "metadata.json").is_file()
|
||||||
|
assert "Theme=com.nexusos.desktop" in defaults, "splash not wired to this package"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patterned_backgrounds_are_referenced_as_raster_not_svg():
|
||||||
|
"""QtSvg is SVG Tiny 1.2 and has no <pattern>, so the brushed-metal and
|
||||||
|
machine-line textures vanish and the gradient renders flat. Every QML that
|
||||||
|
shows that artwork must load the rasterized PNG."""
|
||||||
|
qml_files = [
|
||||||
|
KDE / "sddm" / "NexusOS-QML" / "Main.qml",
|
||||||
|
LNF / "contents" / "splash" / "Splash.qml",
|
||||||
|
]
|
||||||
|
for f in qml_files:
|
||||||
|
# Only the source: lines -- the comments deliberately mention the SVG,
|
||||||
|
# since that is the file you edit and re-rasterize.
|
||||||
|
sources = [l for l in f.read_text(encoding="utf-8").splitlines()
|
||||||
|
if "source:" in l and not l.strip().startswith("//")]
|
||||||
|
bg = [l for l in sources if "background" in l]
|
||||||
|
assert bg, f"{f.name} loads no background"
|
||||||
|
for line in bg:
|
||||||
|
assert "background.png" in line, \
|
||||||
|
f"{f.name} loads a patterned SVG through QtSvg: {line.strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults_sections():
|
||||||
|
"""Parse the look-and-feel defaults into {section: {key: value}}."""
|
||||||
|
out, section = {}, None
|
||||||
|
for line in (LNF / "contents" / "defaults").read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("["):
|
||||||
|
section = line
|
||||||
|
out[section] = {}
|
||||||
|
elif line and "=" in line and section:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
out[section][k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_screen_theme_names_a_look_and_feel_package():
|
||||||
|
"""Plasma 5.27 draws the lock screen from a look-and-feel package, and
|
||||||
|
[Greeter]Theme names that package. A bare theme name (the old "NexusOS")
|
||||||
|
is not one, so Plasma falls back to Breeze without saying anything."""
|
||||||
|
greeter = _defaults_sections()["[kscreenlockerrc][Greeter]"]
|
||||||
|
assert greeter["Theme"].endswith(".desktop"), \
|
||||||
|
f"lock theme must be a look-and-feel package id, got {greeter['Theme']!r}"
|
||||||
|
|
||||||
|
installer = INSTALLER.read_text(encoding="utf-8")
|
||||||
|
lock_lines = [l for l in installer.splitlines()
|
||||||
|
if "kscreenlockerrc" in l and "--key Theme" in l]
|
||||||
|
assert lock_lines, "installer never sets the lock screen theme"
|
||||||
|
for line in lock_lines:
|
||||||
|
assert ".desktop" in line, f"installer sets a non-package lock theme: {line.strip()}"
|
||||||
|
|
||||||
|
# And the lock wallpaper is the raster metal background.
|
||||||
|
assert "background.png" in installer, "lock wallpaper not set to the metal raster"
|
||||||
|
|
||||||
|
|
||||||
|
def _index_theme():
|
||||||
|
"""Parse index.theme into (header dict, list of declared directories)."""
|
||||||
|
header, section, dirs = {}, None, []
|
||||||
|
for line in (ICONS / "index.theme").read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("[") and line != "[Icon Theme]":
|
||||||
|
section = line.strip("[]")
|
||||||
|
dirs.append(section)
|
||||||
|
elif "=" in line and not line.startswith("#") and section is None:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
header[k.strip()] = v.strip()
|
||||||
|
return header, dirs
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_declared_icon_directory_exists():
|
||||||
|
"""index.theme declared 124 directories against 21 real ones, most copied
|
||||||
|
from Papirus. A declared-but-missing directory is dead weight the icon
|
||||||
|
loader walks on every lookup."""
|
||||||
|
_, dirs = _index_theme()
|
||||||
|
missing = [d for d in dirs if not (ICONS / d).is_dir()]
|
||||||
|
assert not missing, f"index.theme declares directories that do not exist: {missing}"
|
||||||
|
|
||||||
|
listed = set(_index_theme()[0].get("Directories", "").split(","))
|
||||||
|
assert listed == set(dirs), "Directories= and the [section] list disagree"
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_theme_inherits_a_recolourable_parent():
|
||||||
|
"""Papirus hardcodes its blues, so nothing the colour scheme does can reach
|
||||||
|
them and the un-themed surface stayed blue forever. Breeze's icons carry
|
||||||
|
ColorScheme-* classes that Plasma recolours from the active scheme."""
|
||||||
|
header, _ = _index_theme()
|
||||||
|
parents = [p.strip() for p in header["Inherits"].split(",")]
|
||||||
|
assert not parents[0].lower().startswith("papirus"), \
|
||||||
|
"primary parent cannot recolour from the colour scheme"
|
||||||
|
assert parents[0] == "breeze-dark", f"expected breeze-dark first, got {parents[0]!r}"
|
||||||
|
assert parents[-1] == "hicolor", "hicolor must remain the last-resort fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_theme_is_installed_where_qt_looks():
|
||||||
|
"""~/.icons is the GTK/XFCE legacy path. Qt/KF5 searches XDG data dirs only,
|
||||||
|
so installing there alone meant Plasma never found the theme and every icon
|
||||||
|
fell back to Breeze without a word."""
|
||||||
|
text = THEME_INSTALLER.read_text(encoding="utf-8")
|
||||||
|
links = [l for l in text.splitlines()
|
||||||
|
if l.strip().startswith("link ") and "NexusOS-icons" in l]
|
||||||
|
assert any(".local/share/icons" in l for l in links), \
|
||||||
|
"icon theme is not installed to an XDG data dir; Plasma will not see it"
|
||||||
|
assert any(".icons/NexusOS" in l for l in links), \
|
||||||
|
"dropping ~/.icons would break the XFCE session and GTK apps"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inherits_check_reads_only_the_primary_parent():
|
||||||
|
"""The installer compared the whole comma-separated Inherits value against a
|
||||||
|
directory name, so a valid multi-parent list warned that an installed
|
||||||
|
fallback was missing."""
|
||||||
|
text = THEME_INSTALLER.read_text(encoding="utf-8")
|
||||||
|
inh = [l for l in text.splitlines() if "INH=" in l and "Inherits" in l]
|
||||||
|
assert inh, "inheritance check not found"
|
||||||
|
assert any("-f1" in l for l in inh), \
|
||||||
|
"inheritance check still treats the whole Inherits list as one theme name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_layout_is_portable():
|
||||||
|
"""The panel script sets the launcher icon by absolute path. Hardcoding
|
||||||
|
this box's home would give any other clone or user a missing icon, so the
|
||||||
|
path is a placeholder the installer substitutes."""
|
||||||
|
js = (KDE / "panel-layout.js").read_text(encoding="utf-8")
|
||||||
|
assert "/home/" not in js, "panel-layout.js hardcodes a home directory"
|
||||||
|
assert "__NEXUS_ROOT__" in js, "no placeholder for the repo path"
|
||||||
|
|
||||||
|
installer = INSTALLER.read_text(encoding="utf-8")
|
||||||
|
assert "__NEXUS_ROOT__" in installer, "installer never substitutes the repo path"
|
||||||
|
# Rewriting the panel wholesale on every restore would wipe later additions.
|
||||||
|
assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Mail account config — offline (no live IMAP/SMTP)."""
|
||||||
|
from modules.mail import backend as mail
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_roundtrip_and_password_masking(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
|
||||||
|
|
||||||
|
assert mail.load_accounts() == []
|
||||||
|
|
||||||
|
pub = mail.save_account(None, {
|
||||||
|
"username": "me@icloud.com",
|
||||||
|
"password": "app-specific-pw",
|
||||||
|
"imap_host": "imap.mail.me.com",
|
||||||
|
"from_addr": "nexus@enderofwings.com",
|
||||||
|
})
|
||||||
|
account_id = pub["id"]
|
||||||
|
# public view exposes a flag, never the secret
|
||||||
|
assert pub["has_password"] is True
|
||||||
|
assert "password" not in pub
|
||||||
|
assert pub["configured"] is True
|
||||||
|
assert mail.is_configured(account_id) is True
|
||||||
|
|
||||||
|
# a blank password on update keeps the stored one (write-only field)
|
||||||
|
mail.save_account(account_id, {"from_name": "Nexus"})
|
||||||
|
assert mail.get_account(account_id)["password"] == "app-specific-pw"
|
||||||
|
assert mail.get_account(account_id)["from_name"] == "Nexus"
|
||||||
|
|
||||||
|
# defaults target iCloud
|
||||||
|
assert mail.get_account(account_id)["smtp_host"] == "smtp.mail.me.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_accounts_are_independent(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
|
||||||
|
|
||||||
|
a = mail.save_account(None, {"username": "a@icloud.com", "password": "pw-a", "label": "Personal"})
|
||||||
|
b = mail.save_account(None, {"username": "b@icloud.com", "password": "pw-b", "label": "Work"})
|
||||||
|
assert a["id"] != b["id"]
|
||||||
|
|
||||||
|
accounts = mail.public_accounts()
|
||||||
|
assert {x["id"] for x in accounts} == {a["id"], b["id"]}
|
||||||
|
|
||||||
|
mail.delete_account(a["id"])
|
||||||
|
remaining = mail.public_accounts()
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0]["id"] == b["id"]
|
||||||
|
assert mail.is_configured(a["id"]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_creds_file_lives_outside_the_db(monkeypatch, tmp_path):
|
||||||
|
# Mail secrets must never ride in memory.db (which bin/sync.py dumps to git).
|
||||||
|
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
|
||||||
|
mail.save_account(None, {"username": "u", "password": "p"})
|
||||||
|
assert (tmp_path / "acct.json").exists()
|
||||||
|
from synapse.memory.store import PersistentMemoryStore
|
||||||
|
assert "password" not in PersistentMemoryStore._SETTINGS_DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_single_account_file_migrates(tmp_path, monkeypatch):
|
||||||
|
import json
|
||||||
|
f = tmp_path / "acct.json"
|
||||||
|
f.write_text(json.dumps({
|
||||||
|
"imap_host": "imap.mail.me.com", "imap_port": 993,
|
||||||
|
"smtp_host": "smtp.mail.me.com", "smtp_port": 587,
|
||||||
|
"username": "legacy@icloud.com", "password": "old-pw",
|
||||||
|
"from_addr": "legacy@enderofwings.com", "from_name": "",
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr(mail, "_ACCOUNT_FILE", f)
|
||||||
|
|
||||||
|
accounts = mail.load_accounts()
|
||||||
|
assert len(accounts) == 1
|
||||||
|
assert accounts[0]["username"] == "legacy@icloud.com"
|
||||||
|
assert "id" in accounts[0]
|
||||||
|
|
||||||
|
# migration is persisted — the file is rewritten in list form
|
||||||
|
assert json.loads(f.read_text())["accounts"][0]["username"] == "legacy@icloud.com"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Credential handling for the mail module.
|
||||||
|
|
||||||
|
Both checks guard fixes for real defects: the account file used to be written at
|
||||||
|
the umask and chmodded afterwards, and the IMAP/SMTP connections used to take
|
||||||
|
Python's stdlib SSL context, which verifies nothing.
|
||||||
|
|
||||||
|
The 0600-mode assertions are POSIX-only: NTFS has no rwx-owner/group/other bit
|
||||||
|
model, so os.open(..., 0o600) on Windows creates a normal read-write file and
|
||||||
|
stat.S_IMODE reports 0o666 regardless of the mode argument -- Python's mode
|
||||||
|
param there only round-trips the read-only *attribute*, not real ACL-based
|
||||||
|
per-user access control (that needs pywin32/icacls, out of scope for a local
|
||||||
|
single-user app whose own user-profile directory is already the actual access
|
||||||
|
boundary on Windows). Skip rather than assert something the OS can't provide.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from modules.mail import backend as mail
|
||||||
|
|
||||||
|
_WINDOWS_NO_POSIX_MODE = pytest.mark.skipif(
|
||||||
|
sys.platform == "win32",
|
||||||
|
reason="0600 is a POSIX permission model; NTFS has no equivalent bits to assert on",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
|
||||||
|
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
|
||||||
|
|
||||||
|
# The mode assertion only means something on POSIX; the rest of this test
|
||||||
|
# (no temp file left behind, password round-trip) is platform-independent
|
||||||
|
# and must keep running on Windows.
|
||||||
|
if sys.platform != "win32":
|
||||||
|
mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
|
||||||
|
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
|
||||||
|
assert not list(tmp_path.glob("*.tmp")), "temp file left behind"
|
||||||
|
|
||||||
|
# The password round-trips to disk but never to the API.
|
||||||
|
assert mail.load_accounts()[0]["password"] == "secret"
|
||||||
|
assert "password" not in mail.public_account(mail.get_account("abc"))
|
||||||
|
assert mail.public_account(mail.get_account("abc"))["has_password"] is True
|
||||||
|
assert json.loads((tmp_path / "mail_accounts.json").read_text())["accounts"]
|
||||||
|
|
||||||
|
|
||||||
|
@_WINDOWS_NO_POSIX_MODE
|
||||||
|
def test_account_file_is_0600_while_it_is_being_written(tmp_path, monkeypatch):
|
||||||
|
"""The old code wrote at the umask and chmodded afterwards, so the file sat
|
||||||
|
world-readable for the length of the write. Assert the handle it is written
|
||||||
|
through is already 0600 -- the pre-fix version never wrote through a handle
|
||||||
|
at all (json.dumps to a string, then write_text), so this fails against it."""
|
||||||
|
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
|
||||||
|
seen = {}
|
||||||
|
real_dump = mail.json.dump
|
||||||
|
|
||||||
|
def spy(obj, fh, **kw):
|
||||||
|
seen["mode"] = stat.S_IMODE(os.fstat(fh.fileno()).st_mode)
|
||||||
|
return real_dump(obj, fh, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mail.json, "dump", spy)
|
||||||
|
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
|
||||||
|
|
||||||
|
assert seen["mode"] == 0o600, f"written through a {oct(seen['mode'])} handle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_context_verifies_certificate_and_hostname():
|
||||||
|
# ssl._create_stdlib_context(), the imaplib/smtplib fallback, gives
|
||||||
|
# CERT_NONE + check_hostname False -- which is what this replaced.
|
||||||
|
assert mail._TLS.verify_mode is ssl.CERT_REQUIRED
|
||||||
|
assert mail._TLS.check_hostname is True
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""modules/registry.py auto-discovery — no per-module registration required."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from modules.registry import ROUTERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovers_every_module_with_a_router():
|
||||||
|
prefixes = sorted(r.prefix for r in ROUTERS)
|
||||||
|
assert prefixes == ["/mail", "/network"]
|
||||||
|
assert all(isinstance(r, APIRouter) for r in ROUTERS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_without_router_py_is_skipped(tmp_path, monkeypatch):
|
||||||
|
# A module folder that hasn't grown a router.py yet (e.g. mid-scaffold)
|
||||||
|
# must not blow up discovery.
|
||||||
|
import modules.registry as registry
|
||||||
|
(tmp_path / "not_a_module").mkdir()
|
||||||
|
(tmp_path / "not_a_module" / "__init__.py").touch()
|
||||||
|
monkeypatch.setattr(registry, "_MODULES_DIR", tmp_path)
|
||||||
|
# No router.py in the folder, so discovery must skip it without raising
|
||||||
|
# (it never even attempts the import).
|
||||||
|
assert registry._discover() == []
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Network module — offline (no real pings, no real nmcli/NetworkManager calls)."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from modules.network import backend as net
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_connection_returns_expected_shape():
|
||||||
|
conn = net.primary_connection()
|
||||||
|
assert set(conn.keys()) == {"interface", "ip", "type"}
|
||||||
|
assert conn["type"] in ("wifi", "ethernet", "offline")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vpn_status_unavailable_when_nmcli_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(net, "_nmcli_available", lambda: False)
|
||||||
|
assert net.vpn_status() == {"available": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_vpn_status_configured_but_disconnected(monkeypatch):
|
||||||
|
monkeypatch.setattr(net, "_nmcli_available", lambda: True)
|
||||||
|
monkeypatch.setattr(net, "_nmcli", lambda *a: "wgs_client:wireguard:disconnected")
|
||||||
|
status = net.vpn_status()
|
||||||
|
assert status == {"available": True, "configured": True, "name": "wgs_client", "connected": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_vpn_status_connected(monkeypatch):
|
||||||
|
monkeypatch.setattr(net, "_nmcli_available", lambda: True)
|
||||||
|
monkeypatch.setattr(net, "_nmcli", lambda *a: "wgs_client:wireguard:activated")
|
||||||
|
status = net.vpn_status()
|
||||||
|
assert status["connected"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_vpn_toggle_raises_without_a_configured_tunnel(monkeypatch):
|
||||||
|
monkeypatch.setattr(net, "vpn_status", lambda: {"available": True, "configured": False, "name": None, "connected": False})
|
||||||
|
try:
|
||||||
|
net.vpn_toggle(True)
|
||||||
|
assert False, "expected RuntimeError"
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_crud_roundtrip(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(net, "_TARGETS_FILE", tmp_path / "targets.json")
|
||||||
|
|
||||||
|
assert net.list_targets() == []
|
||||||
|
t = net.add_target("Router", "192.168.50.1")
|
||||||
|
assert t["label"] == "Router" and t["host"] == "192.168.50.1"
|
||||||
|
|
||||||
|
targets = net.list_targets()
|
||||||
|
assert len(targets) == 1
|
||||||
|
assert targets[0]["id"] == t["id"]
|
||||||
|
|
||||||
|
net.delete_target(t["id"])
|
||||||
|
assert net.list_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_parses_latency_on_success(monkeypatch):
|
||||||
|
monkeypatch.setattr(subprocess, "check_output", lambda *a, **k: "64 bytes from 1.1.1.1: icmp_seq=1 ttl=56 time=12.3 ms")
|
||||||
|
result = net.ping("1.1.1.1")
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["latency_ms"] == 12.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_reports_failure(monkeypatch):
|
||||||
|
def raise_failed(*a, **k):
|
||||||
|
raise subprocess.CalledProcessError(1, "ping")
|
||||||
|
monkeypatch.setattr(subprocess, "check_output", raise_failed)
|
||||||
|
result = net.ping("10.255.255.1")
|
||||||
|
assert result == {"ok": False, "latency_ms": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_targets_merges_target_and_result(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(net, "_TARGETS_FILE", tmp_path / "targets.json")
|
||||||
|
net.add_target("Router", "192.168.50.1")
|
||||||
|
monkeypatch.setattr(net, "ping", lambda host: {"ok": True, "latency_ms": 5.0})
|
||||||
|
|
||||||
|
results = net.ping_targets()
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["host"] == "192.168.50.1"
|
||||||
|
assert results[0]["ok"] is True
|
||||||
|
assert results[0]["latency_ms"] == 5.0
|
||||||
@@ -15,7 +15,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
SHIPPED_PACKAGES = ("synapse", "nexusos_cli")
|
SHIPPED_PACKAGES = ("synapse", "nexusos_cli", "modules")
|
||||||
|
|
||||||
# Import name -> distribution name, where PyPI disagrees with the module.
|
# Import name -> distribution name, where PyPI disagrees with the module.
|
||||||
DISTRIBUTION_OF = {
|
DISTRIBUTION_OF = {
|
||||||
@@ -33,7 +33,7 @@ DISTRIBUTION_OF = {
|
|||||||
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
||||||
|
|
||||||
# Modules that ship inside this repo.
|
# Modules that ship inside this repo.
|
||||||
FIRST_PARTY = {"synapse", "nexusos_cli", "management", "bin", "tests"}
|
FIRST_PARTY = {"synapse", "nexusos_cli", "management", "bin", "tests", "modules"}
|
||||||
|
|
||||||
|
|
||||||
def _pyproject() -> dict:
|
def _pyproject() -> dict:
|
||||||
|
|||||||
+67
-16
@@ -19,7 +19,7 @@ from synapse.nexus_config import DEFAULT_CHAT_MODEL, DEFAULT_MEMORY_MODEL
|
|||||||
from synapse.ollama_manager import OllamaManager
|
from synapse.ollama_manager import OllamaManager
|
||||||
from synapse import ollama_manager
|
from synapse import ollama_manager
|
||||||
from synapse.icons.compositor import _is_allowed_path
|
from synapse.icons.compositor import _is_allowed_path
|
||||||
from synapse.playbook_manager import PlaybookManager
|
from synapse import playbook_manager
|
||||||
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -162,9 +162,9 @@ def test_first_playbook_is_the_system_prompt(tmp_path, monkeypatch):
|
|||||||
instructions="Answer briefly.", order=0))
|
instructions="Answer briefly.", order=0))
|
||||||
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
||||||
|
|
||||||
assert PlaybookManager.get_main_playbook().id == "main"
|
assert playbook_manager.get_main_playbook().id == "main"
|
||||||
assert [p.id for p in PlaybookManager.get_context_playbooks()] == ["ctx"]
|
assert [p.id for p in playbook_manager.get_context_playbooks()] == ["ctx"]
|
||||||
assert PlaybookManager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
|
assert playbook_manager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
|
||||||
|
|
||||||
|
|
||||||
def test_settings_round_trip_over_defaults(tmp_path):
|
def test_settings_round_trip_over_defaults(tmp_path):
|
||||||
@@ -180,10 +180,10 @@ def test_past_conversations_are_searchable(tmp_path):
|
|||||||
# silently drops the assistant's recall of past chats.
|
# silently drops the assistant's recall of past chats.
|
||||||
store = PersistentMemoryStore(tmp_path / "memory.db")
|
store = PersistentMemoryStore(tmp_path / "memory.db")
|
||||||
store.create_conversation("c1")
|
store.create_conversation("c1")
|
||||||
store.add_message("c1", "user", "how do I mount the Wingdrive?")
|
store.add_message("c1", "user", "how do I mount the backup drive?")
|
||||||
store.add_message("c1", "assistant", "use rsync over ssh")
|
store.add_message("c1", "assistant", "use rsync over ssh")
|
||||||
|
|
||||||
assert store.search_conversations("wingdrive") # case-insensitive substring
|
assert store.search_conversations("BACKUP drive") # case-insensitive substring
|
||||||
assert store.search_conversations("nothing here") == []
|
assert store.search_conversations("nothing here") == []
|
||||||
assert store.search_conversations(" ") == []
|
assert store.search_conversations(" ") == []
|
||||||
|
|
||||||
@@ -312,10 +312,10 @@ def test_sync_compare_detects_direction(tmp_path):
|
|||||||
# commits but never closes, and Windows refuses to unlink a file that
|
# commits but never closes, and Windows refuses to unlink a file that
|
||||||
# still has an open handle.
|
# still has an open handle.
|
||||||
with contextlib.closing(sq.connect(db)) as conn, conn:
|
with contextlib.closing(sq.connect(db)) as conn, conn:
|
||||||
conn.executescript(
|
|
||||||
# updated_at REAL, matching the production schema in store.py. A TEXT
|
# updated_at REAL, matching the production schema in store.py. A TEXT
|
||||||
# column here hid a real TypeError for months: the comparison in
|
# column here hid a real TypeError for months: the comparison in
|
||||||
# _extra() ran str-vs-str in the test and str-vs-float in the field.
|
# _extra() ran str-vs-str in the test and str-vs-float in the field.
|
||||||
|
conn.executescript(
|
||||||
"create table conversations (id text primary key, updated_at real not null);"
|
"create table conversations (id text primary key, updated_at real not null);"
|
||||||
"create table memory (id text primary key);"
|
"create table memory (id text primary key);"
|
||||||
)
|
)
|
||||||
@@ -393,6 +393,15 @@ def test_genmon_configs_are_written_with_the_panel_down():
|
|||||||
assert quit_at < copy_at < start_at, "genmon rc copy must happen with the panel stopped"
|
assert quit_at < copy_at < start_at, "genmon rc copy must happen with the panel stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plank_is_actually_launched():
|
||||||
|
"""Restoring ~/.config/plank only brings back the dock's launchers - nothing
|
||||||
|
in it starts Plank. The primary-follow watcher is what launches and revives
|
||||||
|
it, so it needs an autostart entry or a fresh box has no dock at all."""
|
||||||
|
desktop = REPO_ROOT / "management" / "autostart" / "plank.desktop"
|
||||||
|
assert "plank-primary-watch.sh" in desktop.read_text()
|
||||||
|
assert "plank.desktop" in (REPO_ROOT / "bin" / "panel" / "install.sh").read_text()
|
||||||
|
|
||||||
|
|
||||||
_VULKANINFO_IGPU_AND_LLVMPIPE = """\
|
_VULKANINFO_IGPU_AND_LLVMPIPE = """\
|
||||||
Devices:
|
Devices:
|
||||||
========
|
========
|
||||||
@@ -496,32 +505,32 @@ def test_dump_round_trips_a_db_holding_vec_tables(tmp_path):
|
|||||||
def test_curator_drops_fabricated_facts():
|
def test_curator_drops_fabricated_facts():
|
||||||
"""The curator model invents two classes of fact no prompt wording stopped
|
"""The curator model invents two classes of fact no prompt wording stopped
|
||||||
(verified against mistral:7b), and both reached the real memory DB: absence
|
(verified against mistral:7b), and both reached the real memory DB: absence
|
||||||
claims read off the existing-memory block ("Jon does not have any pets",
|
claims read off the existing-memory block ("the user does not have any pets",
|
||||||
which contradicted four cats on file) and specifics lifted from the
|
which contradicted four cats on file) and specifics lifted from the
|
||||||
ASSISTANT's reply ("Jon's main development machine is a MacBook Pro", from
|
ASSISTANT's reply ("the user's main development machine is a MacBook Pro", from
|
||||||
the user message "What am I developing on?"). Deterministic guard, so it
|
the user message "What am I developing on?"). Deterministic guard, so it
|
||||||
holds whatever the model does."""
|
holds whatever the model does."""
|
||||||
from synapse.memory.extractor import _reject_reason
|
from synapse.memory.extractor import _reject_reason
|
||||||
|
|
||||||
# Absence claims are never facts.
|
# Absence claims are never facts.
|
||||||
assert _reject_reason("Jon does not have any pets", "do i have any pets?")
|
assert _reject_reason("the user does not have any pets", "do i have any pets?")
|
||||||
assert _reject_reason("Jon's favorite episode is unknown", "what's my favorite episode?")
|
assert _reject_reason("the user's favorite episode is unknown", "what's my favorite episode?")
|
||||||
assert _reject_reason("Jon has not specified an interest", "tell me about stargate")
|
assert _reject_reason("the user has not specified an interest", "tell me about stargate")
|
||||||
|
|
||||||
# Specifics the user never typed came from the assistant.
|
# Specifics the user never typed came from the assistant.
|
||||||
assert _reject_reason("Jon's main dev machine is a MacBook Pro", "What am I developing on?")
|
assert _reject_reason("the user's main dev machine is a MacBook Pro", "What am I developing on?")
|
||||||
|
|
||||||
# ...but the same shape grounded in the user's own words must survive.
|
# ...but the same shape grounded in the user's own words must survive.
|
||||||
assert _reject_reason(
|
assert _reject_reason(
|
||||||
"Jon owns a 2000 Ford Ranger with a 3.0L V6",
|
"the user owns a 2000 Ford Ranger with a 3.0L V6",
|
||||||
"i also have a 2000 Ford Ranger, it's a five-speed with a 3.0L V6") is None
|
"i also have a 2000 Ford Ranger, it's a five-speed with a 3.0L V6") is None
|
||||||
assert _reject_reason(
|
assert _reject_reason(
|
||||||
"Jon has a beagle named Biscuit",
|
"the user has a beagle named Biscuit",
|
||||||
"i just adopted a dog named Biscuit, he's a beagle") is None
|
"i just adopted a dog named Biscuit, he's a beagle") is None
|
||||||
# A fact carrying no proper nouns or numbers can't be grounding-checked;
|
# A fact carrying no proper nouns or numbers can't be grounding-checked;
|
||||||
# the prompt owns that case, so the guard must let it through.
|
# the prompt owns that case, so the guard must let it through.
|
||||||
assert _reject_reason(
|
assert _reject_reason(
|
||||||
"Jon prefers short answers over long explanations",
|
"the user prefers short answers over long explanations",
|
||||||
"i really prefer short answers over long explanations") is None
|
"i really prefer short answers over long explanations") is None
|
||||||
|
|
||||||
|
|
||||||
@@ -636,3 +645,45 @@ def test_preview_iframe_cannot_navigate_to_a_network_url():
|
|||||||
assert "encodeURIComponent(doc)" in markdown
|
assert "encodeURIComponent(doc)" in markdown
|
||||||
assert "src={frameUrl}" in markdown
|
assert "src={frameUrl}" in markdown
|
||||||
assert "srcDoc={doc}" not in markdown
|
assert "srcDoc={doc}" not in markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
|
||||||
|
from synapse import main
|
||||||
|
# Fake git so the test never touches the network. Behind → the remote
|
||||||
|
# VERSION file, not this checkout's, is what the UI advertises.
|
||||||
|
calls = {
|
||||||
|
("rev-list", "--count", "HEAD..origin/main"): "3",
|
||||||
|
("show", "origin/main:VERSION"): "9.9.9\n",
|
||||||
|
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
|
||||||
|
body = TestClient(app).get("/update/check").json()
|
||||||
|
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
|
||||||
|
|
||||||
|
# An unreachable remote must not 500 the sidebar.
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise RuntimeError("could not resolve host")
|
||||||
|
monkeypatch.setattr(main, "_git", boom)
|
||||||
|
body = TestClient(app).get("/update/check").json()
|
||||||
|
assert body["behind"] == 0 and "could not resolve host" in body["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
|
||||||
|
import subprocess
|
||||||
|
from synapse import main
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_popen(argv, **kw):
|
||||||
|
seen["argv"], seen["kw"] = argv, kw
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_update_running", False)
|
||||||
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.post("/update/apply").json()["started"] is True
|
||||||
|
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
|
||||||
|
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
|
||||||
|
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
|
||||||
|
|
||||||
|
# Double-click must not launch a second pull/rebuild over the first.
|
||||||
|
assert client.post("/update/apply").json()["started"] is False
|
||||||
|
|||||||
@@ -892,3 +892,60 @@ async def _drain_with_messages(manager, model, schemas, user="draw a circle"):
|
|||||||
_run_tool_loop(manager, messages, model, schemas, None, None)
|
_run_tool_loop(manager, messages, model, schemas, None, None)
|
||||||
)
|
)
|
||||||
return statuses, messages
|
return statuses, messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_stays_inside_the_repo():
|
||||||
|
"""The repo-file tools are the fix for the model inventing paths like
|
||||||
|
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
def read(p):
|
||||||
|
return asyncio.run(tools._read_file(p))
|
||||||
|
|
||||||
|
assert "escapes" in read("../../etc/passwd")
|
||||||
|
# a leading slash is treated as repo-relative, so it lands nowhere real
|
||||||
|
assert "root:" not in read("/etc/passwd")
|
||||||
|
assert "required" in read("")
|
||||||
|
# private data and heavy trees are refused even though they're in-repo
|
||||||
|
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
|
||||||
|
assert "not readable" in read(denied), denied
|
||||||
|
assert "does not exist" in read("nexus/nlp.py")
|
||||||
|
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_files_globs_the_repo_without_leaking_denied_paths():
|
||||||
|
import json
|
||||||
|
|
||||||
|
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
|
||||||
|
assert "synapse/main.py" in hits
|
||||||
|
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
|
||||||
|
|
||||||
|
|
||||||
|
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
|
||||||
|
"""A reference playbook routed into the prompt must bring its tools with it.
|
||||||
|
Without this the model reads instructions like "you can read the codebase"
|
||||||
|
while being advertised zero tools — and narrates tool calls it never made."""
|
||||||
|
from synapse.main import _route_playbooks
|
||||||
|
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
||||||
|
|
||||||
|
# Own store, not data/playbooks: the live set is the operator's, and a
|
||||||
|
# published clone ships different playbooks - this asserted on data that
|
||||||
|
# travels with one machine.
|
||||||
|
store = PlaybookFileStore(tmp_path)
|
||||||
|
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
|
||||||
|
instructions="i", order=0))
|
||||||
|
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
|
||||||
|
instructions="You can read the codebase.", order=1,
|
||||||
|
tags=["synapse", "backend"],
|
||||||
|
tools=["read_file", "list_files"]))
|
||||||
|
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
||||||
|
import synapse.playbook_manager as pm
|
||||||
|
|
||||||
|
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
|
||||||
|
names = {pb.title for pb in routed}
|
||||||
|
assert "NexusOS Developer" in names, names
|
||||||
|
|
||||||
|
granted = {t for pb in routed for t in (pb.tools or [])}
|
||||||
|
assert {"read_file", "list_files"} <= granted, granted
|
||||||
|
# none of them are action tools, so they survive the default policy (off)
|
||||||
|
assert tools.schemas_for(sorted(granted), allow_actions=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user