diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 66b068ba..c08f58f1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,7 +27,9 @@ concurrency:
# free = unlicensed (pro tabs locked), a valid Keygen key unlocks in place (no
# re-download). The ASAR integrity fuse (electron-builder.yml) makes a tampered
# app.asar refuse to load — our local-first anti-tamper lever (we don't phone home).
-# Actions are pinned to commit SHAs. Windows is parked.
+# Actions are pinned to commit SHAs. macOS (build-mac) and Windows (build-win) share
+# the one `version` job and publish to the same release; Windows runtimes come from
+# fetch-win-binaries.ps1 rather than the macOS-only LFS binaries.
jobs:
# Resolve the channel + version. STABLE (manual promote) bumps the patch and
# commits it so main tracks the released version; BETA (every merge) stamps a
@@ -284,3 +286,100 @@ jobs:
NOTES_FILE: release-notes.md
RELEASE_URL: https://github.com/off-grid-ai/off-grid-ai-desktop/releases/tag/v${{ needs.version.outputs.version }}
run: node scripts/notify-slack-release.mjs
+
+ # Windows release build. Mirrors build-mac: it consumes the SAME resolved version
+ # + channel from the `version` job and publishes the NSIS installer plus the
+ # Windows electron-updater feed (latest.yml for stable, beta.yml for nightly) to
+ # the SAME GitHub release, so Windows and macOS ship as one version with working
+ # auto-update. Runs AFTER build-mac so the v$VERSION release already exists — this
+ # avoids a release-creation race between two concurrent `electron-builder --publish`
+ # runs. Windows native runtimes are fetched by scripts/fetch-win-binaries.ps1
+ # (the repo's LFS binaries are macOS-only).
+ build-win:
+ needs: [version, build-mac]
+ # windows-2022 = VS 2022 toolchain. windows-latest ships VS 2026, which node-gyp 11
+ # can't parse, breaking native-module (better-sqlite3) compiles. Pin until it catches up.
+ runs-on: windows-2022
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ ref: ${{ needs.version.outputs.sha }} # the exact bumped commit, same as build-mac
+ lfs: false # Windows runtimes come from fetch-win-binaries.ps1, not the LFS dylibs
+ persist-credentials: false # don't leave the token in .git/config for npm postinstall scripts
+ - name: Check out private pro source into pro/
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ repository: off-grid-ai/desktop-pro
+ token: ${{ secrets.PRO_REPO_TOKEN }}
+ path: pro
+ fetch-depth: 1
+ persist-credentials: false # keep PRO_REPO_TOKEN out of pro/.git/config
+ - name: Drop nested .git from pro/
+ shell: bash
+ run: rm -rf pro/.git # don't nest a repo inside the build tree
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ # node-gyp (better-sqlite3-multiple-ciphers) fails on Python 3.13+; pin 3.12.
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install dependencies
+ run: npm ci
+ - name: Fetch Windows native binaries (llama/whisper/sd/ffmpeg)
+ shell: pwsh
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # raises the GitHub API rate limit
+ run: ./scripts/fetch-win-binaries.ps1
+ - name: Stamp build version
+ run: npm version "${{ needs.version.outputs.version }}" --no-git-tag-version --allow-same-version
+ - name: Build (typecheck + bundle)
+ run: npm run build
+ - name: Package & publish (NSIS installer + auto-update metadata)
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Optional Windows code signing — a no-op if the secrets are absent (the
+ # build still publishes, unsigned; SmartScreen warns until a cert is added).
+ CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
+ run: |
+ # Same version tag as build-mac -> both platforms land on ONE release.
+ # stable -> latest.yml, published as a normal release (everyone)
+ # beta -> beta.yml (from the -beta suffix), published as a PRE-RELEASE
+ if [ "${{ needs.version.outputs.channel }}" = "stable" ]; then
+ npx electron-builder --win -c.publish.releaseType=release --publish always
+ else
+ npx electron-builder --win -c.publish.releaseType=prerelease --publish always
+ fi
+ # Permanent Windows download link — the Windows counterpart to the Mac
+ # OffGrid-latest.dmg alias. The versioned installer (off-grid-ai--setup.exe)
+ # changes name every release, so /releases/latest/download/ can't target
+ # it; upload a constant-named copy for a stable URL:
+ # https://github.com/off-grid-ai/off-grid-ai-desktop/releases/latest/download/OffGrid-latest-setup.exe
+ # The versioned installer + latest.yml updater feed are untouched, so auto-update
+ # is unaffected. --clobber replaces the copy from the prior run.
+ - name: Publish stable latest-setup.exe alias
+ if: needs.version.outputs.channel == 'stable'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ needs.version.outputs.version }}
+ run: |
+ cp dist/*-setup.exe dist/OffGrid-latest-setup.exe
+ gh release upload "v$VERSION" dist/OffGrid-latest-setup.exe --clobber
+ # Constant nightly Windows link on the rolling 'nightly' pre-release (mirrors the
+ # Mac nightly.dmg link). build-win runs after build-mac, which already ensured the
+ # 'nightly' release exists — the fallback create is just belt-and-suspenders.
+ - name: Publish constant nightly-setup.exe link
+ if: needs.version.outputs.channel == 'beta'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ cp dist/*-setup.exe dist/OffGrid-nightly-setup.exe
+ gh release view nightly >/dev/null 2>&1 || \
+ gh release create nightly --prerelease --title "Nightly (rolling)" \
+ --notes "Rolling nightly build, auto-updated on every change. Pre-release - expect rough edges."
+ gh release upload nightly dist/OffGrid-nightly-setup.exe --clobber
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
new file mode 100644
index 00000000..197d401c
--- /dev/null
+++ b/.github/workflows/windows-build.yml
@@ -0,0 +1,93 @@
+name: Windows Build (branch)
+
+# Standalone Windows build for the feat/windows-support branch. Kept separate
+# from release.yml (macOS core+pro) on purpose: this neither bumps the version
+# nor publishes to Releases — it packages the NSIS installer and uploads it as a
+# workflow artifact you can download. Re-fold a windows-2022 job into release.yml
+# once a build is verified on a real Windows machine.
+
+on:
+ push:
+ branches:
+ - feat/windows-support
+ workflow_dispatch: # lets you trigger a build manually from the Actions tab
+
+permissions:
+ contents: read
+
+jobs:
+ build-win:
+ # windows-2022 = VS 2022 toolchain. windows-latest ships VS 2026 (VS 18),
+ # which node-gyp 11 can't parse — breaking native-module (better-sqlite3,
+ # node-llama-cpp) compiles. Pin until node-gyp catches up.
+ runs-on: windows-2022
+ env:
+ # True when the private-repo token is configured, so we bundle Pro (same as
+ # the Mac release build). Falsey on a fork / missing secret → core-only exe.
+ # Can't reference `secrets` directly in a step `if`, so surface it as env.
+ HAS_PRO_TOKEN: ${{ secrets.PRO_REPO_TOKEN != '' }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false # don't leave the token in .git/config for npm postinstall scripts
+ lfs: false # the repo's LFS binaries are macOS-only; Windows runtimes
+ # come from fetch-win-binaries.ps1, so don't pull ~235MB of
+ # dylibs we won't ship.
+
+ # Private pro source into pro/ → __OFFGRID_PRO__ true, so the exe bundles the
+ # Pro layer (mirrors release.yml). Needed to exercise the Pro "coming soon"
+ # gate on Windows. Pro stays license-gated at runtime; launch with
+ # OFFGRID_PRO=1 to force it on for testing without a license. Skipped (core
+ # build) when PRO_REPO_TOKEN isn't available.
+ - uses: actions/checkout@v4
+ if: env.HAS_PRO_TOKEN == 'true'
+ with:
+ repository: off-grid-ai/desktop-pro
+ token: ${{ secrets.PRO_REPO_TOKEN }}
+ path: pro
+ fetch-depth: 1
+ persist-credentials: false # keep PRO_REPO_TOKEN out of pro/.git/config
+ - name: Drop nested .git from pro/
+ if: env.HAS_PRO_TOKEN == 'true'
+ shell: bash
+ run: rm -rf pro/.git # don't nest a repo inside the build tree
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ # node-gyp (better-sqlite3-multiple-ciphers) fails on Python 3.13+; pin 3.12.
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Fetch Windows native binaries (llama/whisper/sd/ffmpeg)
+ shell: pwsh
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # raises the GitHub API rate limit
+ run: ./scripts/fetch-win-binaries.ps1
+
+ - name: Build (typecheck + bundle)
+ run: npm run build
+
+ - name: Package Windows installer (NSIS)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Optional Windows code signing (no-op if absent → unsigned; SmartScreen
+ # will warn until you add a cert via these secrets).
+ CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
+ run: npx electron-builder --win --publish never
+
+ - name: Upload installer artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: off-grid-ai-windows
+ path: |
+ dist/*.exe
+ dist/*.zip
+ if-no-files-found: error
diff --git a/README.md b/README.md
index af116fa5..b8ad081e 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
@@ -222,21 +223,45 @@ locked until a valid key is activated.
Grab the latest build from [Releases](https://github.com/off-grid-ai/desktop/releases/latest):
-- **macOS** (Apple Silicon) — signed + notarized `.dmg`
-
-macOS only for now.
+- **macOS** (Apple Silicon) - signed + notarized `.dmg`
+- **Windows** (x64) — NSIS installer (`.exe`)
## Build from source
```bash
git clone https://github.com/off-grid-ai/desktop.git
cd desktop
+git lfs install && git lfs pull # pull the bundled native binaries (LFS) - REQUIRED
npm install
npm run dev # full app
npm run gateway # headless gateway only (:7878)
npm run build:mac # package a macOS app
```
+> The `resources/bin/**` runtimes (llama.cpp, whisper.cpp, stable-diffusion.cpp,
+> ffmpeg) are stored in **Git LFS**. Without `git lfs pull` you get 131-byte
+> pointer stubs and every runtime spawn fails with `ENOEXEC`.
+
+### Build for Windows
+
+The committed LFS binaries are macOS-only; the Windows runtimes are fetched from
+upstream releases at build time. **Build on a Windows machine** (native modules
+must compile there - cross-building from macOS is not supported):
+
+```powershell
+git clone https://github.com/off-grid-ai/desktop.git
+cd desktop
+npm install
+./scripts/fetch-win-binaries.ps1 # pull win64 llama/whisper/sd/ffmpeg into resources/bin
+npm run dev # run locally, or:
+npm run build:win # package the NSIS installer → dist\*-setup.exe
+```
+
+Prereqs on Windows: Node 20, Python 3.12 (node-gyp can't parse VS 2026 yet — use
+the **VS 2022** Build Tools), and Git. CI also builds Windows on every push to
+`feat/windows-support` (`.github/workflows/windows-build.yml`) and uploads the
+installer as a downloadable artifact.
+
Stack: Electron 39 + React 19 + Tailwind v4 (electron-vite),
`better-sqlite3-multiple-ciphers` (encrypted local DB), `@lancedb/lancedb` (vectors),
bundled `llama.cpp` / `whisper.cpp` / `stable-diffusion.cpp` / `ffmpeg` in `resources/bin`.
diff --git a/docs/WINDOWS_SUPPORT.md b/docs/WINDOWS_SUPPORT.md
new file mode 100644
index 00000000..a7667622
--- /dev/null
+++ b/docs/WINDOWS_SUPPORT.md
@@ -0,0 +1,93 @@
+# Off Grid AI — Core Feature Matrix for Windows
+
+Tracking sheet for bringing the **core (free, open-source) app** to Windows on the
+`feat/windows-support` branch. This branch is our working `develop` for Windows —
+branch off it for every change.
+
+**Scope: core only.** "Core" = the free studio + gateway (everything in
+[FEATURES.md](FEATURES.md)). The **Pro** "sees / remembers / acts" layer lives in the
+excluded `pro/` submodule (`!pro/**` in `electron-builder.yml`), ships macOS-signed
+binaries only, and is **out of scope here** — see the [Pro section](#pro-layer-out-of-scope)
+at the bottom.
+
+> Statuses reflect **code inspection plus a verified run on real Windows hardware** for the
+> core flows (app launch, chat, image generation, embeddings). Rows still marked "needs
+> testing" have not yet been exercised end-to-end on a Windows machine.
+
+## Legend
+
+| Status | Meaning |
+|---|---|
+| 🟢 **Present — needs testing** | Cross-platform code path exists and any Windows-specific handling is implemented. Not yet verified on a real Windows machine. |
+| 🟡 **At risk — needs testing** | Implemented, but there's a known Windows-specific gap or fragile spot to confirm during testing (details in notes). |
+| 🔴 **Not present** | Not built / not wired for Windows yet. Work required. |
+| ⚪ **N/A — Apple-only by design** | Will never run on Windows (Apple Silicon / Core ML / macOS Vision). Feature degrades gracefully; a cross-platform fallback usually covers it. |
+
+---
+
+## 1. Build, packaging & distribution
+
+| Item | Status | Notes / evidence |
+|---|---|---|
+| Windows CI build | 🟢 | `.github/workflows/windows-build.yml` (`windows-2022`) builds + packages a branch artifact; `release.yml`'s `build-win` job publishes the installer + updater feed to the release. Verified by installing a build on real Windows hardware. |
+| Native-module compile (node-gyp) | 🟢 | Pinned toolchain: `windows-2022` (VS 2022) + Python 3.12. `windows-latest`/VS 2026 + Python 3.13 break node-gyp 11 — documented in the workflow. Covers `better-sqlite3-multiple-ciphers`, `node-llama-cpp`, `sharp`. |
+| Windows runtime binaries fetch | 🟢 | `scripts/fetch-win-binaries.ps1` pulls win64 `llama-server` / `whisper-cli` / `sd-cli` / `ffmpeg` (+ DLLs) from upstream GitHub releases at build time. **`llama-server` is pinned to `b9838`** (byte-for-byte parity with the macOS engine); `whisper-cli` / `sd-cli` / `ffmpeg` resolve dynamically from their latest upstream releases. Repo LFS binaries are macOS-only and skipped (`lfs: false`). Fails loud if `llama-server.exe` is missing. |
+| NSIS installer | 🟢 | `electron-builder.yml` → `win.executableName`, `nsis` block (desktop shortcut, uninstall name). Untested end-to-end. |
+| Code signing | 🟡 | Optional via `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` secrets; **unset → unsigned build → SmartScreen will warn** on install. No cert configured yet. |
+| Auto-update | 🟢 | `electron-updater` is cross-platform (`src/main/updater.ts`); `release.yml`'s `build-win` job publishes `latest.yml` (stable) / `beta.yml` (nightly) to the release, so Windows installs self-update like macOS. |
+
+---
+
+## 2. Core runtime features (the studio)
+
+| Feature | Status | Depends on | Notes / evidence |
+|---|---|---|---|
+| **The Gateway** (OpenAI-compatible API on `:7878`) | 🟢 | llama-server + Node HTTP | No platform-specific code; rides on chat. Headless `--server-only` path is pure Node. |
+| **Chat** (text + vision + reasoning, streaming) | 🟢 | `llama-server.exe` | `src/main/llm.ts` is the most Windows-hardened path: `exe()` suffix, DLL-dir prepended to `PATH`, and orphan-port cleanup via `netstat`/`tasklist`/`taskkill`. Highest-confidence runtime. |
+| **Model catalog + Hugging Face download** | 🟢 | Node fetch | `@offgrid/models` + `models-manager.ts` — pure JS, downloads into userData. Path handling is cross-platform. |
+| **Image generation — SD/SDXL/Z-Image (GGUF)** | 🟡 | `sd-cli.exe` | `src/main/imagegen.ts` uses `exe('sd-cli')` and the fetch script ships the win build + DLLs. **Gap to check:** the spawn only sets `cwd = binary dir` (`imagegen.ts:628`) and, unlike `llm.ts`, does **not** prepend the bin dir to `PATH`. Relies on Windows' default "load DLLs from the exe's own directory" behaviour — verify SD DLLs resolve. |
+| ↳ Image gen — **MLX / FLUX.2 / Z-Image-via-MLX** | ⚪ | mflux (Apple MLX) | `src/main/mflux.ts` is explicitly Apple-Silicon-only (`process.platform !== 'darwin'` gated off). On Windows, MLX models are simply not offered; **Z-Image still works via the `sd-cli` GGUF path.** |
+| ↳ Image gen — **Core ML / ANE acceleration** | ⚪ | `coreml-sd` (Swift) | macOS-only, gated off in `imagegen.ts`. Windows uses the standard `sd-cli` path. |
+| **Voice — Speech→Text** (whisper.cpp) | 🟢 | `whisper-cli.exe` + `ffmpeg.exe` | `src/main/rag/extractors.ts` uses `exe('whisper-cli')` / `exe('ffmpeg')`; both fetched by the PS1 script. ffmpeg is a self-contained static build. Untested. |
+| **Voice — Text→Speech** (Kokoro-82M) | 🟢 | onnxruntime-node worker | `src/main/tts.ts` runs the worker as Electron-as-Node (`ELECTRON_RUN_AS_NODE=1`) with a cross-platform prebuilt ORT. No platform branching. Untested. |
+| **Hands-free voice mode** | 🟢 | STT + TTS above | Renderer orchestration only; inherits STT/TTS status. |
+| **Embeddings** (`@xenova/transformers`) | 🟢 | onnxruntime-node | Prebuilt native runtime, cross-platform. Powers RAG + `/v1/embeddings`. |
+| **Projects / RAG** (docs, cited retrieval) | 🟢 | LanceDB + better-sqlite3 | Text/PDF/DOCX extraction is pure JS; vector store is `@lancedb/lancedb` (native, compiled in CI). Image docs are captioned by the **vision model** (not OCR), so they're cross-platform. Audio/video ingestion inherits whisper/ffmpeg status. |
+| **Artifacts / canvas** (HTML/React/SVG/Mermaid) | 🟢 | Renderer only | Sandboxed iframe, no platform code. Very high confidence. |
+| **Connectors (MCP)** | 🟢 | stdio / HTTP transports | HTTP/SSE connectors are platform-neutral. stdio connectors spawn via `StdioClientTransport` (`src/main/mcp.ts:114`), whose SDK uses **`cross-spawn`** — which resolves `npx` → `npx.cmd` via `cmd.exe /c` on Windows automatically. The classic gotcha is already handled by the dependency; still worth a live test. |
+| **Tools in chat** (calculator, datetime, web search) | 🟢 | Node | `src/main/tools.ts` — pure JS. Web search is the one intentionally-online feature (DuckDuckGo fetch). |
+| **Encryption at rest** | 🟢 | `better-sqlite3-multiple-ciphers` | Native module compiled in CI (node-gyp). Cross-platform SQLite cipher. |
+| **Onboarding / Settings / Command palette / theming** | 🟢 | Renderer only | Pure web UI, no platform code. |
+
+---
+
+## 3. Known Windows risks to confirm during testing
+
+Ordered by likelihood of biting:
+
+1. **Signing / SmartScreen.** The first release ships unsigned unless `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` are set, so SmartScreen warns on install. Add a cloud-signing cert (e.g. Azure Trusted Signing) to clear it.
+2. **`sd-cli` DLL resolution** — `imagegen.ts:628` sets `cwd` but not `PATH` (chat's `llm.ts` does both). Windows searches the exe's own dir for DLLs by default, so this is *probably* fine; if SD fails to load its DLLs, mirror the `llm.ts` `PATH`-prepend fix (a few lines).
+3. **Upstream binary compatibility** — the fetched llama/whisper/sd builds are CPU/AVX2 x64 baselines; confirm they spawn (no missing VC++ redistributable, correct AVX level) on target hardware.
+4. **Unsigned installer / SmartScreen** — expected until a signing cert is added; will scare testers.
+5. **Cross-repo publish** - `build-win`'s `electron-builder --publish` and the `gh release upload` aliases must land on the same release as the macOS assets; verify on the first `release.yml` Windows run.
+
+---
+
+## 4. How to produce a Windows build
+
+Push to `feat/windows-support` (or trigger `Windows Build (branch)` manually from the
+Actions tab) → download the `off-grid-ai-windows` artifact → install on a Windows machine.
+Do **not** expect it in GitHub Releases; this workflow deliberately doesn't publish.
+
+---
+
+## Pro layer (out of scope)
+
+The Pro "sees / remembers / reflects / acts" layer is **not part of core** and is **not
+present on Windows** (🔴). Its native binaries are macOS-only:
+
+- Screen capture watcher (Swift) and meeting recorder — macOS binaries added by the Pro build.
+- **OCR** — `src/main/ocr.ts` shells out to a bundled **macOS Vision** binary; no Windows equivalent. (Note: this is *not* used by core image-RAG, which captions via the vision model.)
+- macOS permissions (screen recording / accessibility) — `src/main/permissions.ts` is fully `darwin`-gated and no-ops elsewhere.
+
+Porting Pro to Windows is a separate effort and not tracked in this matrix.
diff --git a/docs/WINDOWS_TEST_MODELS.md b/docs/WINDOWS_TEST_MODELS.md
new file mode 100644
index 00000000..6b09968e
--- /dev/null
+++ b/docs/WINDOWS_TEST_MODELS.md
@@ -0,0 +1,148 @@
+# Off Grid AI — Windows Test: Exact Models & Test Data
+
+Companion to [WINDOWS_TEST_PLAN.md](WINDOWS_TEST_PLAN.md). This tells the tester **exactly
+which models to download** for each suite (by the name shown in the app), how big they are,
+and what test inputs to use. Model names below match the **Models** screen catalog verbatim.
+
+> **How you download:** Sidebar → **Models**. The screen groups models by kind
+> (text / vision / image / voice / transcription). Find the exact **name** below, click its
+> **Download**, wait for it to finish and show as installed/active. Everything is on-device,
+> so each model is a one-time download.
+
+---
+
+## 0. First, check your RAM — it decides which sizes you can run
+
+Press **Win + Pause** (or Task Manager → Performance) to see installed RAM, then use the
+smallest option that fits. Bigger = better quality but slower / needs more RAM.
+
+| Your RAM | Use the "Light" picks below | Can also try "Standard" picks |
+|---|---|---|
+| 8 GB | ✅ required | ⚠️ only the ~4GB image model, one at a time |
+| 16 GB | ✅ | ✅ |
+| 24 GB+ | ✅ | ✅ (plus the large models if you want) |
+
+**Only ONE large model loads at a time.** Chat and image generation can't both be resident —
+the app swaps them automatically, so don't be alarmed if starting an image generation pauses
+chat briefly.
+
+---
+
+## 1. Minimal download set (do this for the critical path A→D + core suites)
+
+Download these **five** models first. Total ≈ **6.9 GB**.
+
+| Suite | Kind | Model name (in app) | Size | Min RAM |
+|---|---|---|---|---|
+| C — Chat | text | **Qwen 3.5 0.8B** | 0.53 GB | 3 GB |
+| C — Chat (vision) | vision | **Qwen3-VL 2B** | 1.9 GB (incl. vision file) | 6 GB |
+| E — Image gen | image | **SDXL Lightning (4-step)** | 4.1 GB | 8 GB |
+| F — Voice (speak) | voice | **Kokoro TTS 82M** | ~0.1 GB | 3 GB |
+| F — Voice (dictate) | transcription | **Whisper Base** | 0.15 GB | 3 GB |
+
+That set lets you run every core suite. Everything below is optional depth.
+
+---
+
+## 2. Per-suite model picks (with alternatives)
+
+### Suite C — Chat (text)
+| Role | Model name | HF repo | Size | Notes |
+|---|---|---|---|---|
+| **Light (start here)** | **Qwen 3.5 0.8B** | `unsloth/Qwen3.5-0.8B-GGUF` | 0.53 GB | Tiny + fast; best first smoke test — proves `llama-server.exe` runs. |
+| Standard | Qwen 3.5 4B | `unsloth/Qwen3.5-4B-GGUF` | 2.7 GB | Better answers; use if you have ≥8 GB. |
+| Reasoning check (TC-CHAT-03) | Qwen 3.5 2B or 4B | — | — | These support "thinking" mode; use one of them for the reasoning test. |
+
+### Suite C — Chat with images (vision) → also used by TC-CHAT-04 & TC-PROJ-04
+| Role | Model name | HF repo | Size | Notes |
+|---|---|---|---|---|
+| **Light (start here)** | **Qwen3-VL 2B** | `unsloth/Qwen3-VL-2B-Instruct-GGUF` | 1.9 GB | Downloads the vision add-on automatically. Lightest capable vision model. |
+| Alternative light | SmolVLM2 2.2B | `ggml-org/SmolVLM2-2.2B-Instruct-GGUF` | 2.0 GB | Equivalent; use if Qwen3-VL misbehaves. |
+| Standard | Gemma 4 E4B | `unsloth/gemma-4-E4B-it-GGUF` | 6.0 GB | Higher quality vision + thinking; needs more RAM. |
+
+> A "vision" model is what lets chat **see attached images**. Without one, TC-CHAT-04 and the
+> image parts of Projects can't work — that's expected, not a bug.
+
+### Suite E — Image generation
+| Role | Model name | HF repo | Size | Notes |
+|---|---|---|---|---|
+| **Start here** | **SDXL Lightning (4-step)** | `mzwing/SDXL-Lightning-GGUF` | 4.1 GB | Single file, "Recommended" — simplest path to prove `sd-cli.exe` + its DLLs load on Windows. **Do this one first.** |
+| Fastest drafts | SDXL Turbo (fast drafts) | `OlegSkutte/sdxl-turbo-GGUF` | 4.1 GB | 1–4 step quick drafts. |
+| **Advanced (test 2nd)** | Z-Image Turbo (2026) | `leejet/Z-Image-Turbo-GGUF` | ~6.7 GB total | Flagship, but uses a **multi-file** pipeline (downloads a text-encoder + VAE too). Because it's a more complex spawn, test it **after** SDXL Lightning succeeds — if Lightning works and Z-Image doesn't, note that difference. |
+| img2img (TC-IMG-03) | SDXL Lightning or any SDXL above | — | — | The SDXL models support image-to-image; Z-Image is txt2img only. |
+
+> **Image gen is the #1 Windows risk area.** If generation fails, grab the console text
+> (per the test plan) — a `.dll` / library error here is exactly what we're hunting for.
+
+### Suite F — Voice
+| Role | Model name | HF repo | Size | Notes |
+|---|---|---|---|---|
+| **Text-to-speech (speak)** | **Kokoro TTS 82M** | `onnx-community/Kokoro-82M-v1.0-ONNX` | ~0.1 GB | Default voice; used by TC-VOICE-01 / 03. |
+| TTS alternative | Piper – Lessac (English) | `rhasspy/piper-voices` | 0.06 GB | Use only if Kokoro fails. |
+| **Speech-to-text (dictate)** | **Whisper Base** | `ggerganov/whisper.cpp` (base) | 0.15 GB | Default for TC-VOICE-02 / 03. Proves `whisper-cli.exe` + `ffmpeg.exe`. |
+| STT lightest | Whisper Tiny | `ggerganov/whisper.cpp` (tiny) | 0.08 GB | Fastest, lower accuracy — fine for a functional test. |
+| STT best | Whisper Large v3 Turbo | `ggerganov/whisper.cpp` (large-v3-turbo) | 1.6 GB | Only if you want to check accuracy on ≥6 GB RAM. |
+
+### Suites G/H/J/K (Projects, Artifacts, Tools, Settings)
+No extra models needed — they reuse the **text** model from Suite C (and the **vision** model
+for image documents in TC-PROJ-04). Web search (TC-TOOL-02) needs internet but no model.
+
+---
+
+## 3. Test input files to prepare (create these before you start)
+
+Put these in a folder like `C:\OffGridTest\` so they're easy to find in file pickers.
+
+| File | For test | How to make it |
+|---|---|---|
+| `budget.txt` | TC-PROJ-02 | A plain text file containing exactly: `The Q3 project budget is $5,000 and the deadline is March 14.` |
+| `budget.pdf` | TC-PROJ-02 (alt) | Same text saved/printed as a PDF (to test PDF extraction too). |
+| `photo.jpg` | TC-CHAT-04 / TC-PROJ-04 | Any clear photo — e.g. a picture of a **dog on grass**, or a screenshot with visible text. |
+| `sign.png` | TC-PROJ-04 | An image containing readable text (a sign, a slide) — checks the model reads text from images. |
+| short `speech.wav`/`.mp3` | TC-PROJ-04 (audio) | Record ~10 s of you saying a sentence, or grab any short clip. |
+
+For the doc-grounding test (TC-PROJ-02), the expected AI answer to *"What is the budget?"* is
+**"$5,000"** with a cited source — that specific number is why we plant it in the file.
+
+---
+
+## 4. Suite I (Integrations / MCP) — exact connector to add
+
+This tests the Windows-sensitive `npx` launch path. Use the official **filesystem** reference
+server — it's public and needs no login (first launch downloads the package, so keep internet
+on for this test).
+
+**TC-INT-02 — add this stdio connector:**
+- Sidebar → **Integrations** → add connector → choose the **command** option.
+- **Name:** `Filesystem`
+- **command:** `npx`
+- **args:** `-y @modelcontextprotocol/server-filesystem C:\OffGridTest`
+- Save → **Connect**.
+- **Expected:** shows **Connected** (the app spawned `npx` → `npx.cmd` under the hood).
+- **Watch for:** a spawn / "command not found" error in the PowerShell console — capture it
+ exactly; that's the specific Windows behavior we're verifying.
+
+**TC-INT-03 — use it in chat:**
+- In a chat (with a text model loaded), ask: `List the files in C:\OffGridTest using your tools.`
+- **Expected:** the AI calls the filesystem tool and lists `budget.txt`, `photo.jpg`, etc.
+
+> Requires **Node.js installed** on the test machine for `npx` to exist. If Node isn't
+> installed, note that and skip TC-INT-02/03 (or install Node first) — it's a prerequisite of
+> the connector, not an app bug.
+
+**TC-INT-01 (HTTP connector)** — if you weren't given a real HTTP MCP endpoint, just verify
+the **URL** form opens and accepts input (`https://mcp.example.com/endpoint`) and that a bad
+URL fails gracefully. Don't file "couldn't connect" as a bug without a real endpoint.
+
+---
+
+## 5. Download-order cheat sheet
+
+1. **Qwen 3.5 0.8B** (text) → immediately do Suite C chat + Suite D gateway.
+2. **SDXL Lightning** (image) → Suite E.
+3. **Whisper Base** + **Kokoro TTS 82M** (voice) → Suite F.
+4. **Qwen3-VL 2B** (vision) → TC-CHAT-04, TC-PROJ-04.
+5. (Optional) **Z-Image Turbo** → advanced image test.
+
+If a download itself fails or hangs, that's a **Suite B (Models)** bug — report it with the
+model name and console output, and move on to whatever you *can* test.
diff --git a/docs/WINDOWS_TEST_PLAN.md b/docs/WINDOWS_TEST_PLAN.md
new file mode 100644
index 00000000..617f5bc5
--- /dev/null
+++ b/docs/WINDOWS_TEST_PLAN.md
@@ -0,0 +1,436 @@
+# Off Grid AI — Windows Test Plan (Core)
+
+**For the tester:** You don't need to know this product beforehand. This doc tells you what
+each feature is, exactly what to click, and what *should* happen. Your job is to run each
+test case on Windows and record **Pass / Fail / Blocked**, and file any problem using the
+[bug format](#3-how-to-report-a-bug) below.
+
+Local setup is already done, so there are no install-from-source instructions here — you're
+testing the **packaged Windows build** (the `.exe` installer produced by CI, or a local
+build you were given).
+
+---
+
+## 1. What this app is (30-second orientation)
+
+Off Grid AI is a desktop app that runs AI models **entirely on your own computer** — no
+internet account, no cloud. Think "a private ChatGPT that lives on this PC." It can:
+
+- **Chat** with an AI (text, and images you attach)
+- **Generate images** from a text prompt
+- **Voice**: turn speech into text and text into speech
+- **Projects**: upload your documents and ask questions about them
+- **Gateway**: expose a local web API other programs can call
+- **Integrations (MCP)**: plug in external tool servers
+- **Artifacts**: the AI can render mini web pages / diagrams live
+
+Everything happens locally, so the **first time you use a feature you usually have to
+download a model** for it. That's expected.
+
+### The window layout
+- A **left sidebar** with these items: **Projects, Chat, Integrations, Models, Gateway,
+ Settings**. (You navigate by clicking these.)
+- Some sidebar items may show a **lock icon or an "Upgrade / Pro" screen** when clicked
+ (e.g. Day, Replay, Reflect, Meetings, Actions). **These are "Pro" features and are OUT
+ OF SCOPE — skip them.** Only test the items listed above.
+- The app should work **fully offline**. The *only* feature that intentionally uses the
+ internet is downloading models and "web search" in chat.
+
+---
+
+## 2. Before you start — record your environment
+
+Fill this once and put it at the top of every bug report:
+
+```
+Build / installer file name + version : __________ (e.g. off-grid-ai-0.0.25-setup.exe)
+Windows version : __________ (Win + R → "winver")
+CPU model : __________
+RAM (GB) : __________
+GPU (if any) : __________
+```
+
+### How to capture logs (do this — most bugs are useless without logs)
+The packaged app hides its internal logs by default. To see them, **launch it from a
+terminal so its output prints there:**
+
+1. Open **PowerShell**.
+2. Run the app's executable directly, e.g.:
+ ```powershell
+ & "$env:LOCALAPPDATA\Programs\off-grid-ai\off-grid-ai.exe"
+ ```
+ (If it installed elsewhere, right-click the desktop shortcut → *Open file location* to
+ find the `.exe`, then run that path.)
+3. Leave this PowerShell window open — **error messages and `[llama-server]` / `[OCR]` /
+ `[update]` lines print here.** Copy/paste relevant lines into your bug report.
+
+**Where app data lives** (models, database, generated images) — useful to attach or clear:
+```
+%APPDATA%\Off Grid AI (try this first)
+%APPDATA%\off-grid-ai (fallback)
+```
+Open by pasting that into the File Explorer address bar.
+
+---
+
+## 3. How to report a bug
+
+For **every** failure, copy this template into your tracker (Jira/GitHub/Sheet) and fill it:
+
+```
+BUG ID : WIN-001
+Test case : TC-CHAT-02
+Title : One-line summary (e.g. "Chat produces no response, DLL error in console")
+Severity : Blocker / High / Medium / Low (see rubric below)
+Reproducible : Always / Sometimes (X of Y tries) / Once
+Environment :
+
+Steps to reproduce:
+ 1.
+ 2.
+ 3.
+
+Expected result :
+Actual result :
+
+Console / logs :
+Screenshot/video:
+Notes : anything else (e.g. "worked after restarting app")
+```
+
+### Severity rubric
+| Severity | Use when… |
+|---|---|
+| **Blocker** | The app won't install/launch, or a whole feature is completely unusable and has no workaround. |
+| **High** | A core feature fails or gives wrong results, but other features work. |
+| **Medium** | Feature works but is broken in a noticeable way (bad layout, slow, confusing error, minor data issue). |
+| **Low** | Cosmetic: typo, misalignment, wrong icon, polish. |
+
+### Special things to flag loudly (Windows-specific red flags)
+If you see any of these, note it prominently — they're the failures we most expect:
+- A popup or console error mentioning **`.dll`**, **"VCRUNTIME"**, **"MSVCP"**, **"was not
+ found"**, or **"is not a valid Win32 application"** → a bundled AI binary failed to load.
+- **SmartScreen / "Windows protected your PC"** warning during install → expected (build is
+ unsigned) — just note it happened; click *More info → Run anyway* to continue.
+- A feature spins forever / never responds → capture the console and say which feature.
+- After closing the app, a leftover **`llama-server.exe`** in Task Manager (see TC-STAB-02).
+
+---
+
+## 4. Test suites
+
+Run in this order — later tests depend on earlier ones. **P0 = critical path**, do these
+first; if a P0 fails, note it and continue where possible.
+
+Legend for the **Result** you record: ✅ Pass · ❌ Fail · ⛔ Blocked (couldn't run because
+something earlier failed) · ⏭️ Skipped.
+
+---
+
+### Suite A — Install & first launch `P0`
+
+**What it proves:** the installer works and the app opens on Windows.
+
+**TC-INSTALL-01 — Install the app**
+1. Double-click the `-setup.exe` installer.
+2. If **"Windows protected your PC" (SmartScreen)** appears → click *More info* → *Run
+ anyway*. (Note in your report that it appeared — expected for now.)
+3. Complete the installer.
+- **Expected:** Installs without error; a desktop shortcut named **Off Grid AI** is created.
+
+**TC-INSTALL-02 — First launch & onboarding**
+1. Launch the app (ideally from PowerShell per section 2 so you capture logs).
+2. Observe the first-run **onboarding** screen(s); click the **Continue / Next** button
+ through to the end.
+- **Expected:** A welcome/onboarding screen appears, then you land on the main app on the
+ **Models** screen. No crash, no blank white window.
+
+**TC-INSTALL-03 — Window & navigation**
+1. Click each sidebar item that is in scope: **Projects, Chat, Integrations, Models,
+ Gateway, Settings.**
+- **Expected:** Each opens its screen without crashing. (Locked/Pro tabs showing an upgrade
+ screen are fine — skip them.)
+
+---
+
+### Suite B — Models (download a model) `P0`
+
+**What it proves:** the app can download an AI model — nothing else works without one.
+
+**TC-MODEL-01 — Browse the catalog**
+1. Sidebar → **Models**.
+2. Look at the recommended models list (grouped by size, e.g. "Fits in …").
+- **Expected:** A list of models renders. No blank screen or error.
+
+**TC-MODEL-02 — Download a small text model**
+1. In **Models**, pick a **small** recommended chat/text model (smallest available, so the
+ download is quick).
+2. Click its **Download** button. Watch the progress bar.
+- **Expected:** Download progresses to 100% and the model shows as installed/active. A
+ **Cancel** control is available during download.
+- **Watch for:** stuck at 0%, network error, or the file downloads but never becomes
+ "ready."
+
+**TC-MODEL-03 — Hugging Face search (uses internet)**
+1. In **Models**, use the search to look up a model by name (e.g. type "qwen").
+- **Expected:** Search returns results you could download.
+
+---
+
+### Suite C — Chat `P0`
+
+**What it proves:** the core AI text engine (`llama-server.exe`) runs on Windows. This is
+the single most important suite.
+
+**TC-CHAT-01 — Send a text message**
+1. Sidebar → **Chat**. Start a new chat.
+2. Type `Hello, who are you?` and send.
+- **Expected:** The AI replies with text that **streams in word-by-word**. Reply is coherent.
+- **Watch for (Windows red flag):** no reply at all + a `.dll` / "llama-server" error in the
+ PowerShell console = the AI binary failed to load. **Report as Blocker.**
+
+**TC-CHAT-02 — Multi-turn conversation**
+1. After the reply, send a follow-up like `Summarize that in one sentence.`
+- **Expected:** The AI responds in context (remembers the previous message).
+
+**TC-CHAT-03 — Reasoning / "thinking" mode**
+1. If there's a **reasoning / thinking** toggle for the chat, enable it and ask a
+ reasoning question (e.g. `If a train travels 60km in 45 minutes, what is its speed?`).
+- **Expected:** You may see a separate "thinking" section, then a final answer. Answer is
+ correct (80 km/h).
+
+**TC-CHAT-04 — Vision (attach an image)** — *requires a vision-capable model*
+1. Download a **vision** model from Models if prompted (one that supports images).
+2. In a chat, attach an image file (e.g. a photo or screenshot) and ask
+ `What's in this image?`.
+- **Expected:** The AI describes the image contents.
+- **Watch for:** attach button does nothing, or the model errors on the image.
+
+**TC-CHAT-05 — Per-chat settings**
+1. Open the chat's settings (temperature, context window) and change the context window.
+- **Expected:** Setting saves; chat continues to work afterward (the model restarts quietly).
+
+---
+
+### Suite D — Gateway (local API) `P0`
+
+**What it proves:** the local OpenAI-compatible web API works — a key selling point.
+
+**TC-GW-01 — Gateway screen**
+1. Sidebar → **Gateway**.
+- **Expected:** Shows a **Base URL** (e.g. `http://127.0.0.1:7878/v1`) and a list of
+ **Endpoints**.
+
+**TC-GW-02 — Call the API from PowerShell**
+1. With a chat model downloaded and the app open, run in PowerShell:
+ ```powershell
+ curl.exe http://127.0.0.1:7878/v1/chat/completions -H "Content-Type: application/json" -d '{"model":"local","messages":[{"role":"user","content":"Hello!"}]}'
+ ```
+- **Expected:** A JSON response containing the AI's reply text.
+- **Watch for:** "connection refused" (server not listening) or an empty/error JSON.
+
+**TC-GW-03 — Models endpoint**
+1. Run: `curl.exe http://127.0.0.1:7878/v1/models`
+- **Expected:** JSON listing your installed model(s).
+
+---
+
+### Suite E — Image generation
+
+**What it proves:** `sd-cli.exe` (the image engine) runs on Windows. **This is a known
+Windows risk area** — test carefully and capture the console.
+
+**TC-IMG-01 — Download an image model**
+1. Sidebar → **Models**. Find an **image generation** model (e.g. an SDXL/Z-Image entry)
+ and download it.
+- **Expected:** Downloads and shows as installed.
+
+**TC-IMG-02 — Generate an image**
+1. Open the image-generation UI, enter a prompt like `a red bicycle on a beach, sunset`.
+2. Start generation.
+- **Expected:** You see a **live step-by-step preview** as the image forms, a progress/ETA,
+ and a final PNG. The image roughly matches the prompt.
+- **Watch for (Windows red flag):** generation fails immediately with a **`.dll` error** in
+ the console (the SD engine couldn't load its libraries). **Report with the exact console
+ text — this is a specific thing we're checking.**
+
+**TC-IMG-03 — Image-to-image** *(if the UI offers it)*
+1. Provide a starting image + a prompt and generate.
+- **Expected:** Output is a variation based on the input image.
+
+---
+
+### Suite F — Voice
+
+**What it proves:** speech-to-text (`whisper-cli.exe` + `ffmpeg.exe`) and text-to-speech
+(Kokoro) work on Windows.
+
+**TC-VOICE-01 — Text-to-speech (speak)**
+1. Find the **speak / play audio** control on an AI message (or the voice settings), and
+ trigger it. Download the voice model if prompted.
+- **Expected:** You **hear** the text spoken aloud through your speakers.
+- **Watch for:** no audio, or a console error about the TTS worker.
+
+**TC-VOICE-02 — Speech-to-text (transcribe)**
+1. Use the **microphone / voice input** to dictate a message (say a sentence).
+- **Expected:** Your speech is transcribed into text in the message box.
+- **Watch for:** Windows may ask for **microphone permission** — allow it. No transcript, or
+ an `ffmpeg`/`whisper` error in console = fail.
+
+**TC-VOICE-03 — Hands-free voice mode** *(if present)*
+1. Enter the hands-free voice mode and have a short spoken back-and-forth.
+- **Expected:** You speak → it transcribes → AI replies → reply is spoken aloud.
+
+---
+
+### Suite G — Projects (chat over your documents / RAG)
+
+**What it proves:** document upload + "answer using my files" works.
+
+**TC-PROJ-01 — Create a project**
+1. Sidebar → **Projects** → create one (name it, e.g. "Test Project").
+- **Expected:** Project is created and opens.
+
+**TC-PROJ-02 — Upload a document & ask about it**
+1. In the project's **Knowledge base**, upload a **PDF or .txt/.docx** file that contains
+ some specific fact (e.g. a document that says "The budget is $5,000").
+2. Wait for it to finish processing.
+3. Start a chat in that project and ask a question only answerable from the doc
+ (e.g. `What is the budget?`).
+- **Expected:** The AI answers using the document ($5,000) and shows a **cited source**.
+- **Watch for:** upload fails, processing hangs, or the AI ignores the document.
+
+**TC-PROJ-03 — Per-project instructions**
+1. Set a project instruction (e.g. "Always answer in French").
+2. Ask a question in that project.
+- **Expected:** The AI follows the instruction.
+
+**TC-PROJ-04 — Audio/image document** *(depends on voice/vision models)*
+1. Upload an **image** (with visible text or clear objects) and/or a short **audio** file.
+2. Ask about its contents.
+- **Expected:** Image is described / audio is transcribed and usable in answers.
+
+---
+
+### Suite H — Artifacts / canvas
+
+**What it proves:** the AI can render live mini-webpages/diagrams (pure UI, should be
+reliable on Windows).
+
+**TC-ART-01 — Render an artifact**
+1. In Chat, ask: `Make a simple HTML page with a button that shows an alert when clicked.`
+- **Expected:** A rendered **Preview** appears in a canvas, with a **Code / Preview** toggle
+ and a **Download** option. Clicking the button in the preview shows the alert.
+
+**TC-ART-02 — Mermaid diagram**
+1. Ask: `Draw a flowchart of making tea, as a mermaid diagram.`
+- **Expected:** A diagram renders in the canvas.
+
+---
+
+### Suite I — Integrations (MCP connectors)
+
+**What it proves:** external tool servers can be added and used. **stdio connectors that
+launch `npx` are a Windows-sensitive area** — test TC-INT-02.
+
+**TC-INT-01 — Add an HTTP connector**
+1. Sidebar → **Integrations** → add a connector using the **URL** option
+ (`https://mcp.example.com/endpoint` field). Use any test MCP endpoint you were given, or
+ just verify the *form* opens and accepts input if you have no endpoint.
+- **Expected:** Connector saves; **Connect** attempts a connection.
+
+**TC-INT-02 — Add a stdio connector (`npx`)** — *Windows red flag area*
+1. Add a connector using the **command** option: command = `npx`, args = an MCP server
+ package you were given (or a known one).
+2. Enable / connect it.
+- **Expected:** The connector shows as **Connected** (the app launched `npx` under the
+ hood).
+- **Watch for:** "command not found" / spawn errors in the console — capture them exactly.
+
+**TC-INT-03 — Use a connector in chat**
+1. With a connector connected, ask the AI something that would use its tool.
+- **Expected:** The AI calls the tool and uses the result in its answer.
+
+---
+
+### Suite J — Tools in chat
+
+**TC-TOOL-01 — Calculator / datetime**
+1. Ask: `What is 1234 * 5678?` and `What is today's date and time?`
+- **Expected:** Correct math (7,006,652) and a correct current date/time.
+
+**TC-TOOL-02 — Web search** *(uses internet)*
+1. Ask something requiring fresh info, e.g. `Search the web for the latest news about NASA.`
+- **Expected:** The AI performs a search and summarizes results.
+
+---
+
+### Suite K — Settings, persistence & theming
+
+**TC-SET-01 — Theme toggle**
+1. Sidebar → **Settings**. Toggle between light and dark theme.
+- **Expected:** The whole app switches theme cleanly (no unreadable text, no broken layout —
+ check the starry background is visible in both).
+
+**TC-SET-02 — Data persists across restart** *(also tests encryption-at-rest)*
+1. Have at least one chat with history.
+2. **Fully quit** the app, then reopen it.
+- **Expected:** Your previous chats/projects are **still there**. Downloaded models are still
+ installed.
+- **Watch for:** database errors on startup in the console, or everything wiped.
+
+---
+
+### Suite L — Stability & cleanup
+
+**TC-STAB-01 — Long session**
+1. Use chat + image gen + voice over ~15–20 minutes.
+- **Expected:** No crash, no runaway memory. Note if the app becomes sluggish.
+
+**TC-STAB-02 — No orphaned processes after quit** — *Windows-specific check*
+1. Quit the app fully.
+2. Open **Task Manager → Details** and search for **`llama-server.exe`** (and
+ `sd-cli.exe`, `whisper-cli.exe`).
+- **Expected:** **None** of these are still running after the app is closed.
+- **Watch for:** a leftover `llama-server.exe` — report it (it would block the next launch).
+
+**TC-STAB-03 — Relaunch after quit**
+1. Reopen the app and send a chat message.
+- **Expected:** Works first try (no "port in use" / model won't load error from a leftover
+ process).
+
+---
+
+### Suite M — Auto-update *(likely N/A right now — confirm and note)*
+
+**TC-UPD-01 — Update check**
+1. Watch the console at startup for `[update]` lines.
+- **Expected for this branch:** it's fine if updates **don't** work yet — the Windows update
+ feed isn't published. **Just record what you see** (e.g. `[update] check failed` or
+ nothing). Don't file this as a bug unless the app *crashes* over it.
+
+---
+
+## 5. Quick summary sheet (fill and return)
+
+| Suite | Result (✅/❌/⛔/⏭️) | Bug IDs filed |
+|---|---|---|
+| A — Install & launch | | |
+| B — Models | | |
+| C — Chat | | |
+| D — Gateway | | |
+| E — Image generation | | |
+| F — Voice | | |
+| G — Projects / RAG | | |
+| H — Artifacts | | |
+| I — Integrations (MCP) | | |
+| J — Tools in chat | | |
+| K — Settings & persistence | | |
+| L — Stability & cleanup | | |
+| M — Auto-update | | |
+
+**Overall verdict:** Core app is ☐ usable / ☐ usable with issues / ☐ blocked on Windows.
+
+> Priority order if you're short on time: **A → B → C → D** (install, model, chat, gateway)
+> are the critical path. If those pass, the app fundamentally works on Windows; the rest
+> tells us how complete it is.
diff --git a/electron-builder.yml b/electron-builder.yml
index 7c0b5e49..fbebeb0d 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -29,6 +29,10 @@ extraResources:
- "!models/**"
win:
executableName: off-grid-ai
+ # Without this, electron-builder ships the default Electron icon on Windows (the
+ # mac block sets its own icon below). The 512x512 PNG is auto-converted to a
+ # multi-size Windows ICO for the exe + NSIS installer.
+ icon: resources/icon.png
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1
index 8c52b424..9feaed80 100644
--- a/scripts/fetch-win-binaries.ps1
+++ b/scripts/fetch-win-binaries.ps1
@@ -1,51 +1,172 @@
# Fetch the Windows (x64) native runner binaries into resources/bin for the
-# Windows package. The repo ships macOS binaries; this populates the win64
-# equivalents from upstream official releases at CI time.
+# Windows package. The repo ships macOS binaries (Git LFS); this populates the
+# win64 equivalents from upstream official releases at build time — laid out to
+# match exactly what the app's resolvers expect:
#
-# NOTE: these upstream asset names/versions move. If a download 404s, bump the
-# pinned version below to a current release. After the first successful CI run,
-# verify the app spawns each binary correctly on Windows (paths/.exe handling).
+# resources/bin/llama/llama-server.exe (+ ggml/llama DLLs) <- src/main/llm.ts
+# resources/bin/sd/sd-cli.exe (+ DLLs) <- src/main/imagegen.ts
+# resources/bin/whisper/whisper-cli.exe (+ DLLs) <- src/main/rag/extractors.ts
+# resources/bin/ffmpeg.exe <- src/main/rag/extractors.ts
+#
+# On Windows the DLL loader searches the directory of the .exe first, so each
+# runtime's DLLs MUST sit next to its .exe (hence the per-runtime subdirs).
+#
+# Most runtimes are resolved DYNAMICALLY from each project's latest GitHub
+# release so the script does not go stale. llama.cpp is the EXCEPTION: it is
+# pinned to the same ref the macOS engine is built from (scripts/build-llama.sh,
+# LLAMA_REF=b9838) so grammar / native tool-call handling is byte-for-byte
+# identical across platforms. 'latest' floats, and upstream builds have shipped
+# that reject the tool-call GBNF the app generates from MCP tool schemas.
+# Set OFFGRID_GH_TOKEN (or GITHUB_TOKEN) to avoid the unauthenticated API rate
+# limit (CI sets GITHUB_TOKEN automatically).
+
$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue' # makes Invoke-WebRequest downloads fast
+
$bin = Join-Path $PSScriptRoot '..\resources\bin'
New-Item -ItemType Directory -Force -Path $bin | Out-Null
-$tmp = Join-Path $env:RUNNER_TEMP 'ogbin'
+# Canonicalize: collapses the 'scripts\..\' segment to a real absolute path. The
+# uncollapsed form is longer than the copied files' paths, which made the final
+# Substring-based listing throw and (with ErrorActionPreference=Stop) fail the
+# whole script AFTER the binaries had already copied.
+$bin = [System.IO.Path]::GetFullPath($bin)
+$tmpBase = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
+$tmp = Join-Path $tmpBase 'ogbin'
+if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
-function Get-Zip($url, $dest) {
- Write-Host "↓ $url"
+$ghHeaders = @{ 'User-Agent' = 'offgrid-fetch-win' }
+$token = if ($env:OFFGRID_GH_TOKEN) { $env:OFFGRID_GH_TOKEN } elseif ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } else { $null }
+if ($token) { $ghHeaders['Authorization'] = "Bearer $token" }
+
+# Find the download URL of a release asset whose name matches $pattern. With no
+# $tag it uses the project's LATEST release; with $tag it pins to that exact
+# release (e.g. llama.cpp b9838, to match the macOS source build).
+function Get-AssetUrl($repo, $pattern, $tag) {
+ $uri = if ($tag) { "https://api.github.com/repos/$repo/releases/tags/$tag" }
+ else { "https://api.github.com/repos/$repo/releases/latest" }
+ $rel = Invoke-RestMethod -Headers $ghHeaders -Uri $uri
+ $asset = $rel.assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1
+ if (-not $asset) { throw "no asset matching /$pattern/ in $repo @ $($rel.tag_name)" }
+ Write-Host " $repo @ $($rel.tag_name) -> $($asset.name)"
+ return $asset.browser_download_url
+}
+
+# Download + extract a zip asset, return the extraction dir. Optional $tag pins
+# to a specific release instead of latest.
+function Expand-Asset($repo, $pattern, $tag) {
+ $url = Get-AssetUrl $repo $pattern $tag
$zip = Join-Path $tmp ([System.IO.Path]::GetRandomFileName() + '.zip')
- Invoke-WebRequest -Uri $url -OutFile $zip
+ Write-Host " downloading $url"
+ Invoke-WebRequest -Headers $ghHeaders -Uri $url -OutFile $zip
$out = Join-Path $tmp ([System.IO.Path]::GetFileNameWithoutExtension($zip))
Expand-Archive -Path $zip -DestinationPath $out -Force
return $out
}
-# --- llama.cpp (server + CLIs + ggml dlls), CPU x64 baseline -----------------
-$LLAMA_BUILD = 'b4585' # TODO: bump to a current llama.cpp release tag
+# Copy every .exe/.dll found anywhere under $srcDir into $destSubdir (flattened).
+function Copy-Runtime($srcDir, $destName) {
+ $dest = Join-Path $bin $destName
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
+ Get-ChildItem -Path $srcDir -Recurse -Include *.exe, *.dll |
+ Copy-Item -Destination $dest -Force
+ return $dest
+}
+
+# --- llama.cpp (server + CLIs + ggml DLLs) -----------------------------------
+# PINNED to match the macOS engine (scripts/build-llama.sh). Overridable via env
+# for a coordinated cross-platform bump — keep it in lockstep with build-llama.sh.
+#
+# We ship TWO builds so Windows gets GPU speed without breaking GPU-less boxes:
+# bin/llama <- Vulkan (GPU) build, the app's PRIMARY. Offloads to any
+# Vulkan device (Intel/AMD/NVIDIA, incl. iGPUs like Radeon
+# 740M) and still runs on CPU when no device is present.
+# Needs the system Vulkan loader (vulkan-1.dll, present with
+# any modern GPU driver).
+# bin/llama-cpu <- CPU-only build, the app's FALLBACK (llm.ts) for the rare
+# box with no Vulkan loader at all, where the Vulkan .exe
+# can't even load.
+$LlamaRef = if ($env:LLAMA_REF) { $env:LLAMA_REF } else { 'b9838' }
+Write-Host "== llama.cpp (pinned $LlamaRef): vulkan primary + cpu fallback =="
try {
- $x = Get-Zip "https://github.com/ggml-org/llama.cpp/releases/download/$LLAMA_BUILD/llama-$LLAMA_BUILD-bin-win-cpu-x64.zip" $tmp
- Get-ChildItem -Path $x -Recurse -Include *.exe,*.dll | Copy-Item -Destination $bin -Force
-} catch { Write-Warning "llama.cpp fetch failed: $_" }
+ $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-vulkan-x64\.zip$' $LlamaRef
+ Copy-Runtime $x 'llama' | Out-Null
+} catch { Write-Warning "llama.cpp (vulkan) fetch failed: $_" }
+try {
+ $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-cpu-x64\.zip$' $LlamaRef
+ Copy-Runtime $x 'llama-cpu' | Out-Null
+} catch { Write-Warning "llama.cpp (cpu fallback) fetch failed: $_" }
-# --- whisper.cpp -------------------------------------------------------------
-$WHISPER = 'v1.7.4' # TODO: confirm current whisper.cpp release
+# --- whisper.cpp (whisper-cli.exe + DLLs) ------------------------------------
+Write-Host '== whisper.cpp =='
try {
- $x = Get-Zip "https://github.com/ggml-org/whisper.cpp/releases/download/$WHISPER/whisper-bin-x64.zip" $tmp
- Get-ChildItem -Path $x -Recurse -Include *.exe,*.dll | Copy-Item -Destination $bin -Force
+ $x = Expand-Asset 'ggml-org/whisper.cpp' '^whisper-bin-x64\.zip$'
+ $dest = Copy-Runtime $x 'whisper'
+ # Older releases ship the CLI as main.exe; the app expects whisper-cli.exe.
+ $wc = Join-Path $dest 'whisper-cli.exe'
+ $mn = Join-Path $dest 'main.exe'
+ if (-not (Test-Path $wc) -and (Test-Path $mn)) { Copy-Item $mn $wc -Force }
} catch { Write-Warning "whisper.cpp fetch failed: $_" }
-# --- ffmpeg (GPL, win64) -----------------------------------------------------
+# --- stable-diffusion.cpp (image gen), cpu x64 -------------------------------
+# CPU build ON PURPOSE, not the Vulkan/CUDA ones: the DEFAULT image path is the
+# one-shot `sd-cli` (imagegen.ts; resident sd-server is opt-in). That path spawns
+# once and reports a single exit code, so it CANNOT tell "GPU binary won't load
+# on this box" apart from "generation failed" — there's no launch-failure ladder
+# like llm.ts has (which detects load via HTTP readiness). So the one binary we
+# ship here MUST load unconditionally; only the CPU build does (the Vulkan build
+# hard-requires a Vulkan loader and would just trade "not found" for "won't load"
+# on GPU-less boxes). A Vulkan-primary + CPU-fallback ladder (mirroring the llama
+# bin/llama + bin/llama-cpu setup) is the future speed upgrade; it needs the
+# resident-server readiness seam to detect load failure first.
+#
+# NOTE: upstream renamed this asset — it was `bin-win-avx2-x64.zip`, now gone. The
+# fetch is optional (verify below only WARNS), so a stale pattern here fails
+# SILENTLY at build time and ships a Windows package with no image binary, which
+# surfaces as "Image generation binary (sd-cli) not found" at runtime. This is
+# dynamic 'latest' matching, so re-check the asset name on any upstream bump.
+Write-Host '== stable-diffusion.cpp (cpu x64) =='
try {
- $x = Get-Zip 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip' $tmp
- Get-ChildItem -Path $x -Recurse -Filter 'ffmpeg.exe' | Select-Object -First 1 | Copy-Item -Destination $bin -Force
-} catch { Write-Warning "ffmpeg fetch failed: $_" }
+ $x = Expand-Asset 'leejet/stable-diffusion.cpp' 'bin-win-cpu-x64\.zip$'
+ $dest = Copy-Runtime $x 'sd'
+ # Upstream names the one-shot binary sd.exe; the app resolves sd/sd-cli(.exe).
+ $cli = Join-Path $dest 'sd-cli.exe'
+ $sd = Join-Path $dest 'sd.exe'
+ if (-not (Test-Path $cli) -and (Test-Path $sd)) { Copy-Item $sd $cli -Force }
+} catch { Write-Warning "stable-diffusion.cpp fetch failed: $_" }
-# --- stable-diffusion.cpp (image gen), avx2 x64 ------------------------------
-$SD = 'master-8847020' # TODO: confirm current stable-diffusion.cpp release
+# --- ffmpeg (GPL, win64) — single ffmpeg.exe flat in resources/bin -----------
+Write-Host '== ffmpeg =='
try {
- $x = Get-Zip "https://github.com/leejet/stable-diffusion.cpp/releases/download/$SD/sd-$SD-bin-win-avx2-x64.zip" $tmp
- Get-ChildItem -Path $x -Recurse -Include *.exe,*.dll | Copy-Item -Destination $bin -Force
-} catch { Write-Warning "stable-diffusion.cpp fetch failed: $_" }
+ $zip = Join-Path $tmp 'ffmpeg.zip'
+ Invoke-WebRequest -Headers @{ 'User-Agent' = 'offgrid-fetch-win' } `
+ -Uri 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip' `
+ -OutFile $zip
+ $out = Join-Path $tmp 'ffmpeg'
+ Expand-Archive -Path $zip -DestinationPath $out -Force
+ Get-ChildItem -Path $out -Recurse -Filter 'ffmpeg.exe' |
+ Select-Object -First 1 | Copy-Item -Destination (Join-Path $bin 'ffmpeg.exe') -Force
+} catch { Write-Warning "ffmpeg fetch failed: $_" }
+
+Write-Host ''
+Write-Host 'resources/bin now contains (win64):'
+Get-ChildItem -Path $bin -Recurse -Include *.exe |
+ ForEach-Object { Write-Host " $($_.FullName.Replace($bin, '').TrimStart('\'))" }
-Write-Host "resources/bin now contains:"
-Get-ChildItem -Path $bin | Select-Object Name | Format-Table -HideTableHeaders
+# Verify the result so a failed fetch fails LOUD here, not as a confusing
+# "binary not found" at app startup. llama-server is REQUIRED (no chat without
+# it); whisper/sd/ffmpeg are optional (voice/image degrade gracefully if absent).
+$llama = Join-Path $bin 'llama\llama-server.exe'
+if (-not (Test-Path -LiteralPath $llama)) {
+ Write-Error "REQUIRED binary missing: $llama (the llama.cpp fetch failed above). Cannot run the model server."
+ exit 1
+}
+foreach ($p in @(
+ (Join-Path $bin 'llama-cpu\llama-server.exe'),
+ (Join-Path $bin 'whisper\whisper-cli.exe'),
+ (Join-Path $bin 'sd\sd-cli.exe'),
+ (Join-Path $bin 'ffmpeg.exe'))) {
+ if (-not (Test-Path -LiteralPath $p)) { Write-Warning "optional runtime missing (feature will be unavailable): $p" }
+}
+Write-Host ''
+Write-Host "OK: llama-server.exe present at $llama"
diff --git a/src/main/__tests__/embeddings-cachedir.test.ts b/src/main/__tests__/embeddings-cachedir.test.ts
new file mode 100644
index 00000000..ceeb476c
--- /dev/null
+++ b/src/main/__tests__/embeddings-cachedir.test.ts
@@ -0,0 +1,41 @@
+/**
+ * Regression guard for the "embeddings model timeout" bug (Windows, fresh install).
+ *
+ * transformers.js defaults `env.cacheDir` to `/.cache`, which in
+ * a packaged app resolves INSIDE the read-only app.asar. FileCache.put() then fails
+ * every write with ENOTDIR (asar is a file, not a dir) and swallows it — so the
+ * ~23MB MiniLM download is NEVER persisted and every embedding re-downloads it from
+ * HuggingFace. On a slow link that repeated full download surfaces as a "timeout".
+ * (Confirmed on a Windows box: download + onnxruntime + network all fine; 8 ENOTDIR
+ * warnings from FileCache.put per operation; nothing cached to disk.)
+ *
+ * The fix pins `env.cacheDir` to a writable dir under the userData models dir. This
+ * test fails if that assignment regresses back to the library default. It also guards
+ * the cross-platform contract: the same asar is read-only on macOS, so the cache dir
+ * must be the writable userData path on BOTH platforms, never inside the package.
+ */
+import { describe, it, expect } from 'vitest';
+import path from 'path';
+import os from 'os';
+
+describe('embeddings on-disk cache is a writable dir (not inside app.asar / the package)', () => {
+ it('points transformers cacheDir at the userData models dir', async () => {
+ // runtime-env resolves the data dir from OFFGRID_DATA_DIR; set it BEFORE the
+ // module import, since embeddings.ts reads modelsDir() at load time.
+ const dataDir = path.join(os.tmpdir(), 'offgrid-embed-cachedir-test');
+ process.env.OFFGRID_DATA_DIR = dataDir;
+
+ const { env } = await import('@xenova/transformers');
+ await import('../embeddings'); // sets env.localModelPath / cacheDir / allowRemoteModels on load
+
+ const modelsDir = path.join(dataDir, 'models');
+ expect(env.cacheDir).toBe(path.join(modelsDir, '.cache'));
+ // The download target and the local-model lookup must share the writable dir.
+ expect(env.localModelPath).toBe(modelsDir);
+ // Must NOT be the library default, which lives inside the (read-only-when-packaged)
+ // @xenova/transformers package folder.
+ expect(env.cacheDir).not.toMatch(/@xenova[\\/]transformers/);
+ // Still allow the first-run download (offline bundling is a separate decision).
+ expect(env.allowRemoteModels).toBe(true);
+ });
+});
diff --git a/src/main/__tests__/fetch-win-binaries.test.ts b/src/main/__tests__/fetch-win-binaries.test.ts
new file mode 100644
index 00000000..b4fe9117
--- /dev/null
+++ b/src/main/__tests__/fetch-win-binaries.test.ts
@@ -0,0 +1,52 @@
+/**
+ * Regression guard for the Windows image-gen "binary not found" bug.
+ *
+ * scripts/fetch-win-binaries.ps1 populates resources/bin/sd/sd-cli.exe from an
+ * upstream stable-diffusion.cpp release asset, matched by NAME. The sd fetch is
+ * OPTIONAL (the script's verify block only WARNs when sd-cli.exe is missing), so
+ * a stale asset pattern fails SILENTLY at build time: `Get-AssetUrl` throws "no
+ * asset matching", the try/catch swallows it into a warning, and the Windows
+ * package ships with NO image binary — surfacing only at runtime as
+ * "Image generation binary (sd-cli) not found in resources/bin/sd."
+ * (src/main/imagegen.ts:findSdCli -> throw).
+ *
+ * That is exactly what happened: upstream retired `bin-win-avx2-x64.zip`. This
+ * test fails if the script ever references that dead asset again, and asserts it
+ * matches a currently-published Windows asset instead.
+ */
+import { describe, it, expect } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+const SCRIPT = path.resolve(process.cwd(), 'scripts/fetch-win-binaries.ps1');
+const SRC = fs.existsSync(SCRIPT) ? fs.readFileSync(SCRIPT, 'utf-8') : '';
+
+describe.skipIf(!SRC)('fetch-win-binaries.ps1 — stable-diffusion.cpp asset', () => {
+ it('does not FETCH the retired avx2 Windows asset', () => {
+ // Upstream no longer publishes this name; matching it fetches nothing. The
+ // name may still appear in a comment (documenting the breakage) — what must
+ // never come back is an active Expand-Asset call against it.
+ const activeAvx2 = SRC.split(/\r?\n/).some(
+ (line) => /Expand-Asset/.test(line) && /avx2/.test(line) && !/^\s*#/.test(line),
+ );
+ expect(activeAvx2).toBe(false);
+ });
+
+ it('matches a currently-published Windows sd asset (cpu x64)', () => {
+ // CPU build is deliberate: the default one-shot sd-cli path has no launch-
+ // failure fallback, so the bundled binary must load without a GPU/Vulkan
+ // loader. See the script comment.
+ expect(SRC).toMatch(/leejet\/stable-diffusion\.cpp'\s+'bin-win-cpu-x64\\\.zip\$'/);
+ });
+
+ it('still renames upstream sd.exe to the sd-cli.exe the app resolves', () => {
+ // imagegen.ts / sd-server.ts resolve resources/bin/sd/sd-cli(.exe); upstream
+ // ships it as sd.exe, so the rename must stay or the resolver misses it.
+ expect(SRC).toMatch(/sd-cli\.exe/);
+ expect(SRC).toMatch(/Copy-Item \$sd \$cli -Force/);
+ });
+
+ it('keeps sd-cli.exe in the post-fetch verify (present, even if only a warning)', () => {
+ expect(SRC).toMatch(/'sd\\sd-cli\.exe'/);
+ });
+});
diff --git a/src/main/__tests__/llama-error.test.ts b/src/main/__tests__/llama-error.test.ts
index fb83173b..7bf88558 100644
--- a/src/main/__tests__/llama-error.test.ts
+++ b/src/main/__tests__/llama-error.test.ts
@@ -31,6 +31,13 @@ main: exiting due to model loading error`;
expect(classifyLlamaError('ggml_metal_buffer: failed to allocate buffer, size = 9216.00 MiB')?.code).toBe('out_of_memory');
});
+ it('names the machine per platform in the OOM reason (Mac on macOS, device elsewhere)', () => {
+ const oom = 'ggml_metal_buffer: failed to allocate buffer, size = 9216.00 MiB';
+ expect(classifyLlamaError(oom, 'darwin')?.reason).toContain('too large for this Mac');
+ expect(classifyLlamaError(oom, 'win32')?.reason).toContain('too large for this device');
+ expect(classifyLlamaError(oom, 'linux')?.reason).toContain('too large for this device');
+ });
+
it('flags a missing dylib', () => {
expect(classifyLlamaError('dyld: Library not loaded: @rpath/libomp.dylib')?.code).toBe('missing_library');
});
diff --git a/src/main/__tests__/tools-stream.test.ts b/src/main/__tests__/tools-stream.test.ts
index 80b6149c..3281d9f1 100644
--- a/src/main/__tests__/tools-stream.test.ts
+++ b/src/main/__tests__/tools-stream.test.ts
@@ -9,7 +9,9 @@ const { streamChatMock, initMock } = vi.hoisted(() => ({
streamChatMock: vi.fn(),
initMock: vi.fn().mockResolvedValue(undefined),
}));
-vi.mock('../llm', () => ({ llm: { init: initMock, streamChat: streamChatMock } }));
+// effectiveContextSize() is called by toolChat to budget tool schemas to the window.
+// Return a roomy fixed size so budgeting takes its normal (no-prune) path under test.
+vi.mock('../llm', () => ({ llm: { init: initMock, streamChat: streamChatMock, effectiveContextSize: () => 8192 } }));
const { getSettingMock, saveSettingMock } = vi.hoisted(() => ({ getSettingMock: vi.fn(() => [] as string[]), saveSettingMock: vi.fn() }));
vi.mock('../database', () => ({ getSetting: getSettingMock, saveSetting: saveSettingMock }));
diff --git a/src/main/database.ts b/src/main/database.ts
index 487f799c..47664df6 100644
--- a/src/main/database.ts
+++ b/src/main/database.ts
@@ -86,8 +86,12 @@ export function getDB(): Database.Database {
}
db.pragma('journal_mode = WAL');
- // Register custom function for vector search
- db.function('cosine_similarity', (...args: unknown[]) => cosineSimilarity(args[0] as string, args[1] as string));
+ // Register custom function for vector search. Declare the two params EXPLICITLY:
+ // better-sqlite3 derives the SQL arity from fn.length, and a rest param
+ // (...args) has length 0 — so it registered as a 0-arg function and every
+ // 2-arg call ("SELECT cosine_similarity(embedding, ?)") threw "wrong number of
+ // arguments to function cosine_similarity()".
+ db.function('cosine_similarity', (a: unknown, b: unknown) => cosineSimilarity(a as string, b as string));
// Initialize Schema
db.exec(`
diff --git a/src/main/embeddings.ts b/src/main/embeddings.ts
index d6d0f90b..d2afe5e5 100644
--- a/src/main/embeddings.ts
+++ b/src/main/embeddings.ts
@@ -1,9 +1,21 @@
+import path from 'path';
import { pipeline, env } from '@xenova/transformers';
import { modelsDir } from './runtime-env';
-// Configure transformers to look for models locally or cache them properly
+// Configure transformers to look for models locally or cache them properly.
env.localModelPath = modelsDir();
env.allowRemoteModels = true; // Allow download on first run
+// Pin the on-disk HTTP cache to a WRITABLE dir. transformers.js defaults cacheDir
+// to `/.cache` — which, in a packaged app, resolves INSIDE
+// the read-only app.asar. FileCache.put() then fails every write with ENOTDIR
+// (asar is a single file, not a directory), catches it non-fatally, and falls back
+// to the in-memory buffer. Net effect: the ~23MB MiniLM download NEVER persists, so
+// every embedding re-downloads it from HuggingFace — which times out on a slow link
+// and reads as "embeddings model timeout" on a fresh install. Point the cache at the
+// same writable userData/models dir we already use for localModelPath so the model
+// is downloaded once and read from disk thereafter. Cross-platform: the same asar is
+// read-only on macOS too (the signed .app), so this fixes both, not just Windows.
+env.cacheDir = path.join(modelsDir(), '.cache');
class EmbeddingService {
private pipe: any = null;
diff --git a/src/main/imagegen.ts b/src/main/imagegen.ts
index 7a8e26f1..c7b793d5 100644
--- a/src/main/imagegen.ts
+++ b/src/main/imagegen.ts
@@ -12,7 +12,7 @@ import { getResidencyMode } from './runtime-residency';
import type { ManagedRuntime } from './runtime-manager';
import { isMfluxModelId, mfluxAvailable, getMfluxModel, runMflux, cancelMflux, MFLUX_MODELS } from './mflux';
import { getActiveModal } from './active-models';
-import { binRoots, dataDir, modelsDir } from './runtime-env';
+import { binRoots, dataDir, modelsDir, exe } from './runtime-env';
import { sdServer } from './sd-server';
import { standardModelDefaults, taesdFilename } from '../shared/image-defaults';
import { defaultImageModelFilename } from './image-default';
@@ -24,7 +24,7 @@ import { initialProgressState, reduceProgress } from './imagegen/progress';
function findSdCli(): string | null {
for (const r of binRoots()) {
- const p = path.join(r, 'sd', 'sd-cli');
+ const p = path.join(r, 'sd', exe('sd-cli'));
if (fs.existsSync(p)) return p;
}
return null;
diff --git a/src/main/index.ts b/src/main/index.ts
index 2d004650..eabc62cc 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -84,7 +84,7 @@ function createWindow(): void {
show: false,
title: 'Off Grid AI',
autoHideMenuBar: true,
- ...(process.platform === 'linux' ? { icon } : {}),
+ ...(process.platform === 'linux' || process.platform === 'win32' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false, // REQUIRED for IPC
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 43fe4ae2..84319082 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -641,12 +641,6 @@ ipcMain.handle('db:search-memories', async (_, query: string) => {
}
});
- ipcMain.handle('llm:status', async () => {
- const { llm } = await import('./llm');
- return {
- ready: llm.isReady()
- };
- });
// Cancel an in-flight streaming turn; chatStream resolves with the partial answer.
ipcMain.on('rag:cancel', (_evt, streamId: string) => {
diff --git a/src/main/llama-error.ts b/src/main/llama-error.ts
index 8bbe8474..4bcd8fdf 100644
--- a/src/main/llama-error.ts
+++ b/src/main/llama-error.ts
@@ -4,6 +4,8 @@
// led to real users (and us) guessing at code-signing when the truth was in the
// stderr the whole time — e.g. "unknown model architecture: 'gemma4'".
+import { deviceNoun, type DevicePlatform } from '../shared/device';
+
export interface LlamaFailure {
/** Stable code for UI/branching. */
code: 'engine_outdated' | 'os_too_old' | 'out_of_memory' | 'missing_library' | 'model_corrupt' | 'unknown';
@@ -16,7 +18,10 @@ export interface LlamaFailure {
* text looks like a known fatal cause (so callers can fall back to a generic
* message). Order matters: most specific first.
*/
-export function classifyLlamaError(stderr: string): LlamaFailure | null {
+export function classifyLlamaError(
+ stderr: string,
+ platform: DevicePlatform = process.platform,
+): LlamaFailure | null {
const s = (stderr || '').toLowerCase();
if (!s.trim()) return null;
@@ -39,7 +44,7 @@ export function classifyLlamaError(stderr: string): LlamaFailure | null {
// Memory pressure on load (Metal/host alloc, OOM kill).
if (/failed to allocate|out of memory|insufficient memory|cannot allocate|ggml_metal.*alloc|unable to allocate|vk_error_out_of_device_memory|oom/.test(s)) {
- return { code: 'out_of_memory', reason: 'Out of memory - this model is too large for this Mac. Try a smaller model or Conservative mode.' };
+ return { code: 'out_of_memory', reason: `Out of memory - this model is too large for this ${deviceNoun(platform)}. Try a smaller model or Conservative mode.` };
}
// A required dylib is missing or unloadable.
diff --git a/src/main/llm.ts b/src/main/llm.ts
index 182a5b0d..82db67c9 100644
--- a/src/main/llm.ts
+++ b/src/main/llm.ts
@@ -5,7 +5,7 @@ import { callHook } from "./bootstrap/hookRegistry";
import path from "path";
import * as fs from "fs";
import * as http from "http";
-import { modelsDir as getModelsDir, binRoots, isPackaged, onHostQuit } from "./runtime-env";
+import { modelsDir as getModelsDir, binRoots, isPackaged, onHostQuit, exe } from "./runtime-env";
import { computeSafeCtx, modeBudget, type KvCacheType, type PerformanceMode } from "./model-sizing";
import { classifyLlamaError } from "./llama-error";
import type { ManagedRuntime } from "./runtime-manager";
@@ -14,7 +14,7 @@ import { DEFAULT_CTX_SIZE } from "../shared/llm-defaults";
import { MODE_PRESETS, samplingPayload, launchArgsChanged } from "./llm/settings-math";
import { buildMessages, imageMime, thinkingPayload, type DecodedImage } from "./llm/chat-payload";
import { parseSseLine, createThinkSplitter, createToolCallAccumulator, type AssembledToolCall } from "./llm/sse-stream";
-import { modelRequestOptions, postCompletionOnce } from "./llm/http-post";
+import { modelRequestOptions, postCompletionOnce, describeServerError } from "./llm/http-post";
export type { KvCacheType, PerformanceMode };
@@ -142,6 +142,12 @@ export class LLMService {
}
}
+ /** The EFFECTIVE (RAM-clamped) context window the server is actually running
+ * with — the real ceiling for prompt + tools + answer. */
+ effectiveContextSize(): number {
+ return this.safeCtxSize(this.ctxSize);
+ }
+
getSettings(): LlmSettings {
return {
temperature: this.temperature, ctxSize: this.ctxSize,
@@ -269,6 +275,22 @@ export class LLMService {
return getModelsDir();
}
+ /** The active chat/vision model's id (catalog id if known, else the weight
+ * filename) and whether it has a vision projector — so the gateway's
+ * /v1/models can list the text model from disk even when the server hasn't
+ * loaded it yet (otherwise an idle/headless gateway reports no chat model).
+ * Returns null when no model is downloaded. */
+ activeModelInfo(): { id: string; vision: boolean } | null {
+ this.resolveModel();
+ if (!fs.existsSync(this.modelPath)) return null;
+ let id = path.basename(this.modelPath);
+ try {
+ const cfg = JSON.parse(fs.readFileSync(this.activeModelFile, "utf-8"));
+ if (cfg?.id) id = cfg.id;
+ } catch { /* fall back to the filename */ }
+ return { id, vision: !!this.mmProjPath && fs.existsSync(this.mmProjPath) };
+ }
+
/** Cheap integrity check: a real GGUF starts with the "GGUF" magic and is more
* than a few bytes. Catches truncated/corrupt downloads before we hand the file
* to llama-server (which would otherwise crash on load). */
@@ -333,31 +355,24 @@ export class LLMService {
// newest model archs (gemma4/qwen35) AND runs on macOS 13+. The old dual-
// engine setup shipped a second, older binary as a "fallback" that silently
// couldn't load those models — removed.
+ // Engines to try, IN ORDER. On Windows we ship a Vulkan (GPU) build in
+ // bin/llama and a CPU-only fallback in bin/llama-cpu: if the Vulkan server
+ // can't start (e.g. no Vulkan loader on the box) we fall through to CPU. On
+ // macOS/Linux only bin/llama exists, so this is a single-entry list and the
+ // behaviour is unchanged.
const roots = binRoots();
- const candidates = roots.map((r) => path.join(r, "llama", "llama-server"));
- const serverPath = candidates.find((p) => fs.existsSync(p)) ?? "";
- if (!serverPath) {
- console.error(`[LLMService] llama-server binary not found. Looked in:\n${candidates.join("\n")}`);
+ const serverPaths = roots.flatMap((r) => [
+ path.join(r, "llama", exe("llama-server")),
+ path.join(r, "llama-cpu", exe("llama-server")),
+ path.join(r, exe("llama-server")),
+ ]).filter((p) => fs.existsSync(p));
+ if (!serverPaths.length) {
+ console.error(`[LLMService] llama-server binary not found under: ${roots.join(", ")}`);
return;
}
- console.log(`[LLMService] Starting llama-server from ${serverPath}`);
console.log(`[LLMService] Model: ${this.modelPath}`);
- // Strip macOS quarantine attributes on production builds (downloaded DMGs get quarantined)
- if (isPackaged() && process.platform === 'darwin') {
- try {
- const binDir = path.dirname(serverPath);
- execSync(`xattr -cr "${binDir}"`, { stdio: 'ignore' });
- execSync(`chmod +x "${serverPath}"`, { stdio: 'ignore' });
- console.log('[LLMService] Cleared quarantine attributes from bin directory');
- } catch (e) {
- console.warn('[LLMService] Could not clear quarantine attributes:', e);
- }
- }
-
- const binDir = path.dirname(serverPath);
-
const args = ["-m", this.modelPath];
if (this.mmProjPath) args.push("--mmproj", this.mmProjPath);
args.push(
@@ -383,34 +398,68 @@ export class LLMService {
}
// ALSO kill an ORPHANED server from a previous app process — when the app
// restarts, the old llama-server keeps holding the port, so a new spawn can't
- // bind and config changes (ctx size, model) silently never take effect. Find
- // whatever owns the port and kill it.
- try {
- const pids = execSync(`lsof -ti tcp:${this.port}`, { encoding: "utf-8" }).trim().split("\n").filter(Boolean);
- let killed = 0;
- for (const pid of pids) {
- // ONLY kill a process we recognize as our own llama-server. The port is
- // ours by convention, not by reservation — blindly SIGKILLing whatever
- // holds it would take down an unrelated app that happened to bind it.
- let cmd = "";
- try { cmd = execSync(`ps -p ${pid} -o command=`, { encoding: "utf-8" }).trim(); } catch { continue; /* already gone */ }
- if (!/llama-server/i.test(cmd)) {
- console.warn(`[LLMService] port ${this.port} held by non-llama process ${pid} (${cmd.slice(0, 80)}) — leaving it alone`);
- continue;
- }
- try { process.kill(Number(pid), "SIGKILL"); killed++; console.log(`[LLMService] killed orphaned llama-server ${pid} on port ${this.port}`); } catch { /* gone */ }
+ // bind and config changes (ctx size, model) silently never take effect. Worse,
+ // waitForReady would then talk to the ORPHAN (which may serve no/stale model),
+ // marking us "ready" with an empty /v1/models. Find whatever owns the port
+ // and kill it first.
+ if (this.killOrphansOnPort(this.port) > 0) {
+ await new Promise((r) => setTimeout(r, 400)); // let the port free
+ }
+
+ for (let i = 0; i < serverPaths.length; i++) {
+ if (await this.launchServer(serverPaths[i], args)) return; // ready
+ if (i < serverPaths.length - 1) {
+ console.warn(`[LLMService] engine at ${serverPaths[i]} failed to start; trying fallback engine`);
+ // launchServer already tore its process down; free the port before retry.
+ if (this.killOrphansOnPort(this.port) > 0) await new Promise((r) => setTimeout(r, 400));
}
- if (killed) await new Promise((r) => setTimeout(r, 400)); // let the port free
- } catch { /* nothing on the port */ }
+ }
+ console.error('[LLMService] all llama-server engines failed to start');
+ }
- const proc = spawn(serverPath, args, {
- env: {
- ...process.env,
- DYLD_LIBRARY_PATH: binDir,
- },
- });
+ /** Spawn ONE llama-server binary and wait until the model is loaded. Returns
+ * true when it's ready, false when it fails to start — so _doInit can fall
+ * through to the next engine (Windows Vulkan -> CPU). A failed process is torn
+ * down with its close handler neutralized so it can't trigger a crash-restart. */
+ private async launchServer(serverPath: string, args: string[]): Promise {
+ const binDir = path.dirname(serverPath);
+ console.log(`[LLMService] Starting llama-server from ${serverPath}`);
+ // Strip macOS quarantine attributes on production builds (downloaded DMGs get quarantined)
+ if (isPackaged() && process.platform === 'darwin') {
+ try {
+ execSync(`xattr -cr "${binDir}"`, { stdio: 'ignore' });
+ execSync(`chmod +x "${serverPath}"`, { stdio: 'ignore' });
+ console.log('[LLMService] Cleared quarantine attributes from bin directory');
+ } catch (e) {
+ console.warn('[LLMService] Could not clear quarantine attributes:', e);
+ }
+ }
+
+ let proc: ChildProcess;
+ try {
+ proc = spawn(serverPath, args, {
+ env: {
+ ...process.env,
+ // macOS: rpath for the co-located dylibs. Windows: the loader already
+ // searches the exe's own dir for DLLs, but prepend binDir to PATH so the
+ // ggml/llama DLLs resolve even if that behaviour is restricted.
+ DYLD_LIBRARY_PATH: binDir,
+ ...(process.platform === 'win32'
+ ? { PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
+ : {}),
+ },
+ });
+ } catch (e) {
+ console.error(`[LLMService] failed to spawn ${serverPath}:`, e);
+ return false;
+ }
this.server = proc;
this.stderrTail = [];
+ let abandoned = false; // set when we give up on this proc so its close handler is inert
+
+ // A spawn/load error (e.g. a missing Vulkan loader on Windows) surfaces here;
+ // swallow it (waitForReady will fail and we fall back) so it isn't unhandled.
+ proc.on("error", (e) => { console.error(`[LLMService] llama-server process error:`, e); });
proc.stderr?.on("data", (data) => {
const text = String(data);
@@ -422,10 +471,9 @@ export class LLMService {
proc.on("close", (code, signal) => {
console.log(`[llama-server] exited with code ${code} signal ${signal}`);
- // Ignore the close of a PROCESS WE'VE ALREADY REPLACED: on restart/reload,
- // stop() kills the old proc and init() spawns a new one; the old proc's
- // close event fires async and must not null out the live server.
- if (this.server !== proc) return;
+ // Ignore the close of a PROCESS WE'VE ALREADY REPLACED (restart/reload) or
+ // one we deliberately abandoned during engine fallback.
+ if (this.server !== proc || abandoned) return;
const wasIntentional = this.intentionalStop;
this.intentionalStop = false;
this.server = null;
@@ -453,12 +501,60 @@ export class LLMService {
console.log("[LLMService] Vision server ready!");
this.initialized = true;
this.lastErrorMsg = null; // healthy again — clear any prior failure reason
+ return true;
} catch (e) {
- console.error("[LLMService] Failed to start server:", e);
- this.stop();
+ console.error(`[LLMService] engine at ${binDir} failed to start:`, e);
+ // Tear down WITHOUT going through stop() (which sets intentionalStop and
+ // would suppress crash-recovery for the NEXT engine). Neutralize this
+ // proc's close handler so the fallback isn't misread as a crash.
+ abandoned = true;
+ try { proc.kill("SIGKILL"); } catch { /* already gone */ }
+ if (this.server === proc) { this.server = null; this.initialized = false; }
+ return false;
}
}
+ // Kill an orphaned llama-server still holding our port (from a crashed/previous
+ // app process). ONLY kills a process we recognize as our own llama-server — the
+ // port is ours by convention, not reservation, so we never SIGKILL an unrelated
+ // app that happened to bind it. Cross-platform: lsof/ps on macOS+Linux,
+ // netstat/tasklist/taskkill on Windows. Returns how many we killed.
+ private killOrphansOnPort(port: number): number {
+ let killed = 0;
+ try {
+ if (process.platform === "win32") {
+ // " TCP 127.0.0.1:8439 0.0.0.0:0 LISTENING 12345"
+ const out = execSync("netstat -ano -p tcp", { encoding: "utf-8" });
+ const pids = new Set();
+ for (const line of out.split(/\r?\n/)) {
+ const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
+ if (m && m[1] === String(port)) pids.add(m[2]);
+ }
+ for (const pid of pids) {
+ let img = "";
+ try { img = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf-8" }); } catch { continue; /* gone */ }
+ if (!/llama-server/i.test(img)) {
+ console.warn(`[LLMService] port ${port} held by non-llama PID ${pid} — leaving it alone`);
+ continue;
+ }
+ try { execSync(`taskkill /PID ${pid} /F /T`, { stdio: "ignore" }); killed++; console.log(`[LLMService] killed orphaned llama-server.exe ${pid} on port ${port}`); } catch { /* gone */ }
+ }
+ } else {
+ const pids = execSync(`lsof -ti tcp:${port}`, { encoding: "utf-8" }).trim().split("\n").filter(Boolean);
+ for (const pid of pids) {
+ let cmd = "";
+ try { cmd = execSync(`ps -p ${pid} -o command=`, { encoding: "utf-8" }).trim(); } catch { continue; /* already gone */ }
+ if (!/llama-server/i.test(cmd)) {
+ console.warn(`[LLMService] port ${port} held by non-llama process ${pid} (${cmd.slice(0, 80)}) — leaving it alone`);
+ continue;
+ }
+ try { process.kill(Number(pid), "SIGKILL"); killed++; console.log(`[LLMService] killed orphaned llama-server ${pid} on port ${port}`); } catch { /* gone */ }
+ }
+ }
+ } catch { /* nothing on the port */ }
+ return killed;
+ }
+
/** Auto-recover from an unexpected llama-server crash. Backs off, and on repeated
* crashes shrinks the context (the usual culprit is memory pressure) before
* retrying. Gives up after a few attempts so we never spin forever. */
@@ -489,16 +585,34 @@ export class LLMService {
this.init().catch(() => {});
}
+ // Ready = the model is actually LOADED, not merely that the server answers.
+ // /health can report OK before the weights finish loading, and an orphan server
+ // on this port would answer /health while serving NO model — which surfaced as
+ // a 200 server with an empty /v1/models. So we additionally require /v1/models
+ // to list a model before declaring ready, and we bail immediately if the server
+ // process exits (a model that fails to load takes the process down with it).
private async waitForReady(timeout = 60000): Promise {
const start = Date.now();
+ let healthOk = false;
while (Date.now() - start < timeout) {
+ // The server died during startup (e.g. model load failure) — stop waiting.
+ if (!this.server) throw new Error("llama-server exited during startup — model failed to load");
try {
- const res = await fetch(`http://127.0.0.1:${this.port}/health`);
- if (res.ok) return;
- } catch {}
+ if (!healthOk) {
+ const res = await fetch(`http://127.0.0.1:${this.port}/health`);
+ healthOk = res.ok;
+ }
+ if (healthOk) {
+ const res = await fetch(`http://127.0.0.1:${this.port}/v1/models`);
+ if (res.ok) {
+ const body = await res.json().catch(() => null);
+ if (Array.isArray(body?.data) && body.data.length > 0) return;
+ }
+ }
+ } catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 500));
}
- throw new Error("Server failed to start");
+ throw new Error("Server started but no model was loaded within the timeout");
}
// Use Node http module instead of fetch to avoid undici's headersTimeout (300s)
@@ -611,7 +725,16 @@ export class LLMService {
// Fresh connection per request via the shared contract (llm/http-post.ts) — no keep-alive
// pool, so the tool loop's back-to-back requests never hit a half-closed socket (ECONNRESET).
const req = http.request(modelRequestOptions(this.port, Buffer.byteLength(body)), (res) => {
- if (res.statusCode !== 200) { clearTimeout(timer); reject(new Error(`LLM Server Error: ${res.statusCode}`)); res.resume(); return; }
+ if (res.statusCode !== 200) {
+ // Read the server's error body (small) so B2 can surface an actionable
+ // message (e.g. context overflow from too many connectors) instead of a
+ // bare status code.
+ let err = '';
+ res.setEncoding('utf8');
+ res.on('data', (c: string) => { if (err.length < 4096) err += c; });
+ res.on('end', () => { clearTimeout(timer); if (!timedOut && !aborted) reject(new Error(describeServerError(res.statusCode, err))); });
+ return;
+ }
res.setEncoding('utf8');
res.on('data', (chunk: string) => {
buf += chunk;
@@ -682,7 +805,16 @@ export class LLMService {
// Fresh connection per request via the shared contract (llm/http-post.ts) — no keep-alive
// pool, so the tool loop's back-to-back requests never hit a half-closed socket (ECONNRESET).
const req = http.request(modelRequestOptions(this.port, Buffer.byteLength(body)), (res) => {
- if (res.statusCode !== 200) { clearTimeout(timer); reject(new Error(`LLM Server Error: ${res.statusCode}`)); res.resume(); return; }
+ if (res.statusCode !== 200) {
+ // Read the server's error body (small) so B2 can surface an actionable
+ // message (e.g. context overflow from too many connectors) instead of a
+ // bare status code.
+ let err = '';
+ res.setEncoding('utf8');
+ res.on('data', (c: string) => { if (err.length < 4096) err += c; });
+ res.on('end', () => { clearTimeout(timer); if (!timedOut && !aborted) reject(new Error(describeServerError(res.statusCode, err))); });
+ return;
+ }
res.setEncoding('utf8');
res.on('data', (chunk: string) => {
buf += chunk;
@@ -774,13 +906,17 @@ export class LLMService {
}
/** Hard restart: kill the server and spawn it fresh (picks up a model swap or
- * recovers a crashed/hung instance). Used by "Configure for me" and the
- * Health panel's restart action. */
+ * recovers a crashed/hung instance). Used by "Configure for me", the Health
+ * panel's restart action, and the Models screen dev control. Clears `paused`
+ * so a manual restart always recovers even while paused for image gen, and
+ * throws if the server fails to come back up so the caller can surface it. */
async restart(): Promise {
+ this.paused = false;
this.stop();
this.initialized = false;
this.resolveModel();
await this.init();
+ if (!this.initialized) throw new Error("Server did not come back up — check the model is downloaded");
}
}
diff --git a/src/main/llm/http-post.ts b/src/main/llm/http-post.ts
index 144ab2a0..45c0165f 100644
--- a/src/main/llm/http-post.ts
+++ b/src/main/llm/http-post.ts
@@ -11,6 +11,30 @@
import * as http from 'http';
+/** Turn a non-200 model-server response into an ACTIONABLE message. llama-server
+ * returns a JSON body like {"error":{"message":"request (22825 tokens) exceeds
+ * the available context size (16384 tokens) …"}}; the bare status code alone is
+ * useless to the user. We surface the common, user-fixable cases in plain
+ * language and otherwise fall back to the server's own message. */
+export function describeServerError(statusCode: number | undefined, body: string): string {
+ let detail = (body || '').trim();
+ try {
+ const j = JSON.parse(body);
+ const m = j?.error?.message ?? j?.message;
+ if (typeof m === 'string' && m) detail = m;
+ } catch { /* non-JSON body — use the raw text */ }
+ // Context overflow — usually too many connectors enabled at once (their tool
+ // schemas + grammar overflow the context window).
+ if (/exceeds the available context size/i.test(detail)) {
+ return 'The request is larger than the model’s context window — usually too many connectors enabled at once. Disable some connectors, or raise the context window in Settings, then try again.';
+ }
+ // A tool schema that can't be compiled into a valid grammar for the engine.
+ if (/failed to (parse|initialize|compile) (grammar|json ?schema)/i.test(detail)) {
+ return 'A connected tool’s schema couldn’t be turned into a valid grammar for the local model. Disable the most recently added connector and try again.';
+ }
+ return `LLM Server Error: ${statusCode ?? '?'}${detail ? ` ${detail}` : ''}`;
+}
+
/** The request options that guarantee a fresh, non-pooled connection to the model server.
* This is the contract that unbroke the tool loop — defined once, consumed everywhere. */
export function modelRequestOptions(port: number, contentLength: number): http.RequestOptions {
@@ -47,7 +71,7 @@ export function postCompletionOnce(port: number, body: string, timeoutMs: number
res.on('end', () => {
clearTimeout(timer);
if (timedOut) { return; }
- if (res.statusCode !== 200) { reject(new Error(`LLM Server Error: ${res.statusCode} ${data}`)); return; }
+ if (res.statusCode !== 200) { reject(new Error(describeServerError(res.statusCode, data))); return; }
resolve(data);
});
});
diff --git a/src/main/model-server.ts b/src/main/model-server.ts
index ce21a1b3..71914f6c 100644
--- a/src/main/model-server.ts
+++ b/src/main/model-server.ts
@@ -472,7 +472,17 @@ async function handleModelsList(res: http.ServerResponse): Promise {
const upstream = await fetchUpstreamModels();
const upData = Array.isArray(upstream.data) ? (upstream.data as Record[]) : [];
// Tag the LLM entries chat vs vision from their advertised capabilities.
- const text: Record[] = tagLlmEntries(upData);
+ let text: Record[] = tagLlmEntries(upData);
+ // Fall back to the on-disk active model when the upstream llama-server hasn't
+ // loaded one yet (idle app, headless gateway, or a server that came up without
+ // a model). Without this, /v1/models reports an empty chat model even though one
+ // is installed and would load on the next request.
+ if (text.length === 0) {
+ const info = llm.activeModelInfo();
+ if (info) {
+ text = [{ id: info.id, object: 'model', created: now, owned_by: 'off-grid', kind: info.vision ? 'vision' : 'chat' }];
+ }
+ }
const tag = (id: string, kind: string, extra: Record = {}): Record =>
modelEntry(id, kind, now, extra);
diff --git a/src/main/runtime-env.ts b/src/main/runtime-env.ts
index 4a6bfcbb..182c1e16 100644
--- a/src/main/runtime-env.ts
+++ b/src/main/runtime-env.ts
@@ -67,6 +67,13 @@ export function binRoots(): string[] {
return [path.join(process.cwd(), 'resources', 'bin')];
}
+/** Append the platform executable extension to a bundled binary's base name:
+ * `.exe` on Windows, nothing on macOS/Linux. Use this at every spawn site so
+ * `llama-server` resolves to `llama-server.exe` on win32. */
+export function exe(name: string): string {
+ return process.platform === 'win32' ? `${name}.exe` : name;
+}
+
/** App/package root (cwd for spawned helpers that resolve their own deps). */
export function appRoot(): string {
if (process.env.OFFGRID_APP_ROOT) return process.env.OFFGRID_APP_ROOT;
diff --git a/src/main/sd-server.ts b/src/main/sd-server.ts
index 6d685756..dfec8021 100644
--- a/src/main/sd-server.ts
+++ b/src/main/sd-server.ts
@@ -21,7 +21,7 @@ import { spawn, type ChildProcess, execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
-import { binRoots, isPackaged } from './runtime-env';
+import { binRoots, isPackaged, exe } from './runtime-env';
/** Off the LLM's 8439 so both engines can bind (they never run at once, but a
* lingering LLM shouldn't block the image server's port either). */
@@ -175,7 +175,7 @@ export class SdServerService {
private findBinary(): string | null {
for (const r of binRoots()) {
- const p = path.join(r, 'sd', 'sd-server');
+ const p = path.join(r, 'sd', exe('sd-server'));
if (fs.existsSync(p)) return p;
}
return null;
@@ -215,7 +215,17 @@ export class SdServerService {
const args = buildSdServerContextArgs({ ...ctx, port: this.port });
// cwd at the binary dir so @executable_path rpath resolves libstable-diffusion.dylib.
- const proc = spawn(bin, args, { cwd: binDir, env: { ...process.env, DYLD_LIBRARY_PATH: binDir } });
+ // Windows: prepend binDir to PATH so the co-located stable-diffusion DLLs resolve.
+ const proc = spawn(bin, args, {
+ cwd: binDir,
+ env: {
+ ...process.env,
+ DYLD_LIBRARY_PATH: binDir,
+ ...(process.platform === 'win32'
+ ? { PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
+ : {}),
+ },
+ });
this.server = proc;
this.stderrTail = [];
const capture = (d: Buffer): void => {
diff --git a/src/main/setup.ts b/src/main/setup.ts
index 35363371..d9cb604f 100644
--- a/src/main/setup.ts
+++ b/src/main/setup.ts
@@ -12,6 +12,7 @@ import { llm } from './llm';
import { decideChatStatus } from './chat-health';
import { getActiveModel, downloadModel, listInstalled, setActiveModel, setActiveModalChoice } from './models-manager';
import { LLAMA_SERVER_PORT, GATEWAY_PORT } from '../shared/ports';
+import { deviceNoun } from '../shared/device';
import type { RecMode } from './models/setup-types';
import {
normalizeMode,
@@ -242,7 +243,7 @@ export async function getSetupPlan(mode?: RecMode): Promise {
export async function autoConfigure(onProgress?: SetupProgressCb): Promise<{ success: boolean; error?: string; modelId?: string; modelName?: string }> {
const emit = (p: SetupProgress): void => { try { onProgress?.(p); } catch { /* ignore */ } };
- emit({ phase: 'select', message: 'Picking a model that fits your Mac…' });
+ emit({ phase: 'select', message: `Picking a model that fits your ${deviceNoun(process.platform)}…` });
const model = await recommendChatModel();
if (!model) { emit({ phase: 'error', message: 'No suitable model found.' }); return { success: false, error: 'no suitable model found' }; }
diff --git a/src/main/tools.ts b/src/main/tools.ts
index 2f397bdc..84a9706a 100644
--- a/src/main/tools.ts
+++ b/src/main/tools.ts
@@ -312,7 +312,28 @@ export async function toolChat(
}
} catch (err) { console.error('[tools] extension schemas', e.id, err); }
}
- const tools = extSchemas.length ? [...schemas(imageAvailable), ...extSchemas] : schemas(imageAvailable);
+ const builtins = schemas(imageAvailable);
+ const rawTools = extSchemas.length ? [...builtins, ...extSchemas] : builtins;
+ // Keep the tool payload within the model's context. llama-server inlines every
+ // tool schema into the prompt AND compiles it to a grammar, so a big connector
+ // set can blow past the context window and 400 the whole turn. Budget to a
+ // fraction of the effective context (leaving room for system + history +
+ // answer); prune verbose schemas first, drop connector tools only if needed.
+ const { budgetTools } = await import('./tools/tool-budget');
+ const ctx = llm.effectiveContextSize();
+ // Cap tool tokens in ABSOLUTE terms too, not just as a fraction of context:
+ // llama-server re-processes the entire tool prompt every tool round (gemma-4's
+ // sliding-window attention defeats the prompt cache), so on CPU-only inference a
+ // large tool payload dominates latency. ~4k keeps a useful connector set while
+ // roughly halving per-round prompt cost vs the old 45%-of-a-big-context budget.
+ const MAX_TOOL_TOKENS = 4000;
+ const toolBudget = Math.max(1024, Math.min(Math.floor(ctx * 0.4), MAX_TOOL_TOKENS));
+ const budgeted = budgetTools(rawTools, toolBudget, builtins.length);
+ if (budgeted.pruned || budgeted.droppedCount) {
+ console.warn(`[tools] context budget ${toolBudget} tok: pruned schemas${budgeted.droppedCount ? `, dropped ${budgeted.droppedCount} connector tool(s)` : ''} to fit (final ~${budgeted.estTokens} tok)`);
+ if (budgeted.droppedCount) hints.push(`Note: ${budgeted.droppedCount} connector tool(s) were omitted this turn to fit the context window; ask the user to disable some connectors if a needed tool is missing.`);
+ }
+ const tools = budgeted.tools;
const sys = 'You are Off Grid, a private on-device assistant. Use the provided tools when they help answer precisely. Keep answers concise.'
+ (hints.length ? ' ' + hints.join(' ') : '');
diff --git a/src/main/tools/tool-budget.ts b/src/main/tools/tool-budget.ts
new file mode 100644
index 00000000..4ffb719e
--- /dev/null
+++ b/src/main/tools/tool-budget.ts
@@ -0,0 +1,75 @@
+// Keep the tool-schema payload within the model's context budget.
+//
+// With many MCP connectors enabled, the combined tool schemas can exceed the
+// whole context window: llama-server inlines every tool schema into the prompt
+// AND compiles it into a GBNF grammar. Too big and the server rejects the turn
+// with a 400 ("request … exceeds the available context size"). Certain schema
+// keywords (numeric/string RANGE constraints) also expand into enormous nested
+// grammars — the exact thing that made tool-call grammars fail to parse.
+//
+// Strategy, cheapest first:
+// 1. PRUNE every schema — drop the grammar-bloating, low-value keywords and
+// truncate long descriptions. Keeps ALL tools available.
+// 2. If still over budget, DROP whole connector tools from the end until it
+// fits — but NEVER below the built-ins — and report how many went (never a
+// silent cap; the caller logs it and tells the model).
+
+// Rough token estimate: ~4 chars/token for compact JSON. Good enough for a guard.
+function estTokens(v: unknown): number {
+ return Math.ceil(JSON.stringify(v ?? '').length / 4);
+}
+
+// Keywords stripped from a schema: they bloat tokens and/or the compiled grammar
+// without changing which tool or arguments exist. The numeric/string RANGE
+// constraints (minimum/maximum/…) are the worst offenders — they expand into
+// giant nested-digit grammars. enum/type/properties/required/items are KEPT.
+const DROP_KEYS = new Set([
+ 'examples', 'example', '$comment', 'title', 'default',
+ 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
+ 'pattern', 'format', 'minLength', 'maxLength', 'minItems', 'maxItems',
+]);
+
+const MAX_DESC = 120; // chars — enough to guide tool choice, not to bloat the prompt
+
+function pruneSchema(node: unknown): unknown {
+ if (Array.isArray(node)) return node.map(pruneSchema);
+ if (!node || typeof node !== 'object') return node;
+ const out: Record = {};
+ for (const [k, val] of Object.entries(node as Record)) {
+ if (DROP_KEYS.has(k)) continue;
+ if (k === 'description' && typeof val === 'string') {
+ out[k] = val.length > MAX_DESC ? val.slice(0, MAX_DESC - 1) + '…' : val;
+ } else {
+ out[k] = pruneSchema(val);
+ }
+ }
+ return out;
+}
+
+export interface ToolBudgetResult {
+ tools: unknown[];
+ droppedCount: number; // connector tools removed to fit
+ pruned: boolean; // schemas were pruned to fit
+ estTokens: number; // final estimate
+}
+
+/** Fit `tools` into `maxTokens`. `keepFirst` leading tools are BUILT-INS that are
+ * never dropped (only the connector tools after them are). */
+export function budgetTools(tools: unknown[], maxTokens: number, keepFirst: number): ToolBudgetResult {
+ if (estTokens(tools) <= maxTokens) {
+ return { tools, droppedCount: 0, pruned: false, estTokens: estTokens(tools) };
+ }
+ // 1) Prune every schema first — cheap, keeps all tools available.
+ const prunedAll = tools.map(pruneSchema);
+ if (estTokens(prunedAll) <= maxTokens) {
+ return { tools: prunedAll, droppedCount: 0, pruned: true, estTokens: estTokens(prunedAll) };
+ }
+ // 2) Still over: drop connector tools from the end, never below the built-ins.
+ const kept = prunedAll.slice();
+ let dropped = 0;
+ while (kept.length > keepFirst && estTokens(kept) > maxTokens) {
+ kept.pop();
+ dropped++;
+ }
+ return { tools: kept, droppedCount: dropped, pruned: true, estTokens: estTokens(kept) };
+}
diff --git a/src/main/transcription/whisper-cli.ts b/src/main/transcription/whisper-cli.ts
index 491ca71a..b9d201db 100644
--- a/src/main/transcription/whisper-cli.ts
+++ b/src/main/transcription/whisper-cli.ts
@@ -10,7 +10,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { getActiveModal } from '../active-models';
-import { binRoots, modelsDir } from '../runtime-env';
+import { binRoots, modelsDir, exe } from '../runtime-env';
import { modelsByKind } from '@offgrid/models';
import { existing } from './bin-resolution';
import { catalogEngine } from './classify';
@@ -20,13 +20,13 @@ const execFileAsync = promisify(execFile);
/** Resolve the bundled whisper-cli across dev / packaged layouts. */
export function whisperBin(): string | null {
- return existing(binRoots().map((r) => path.join(r, 'whisper', 'whisper-cli')));
+ return existing(binRoots().map((r) => path.join(r, 'whisper', exe('whisper-cli'))));
}
/** Resolve ffmpeg: bundled first, then common system locations. */
export function ffmpegBin(): string | null {
return existing([
- ...binRoots().map((r) => path.join(r, 'ffmpeg')),
+ ...binRoots().map((r) => path.join(r, exe('ffmpeg'))),
'/opt/homebrew/bin/ffmpeg',
'/usr/local/bin/ffmpeg',
'/usr/bin/ffmpeg',
diff --git a/src/main/transcription/whisper-server.ts b/src/main/transcription/whisper-server.ts
index 82b4bf36..1df77d50 100644
--- a/src/main/transcription/whisper-server.ts
+++ b/src/main/transcription/whisper-server.ts
@@ -22,7 +22,7 @@ import fs from 'fs';
import os from 'os';
import { promisify } from 'util';
import { execFile } from 'child_process';
-import { binRoots, isPackaged } from '../runtime-env';
+import { binRoots, isPackaged, exe } from '../runtime-env';
import { whisperModel, ffmpegBin } from './whisper-cli';
import type { TranscriptionService, Transcript, TranscribeOptions } from './types';
@@ -135,7 +135,7 @@ export class WhisperServerService {
/** Resolve the bundled whisper-server binary across dev / packaged layouts. */
findBinary(): string | null {
for (const r of binRoots()) {
- const p = path.join(r, 'whisper-server', 'whisper-server');
+ const p = path.join(r, 'whisper-server', exe('whisper-server'));
if (fs.existsSync(p)) return p;
}
return null;
@@ -175,7 +175,18 @@ export class WhisperServerService {
const args = buildWhisperServerArgs({ ...ctx, port: this.port });
// cwd at the binary dir so @rpath resolves the co-located libwhisper/libggml dylibs.
- const proc = spawn(bin, args, { cwd: binDir, env: { ...process.env, DYLD_LIBRARY_PATH: binDir } });
+ const proc = spawn(bin, args, {
+ cwd: binDir,
+ // macOS: rpath for the co-located dylibs. Windows: prepend binDir to PATH so
+ // the ggml/whisper DLLs next to the exe resolve.
+ env: {
+ ...process.env,
+ DYLD_LIBRARY_PATH: binDir,
+ ...(process.platform === 'win32'
+ ? { PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
+ : {}),
+ },
+ });
this.server = proc;
this.stderrTail = [];
const capture = (d: Buffer): void => {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 44be9606..18eaf06c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -10,6 +10,9 @@ try {
// pro tabs without an async round-trip. See main/license-ipc.ts (`pro:is-enabled`).
// Falls back to false if the handler isn't registered (should never happen).
isPro: ipcRenderer.sendSync('pro:is-enabled') === true,
+ // Host OS, read synchronously at preload time (Node context) so renderer
+ // copy can name the machine correctly ('Mac' vs 'device'). See shared/device.ts.
+ platform: process.platform,
// License (Keygen) activation + status for the upgrade/settings UI.
license: {
status: () => ipcRenderer.invoke('license:status'),
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 15df12d4..b62ff730 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -18,7 +18,8 @@ import type { SearchHit } from './types';
import { loadProFeaturesRenderer } from './bootstrap/loadProFeaturesRenderer';
import { renderProView, type ProViewContext } from './bootstrap/proView';
import { UpgradeScreen } from './components/pro/UpgradeScreen';
-import { getProFeature } from './components/pro/proCatalog';
+import { getProFeature, proFeatureComingSoon } from './components/pro/proCatalog';
+import { currentPlatform, isMac } from './lib/device';
import { NotificationProvider, useNotifications } from './hooks/useNotifications';
import { ToastProvider } from './hooks/useToast';
import { ReprocessingProvider, useReprocessing } from './hooks/useReprocessing';
@@ -161,8 +162,9 @@ function AppContent() {
}, []);
// Free users land on Models (download a model first, with the sidebar to
- // explore); pro lands on Day. Never a locked Pro tab.
- const [viewMode, setViewMode] = useState(isPro ? 'day' : 'models');
+ // explore); pro lands on Day. Never a locked Pro tab — and never a coming-soon
+ // Pro tab either, so Pro on a non-Mac platform also starts on Models.
+ const [viewMode, setViewMode] = useState(isPro && isMac() ? 'day' : 'models');
const [selectedSessionId, setSelectedSessionId] = useState(null);
const [selectedMemoryId, setSelectedMemoryId] = useState(null);
// Version of a downloaded-and-staged update (null = none). Surfaced as a banner
@@ -765,6 +767,11 @@ function AppContent() {
) : viewMode === 'settings' ? (
+ ) : proFeatureComingSoon(viewMode, currentPlatform(), isPro) ? (
+ // Pro is macOS-tested only for now: a Pro subscriber on a
+ // non-Mac platform gets the coming-soon screen, never the
+ // untested feature. (Free users still get the upsell below.)
+
) : (
// Pro tabs: render through the pro view-router when active,
// otherwise show the upgrade writeup for that feature.
diff --git a/src/renderer/src/components/ModelsScreen.tsx b/src/renderer/src/components/ModelsScreen.tsx
index d89983a9..eba1700d 100644
--- a/src/renderer/src/components/ModelsScreen.tsx
+++ b/src/renderer/src/components/ModelsScreen.tsx
@@ -16,6 +16,7 @@ import {
IconStarFilled,
} from '@tabler/icons-react';
import { StoragePanel } from './setup/StoragePanel';
+import { deviceNoun } from '@renderer/lib/device';
import {
filterAndSort,
parseParamCount,
@@ -84,7 +85,7 @@ const USE_CASES: UseCase[] = [
{ id: 'general', label: 'General', blurb: 'Everyday questions, drafting, and brainstorming.', match: () => true },
{ id: 'coding', label: 'Coding', blurb: 'Code generation — larger models reason better.', match: (m) => (m.params ?? 0) >= 4 },
{ id: 'writing', label: 'Writing', blurb: 'Long-form drafting — long context helps.', match: (m) => (m.params ?? 0) >= 2 },
- { id: 'legal', label: 'Legal', blurb: 'Dense docs, careful reasoning — on-device, nothing leaves your Mac.', match: (m) => (m.params ?? 0) >= 7 },
+ { id: 'legal', label: 'Legal', blurb: `Dense docs, careful reasoning — on-device, nothing leaves your ${deviceNoun()}.`, match: (m) => (m.params ?? 0) >= 7 },
{ id: 'vision', label: 'Vision', blurb: 'Understand images, screenshots, documents.', match: (m) => m.kind === 'vision' },
{ id: 'lightweight', label: 'Lightweight', blurb: 'Fast, low-memory — for modest machines.', match: (m) => (m.params ?? 0) <= 4 },
];
@@ -398,11 +399,13 @@ export function ModelsScreen() {
{/* Header */}
- {bytes / 1e9 <= ramGb * 0.38 ? 'Comfortable fit on your Mac.' : bytes / 1e9 <= ramGb * 0.55 ? 'Tight on RAM — context will be reduced.' : 'Large for your Mac — may run slowly.'}
+ {bytes / 1e9 <= ramGb * 0.38 ? `Comfortable fit on your ${deviceNoun()}.` : bytes / 1e9 <= ramGb * 0.55 ? 'Tight on RAM — context will be reduced.' : `Large for your ${deviceNoun()} — may run slowly.`}
)}
{m.imageModes && m.imageModes.length > 0 && (
diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx
index 696093c8..dcff6ad0 100644
--- a/src/renderer/src/components/Onboarding.tsx
+++ b/src/renderer/src/components/Onboarding.tsx
@@ -4,6 +4,7 @@ import { LampContainer } from './ui/lamp';
import { OrbitingCircles } from './ui/orbiting-circles';
import { GridBackdrop } from './ui/grid-backdrop';
import { cn } from '@renderer/lib/utils';
+import { deviceNoun } from '@renderer/lib/device';
import logo from '@/assets/logo.png';
import {
ArrowRight,
@@ -87,7 +88,7 @@ const PRO_GRID = [
{ icon: MagnifyingGlass, label: 'Memory', line: 'One search across everything you have seen, said, and saved, so you never lose a thing twice.' },
{ icon: Graph, label: 'Entities', line: 'Builds a record of every person and project on its own, so you walk into any call knowing where you left off.' },
{ icon: CalendarBlank, label: 'Day', line: 'Lays out your day from your work and the calendars you connect, so you start oriented instead of scrambling.' },
- { icon: ShieldCheck, label: 'Vault', line: 'Encrypts passwords, keys, and secret files with a key that never leaves this Mac, so they stay yours alone.' },
+ { icon: ShieldCheck, label: 'Vault', line: `Encrypts passwords, keys, and secret files with a key that never leaves this ${deviceNoun()}, so they stay yours alone.` },
{ icon: Waveform, label: 'Voice', line: 'Hold Option+Space and talk - transcribed locally and pasted at your cursor, so you type with your voice anywhere.' },
];
@@ -134,7 +135,7 @@ export function Onboarding({ onComplete }: OnboardingProps) {
-
+
No account, no API key, no telemetry. Inference happens on your CPU and GPU. Turn off wifi and it keeps working. You can verify it yourself.
diff --git a/src/renderer/src/components/PermissionGate.tsx b/src/renderer/src/components/PermissionGate.tsx
index 2acdfea8..c5509734 100644
--- a/src/renderer/src/components/PermissionGate.tsx
+++ b/src/renderer/src/components/PermissionGate.tsx
@@ -3,6 +3,7 @@ import { motion } from 'motion/react';
import { BorderBeam } from './ui/border-beam';
import { GridBackdrop } from './ui/grid-backdrop';
import { cn } from '@renderer/lib/utils';
+import { deviceNoun } from '@renderer/lib/device';
import { Shield, Eye, Check, X, ArrowsClockwise as RefreshCw, Cpu } from '@phosphor-icons/react';
import { SetupPanel } from './setup/SetupPanel';
@@ -319,7 +320,7 @@ function SetupNudge({
// Capture permissions (Pro-only) are the secondary, optional step.
const title = missingModel ? 'Set up your local AI' : 'Finish setting up capture';
const detail = missingModel
- ? 'Pick a model yourself, or let Off Grid configure one for your Mac.'
+ ? `Pick a model yourself, or let Off Grid configure one for your ${deviceNoun()}.`
: 'Grant screen & accessibility access so Off Grid can see & remember.';
const cta = missingModel ? 'Configure' : 'Set up';
return (
diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx
index 05ad790d..2e463e04 100644
--- a/src/renderer/src/components/Settings.tsx
+++ b/src/renderer/src/components/Settings.tsx
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
-import { LockKey, X, CheckCircle, Desktop, EnvelopeSimple, CaretDown } from '@phosphor-icons/react';
+import { LockKey, X, CheckCircle, Desktop, EnvelopeSimple, CaretDown, Clock } from '@phosphor-icons/react';
import { cn } from '@renderer/lib/utils';
+import { currentPlatform, deviceNoun } from '@renderer/lib/device';
+import { proComingSoonHere } from './pro/proCatalog';
import { ProgressiveBlur } from './ui/progressive-blur';
import { SetupPanel } from './setup/SetupPanel';
import { StoragePanel } from './setup/StoragePanel';
@@ -51,7 +53,21 @@ function SettingsCard({
// A Pro section shown (disabled) in the free build: title + description + a
// "Pro" badge, dimmed and non-interactive.
-function ProPlaceholder({ title, description, delay = 0.18 }: { title: string; description: string; delay?: number }): React.ReactElement {
+// Dimmed placeholder for a Pro section. Default badge is the "Pro" lock (free
+// build upsell). `comingSoon` swaps it for a neutral "Coming soon" clock — used
+// for a Pro subscriber on a non-Mac platform, where the section is a real feature
+// they own but that isn't ready on their device yet.
+function ProPlaceholder({
+ title,
+ description,
+ delay = 0.18,
+ comingSoon = false,
+}: {
+ title: string;
+ description: string;
+ delay?: number;
+ comingSoon?: boolean;
+}): React.ReactElement {
return (
-
- Pro
-
+ {comingSoon ? (
+
+ Coming soon
+
+ ) : (
+
+ Pro
+
+ )}
{title}
{description}
@@ -403,6 +425,10 @@ export function Settings() {
// and are hidden in the free build.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isPro = !!(window as any).api?.isPro;
+ // Pro subscriber on a non-Mac platform: backend-dependent Pro sections (proactive
+ // delivery, learned prefs) aren't ready on this device yet, so show a coming-soon
+ // placeholder. Identity + plan management stay available (they work cross-platform).
+ const proComingSoon = proComingSoonHere(currentPlatform(), isPro);
const [idName, setIdName] = useState('');
const [idEmail, setIdEmail] = useState('');
const [appVersion, setAppVersion] = useState('');
@@ -477,15 +503,21 @@ export function Settings() {
)}
- {/* Pro sections — shown but disabled in the free build. */}
- {isPro ? (
+ {/* Pro sections — shown but disabled in the free build; on a non-Mac
+ platform a Pro subscriber sees a coming-soon placeholder (the
+ feature is theirs, just not ready on this device yet). */}
+ {proComingSoon ? (
+
+ ) : isPro ? (
) : (
)}
- {isPro ? (
+ {proComingSoon ? (
+
+ ) : isPro ? (
diff --git a/src/renderer/src/components/pro/UpgradeScreen.tsx b/src/renderer/src/components/pro/UpgradeScreen.tsx
index 93546e56..17f4976c 100644
--- a/src/renderer/src/components/pro/UpgradeScreen.tsx
+++ b/src/renderer/src/components/pro/UpgradeScreen.tsx
@@ -1,7 +1,8 @@
import { useState } from 'react';
-import { ArrowSquareOut, Check, Sparkle, Key, CircleNotch, DeviceMobile } from '@phosphor-icons/react';
+import { ArrowSquareOut, Check, Sparkle, Key, CircleNotch, DeviceMobile, Clock, Desktop } from '@phosphor-icons/react';
import { PRO_PAY_URL, PRO_FEATURES, type ProFeature } from './proCatalog';
-import { OFF_GRID_MOBILE_URL, openExternal } from '../../constants/links';
+import { OFF_GRID_MOBILE_URL, OFF_GRID_WEBSITE_URL, openExternal } from '../../constants/links';
+import { deviceNoun, isMac } from '@renderer/lib/device';
// License-key activation. Only meaningful in a pro-capable build (__OFFGRID_PRO__);
// a core build has no pro code bundled, so entering a key would unlock nothing.
@@ -77,12 +78,20 @@ function LicenseActivation(): React.ReactElement {
);
}
-// Shown in the free build when a Pro tab is opened. Pro is launching soon — this
-// writes up what the feature will do and points to early access (free waitlist)
-// or paying now (lifetime free + first access). People who've already paid are
-// reassured they're first in line.
-export function UpgradeScreen({ feature }: { feature?: ProFeature }): React.ReactElement {
+// Shown when a Pro tab is opened. Two variants share the same feature writeup:
+// - 'upgrade' (default): the free-build upsell — buy Pro / activate a license.
+// - 'coming-soon': a Pro subscriber on a non-Mac platform (Pro is macOS-tested
+// only for now). No buy CTA — they already pay; instead we reassure them their
+// license works on Mac + phone today and their platform is on the way.
+export function UpgradeScreen({
+ feature,
+ variant = 'upgrade',
+}: {
+ feature?: ProFeature;
+ variant?: 'upgrade' | 'coming-soon';
+}): React.ReactElement {
const f = feature;
+ const comingSoon = variant === 'coming-soon';
const open = (url: string): void => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).api;
@@ -95,9 +104,15 @@ export function UpgradeScreen({ feature }: { feature?: ProFeature }): React.Reac
{/* Left — the pitch (left-aligned, desktop reading column) */}
-
- Off Grid Pro · Available now
-
+ {comingSoon ? (
+
+ Off Grid Pro · Coming soon
+
+ ) : (
+
+ Off Grid Pro · Available now
+
+ )}
- {/* Right — action card (buy + activate), the desktop side panel */}
+ {/* Right — action card. 'upgrade' = buy + activate; 'coming-soon' = reassure
+ an existing Pro subscriber their license works on Mac + phone today. */}
Configure it for me
-
Pick how much of your Mac to use, then one click does the rest.
+
Pick how much of your {deviceNoun()} to use, then one click does the rest.
@@ -185,7 +186,7 @@ export function SetupPanel({ onConfigured, hideHealth }: SetupPanelProps): React
Chat first (you’re in as soon as it’s ready); voice, speech{plan.items.some((i) => i.kind === 'image') ? ' & image' : ''} finish in the background.
- For solid reasoning & tool use, Gemma 4 E4B is the recommended minimum (4B, ~6 GB — fine on a 16 GB Mac). Smaller 2B models are lighter and add vision, but are noticeably weaker at reasoning.
+ For solid reasoning & tool use, Gemma 4 E4B is the recommended minimum (4B, ~6 GB — fine on a 16 GB {deviceNoun()}). Smaller 2B models are lighter and add vision, but are noticeably weaker at reasoning.
)}
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts
index 36030ac8..6363b03a 100644
--- a/src/renderer/src/env.d.ts
+++ b/src/renderer/src/env.d.ts
@@ -121,6 +121,9 @@ interface AppSettings {
interface IElectronAPI {
// Open-core bridge
isPro?: boolean
+ // Host OS (process.platform), bridged at preload time. Used by lib/device.ts
+ // to name the machine ('Mac' on darwin, else 'device').
+ platform?: string
proInvoke?: (channel: string, ...args: unknown[]) => Promise
proOn?: (channel: string, cb: (...a: unknown[]) => void) => () => void
proOff?: (channel: string) => void
diff --git a/src/renderer/src/lib/__tests__/device.test.ts b/src/renderer/src/lib/__tests__/device.test.ts
new file mode 100644
index 00000000..557c1b5f
--- /dev/null
+++ b/src/renderer/src/lib/__tests__/device.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { deviceNoun, isMac, currentPlatform } from '../device';
+
+// The renderer wrapper resolves the platform from the preload-bridged
+// `window.api.platform` and delegates the naming rule to shared/device.ts.
+describe('renderer deviceNoun wrapper', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('reads window.api.platform: darwin -> Mac', () => {
+ vi.stubGlobal('window', { api: { platform: 'darwin' } });
+ expect(deviceNoun()).toBe('Mac');
+ });
+
+ it('reads window.api.platform: win32 -> device', () => {
+ vi.stubGlobal('window', { api: { platform: 'win32' } });
+ expect(deviceNoun()).toBe('device');
+ expect(deviceNoun({ capitalize: true })).toBe('Device');
+ });
+
+ it('falls back to "device" when window.api is absent', () => {
+ vi.stubGlobal('window', {});
+ expect(deviceNoun()).toBe('device');
+ });
+
+ it('falls back to "device" when window itself is undefined (non-DOM env)', () => {
+ vi.stubGlobal('window', undefined);
+ expect(deviceNoun()).toBe('device');
+ });
+
+ it('isMac() reflects the bridged platform', () => {
+ vi.stubGlobal('window', { api: { platform: 'darwin' } });
+ expect(isMac()).toBe(true);
+ vi.stubGlobal('window', { api: { platform: 'win32' } });
+ expect(isMac()).toBe(false);
+ vi.stubGlobal('window', {});
+ expect(isMac()).toBe(false);
+ });
+
+ it('currentPlatform() returns the bridged value or "unknown"', () => {
+ vi.stubGlobal('window', { api: { platform: 'linux' } });
+ expect(currentPlatform()).toBe('linux');
+ vi.stubGlobal('window', {});
+ expect(currentPlatform()).toBe('unknown');
+ });
+});
diff --git a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts
index d997c2a9..94bd911b 100644
--- a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts
+++ b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts
@@ -4,7 +4,7 @@
// (the free build renders locked nav items straight from this data, so a malformed
// entry ships a broken upsell tab).
import { describe, it, expect } from 'vitest';
-import { getProFeature, PRO_FEATURES, PRO_PAY_URL } from '../../components/pro/proCatalog';
+import { getProFeature, proComingSoonHere, proFeatureComingSoon, PRO_FEATURES, PRO_PAY_URL } from '../../components/pro/proCatalog';
describe('getProFeature', () => {
it('returns the matching feature for a known route', () => {
@@ -29,6 +29,53 @@ describe('getProFeature', () => {
});
});
+describe('proComingSoonHere (base rule: Pro is macOS-tested only for now)', () => {
+ it('is true for a Pro subscriber on any non-Mac platform', () => {
+ expect(proComingSoonHere('win32', true)).toBe(true);
+ expect(proComingSoonHere('linux', true)).toBe(true);
+ expect(proComingSoonHere('unknown', true)).toBe(true);
+ });
+
+ it('is false on macOS', () => {
+ expect(proComingSoonHere('darwin', true)).toBe(false);
+ });
+
+ it('is false for free users everywhere (they get the upsell)', () => {
+ expect(proComingSoonHere('win32', false)).toBe(false);
+ expect(proComingSoonHere('darwin', false)).toBe(false);
+ });
+});
+
+describe('proFeatureComingSoon (Pro is macOS-tested only for now)', () => {
+ const aRoute = PRO_FEATURES[0].route;
+
+ it('shows coming-soon for a Pro subscriber on a non-Mac platform', () => {
+ expect(proFeatureComingSoon(aRoute, 'win32', true)).toBe(true);
+ expect(proFeatureComingSoon(aRoute, 'linux', true)).toBe(true);
+ });
+
+ it('never shows coming-soon on macOS (Pro works there)', () => {
+ expect(proFeatureComingSoon(aRoute, 'darwin', true)).toBe(false);
+ });
+
+ it('never shows coming-soon to a free user (they get the upsell instead)', () => {
+ expect(proFeatureComingSoon(aRoute, 'win32', false)).toBe(false);
+ expect(proFeatureComingSoon(aRoute, 'linux', false)).toBe(false);
+ });
+
+ it('only applies to real Pro routes, not core/unknown views', () => {
+ expect(proFeatureComingSoon('models', 'win32', true)).toBe(false);
+ expect(proFeatureComingSoon('does-not-exist', 'win32', true)).toBe(false);
+ expect(proFeatureComingSoon('', 'win32', true)).toBe(false);
+ });
+
+ it('gates every catalog route for a Windows Pro subscriber', () => {
+ for (const f of PRO_FEATURES) {
+ expect(proFeatureComingSoon(f.route, 'win32', true), `coming-soon for ${f.route}`).toBe(true);
+ }
+ });
+});
+
describe('PRO_FEATURES data integrity', () => {
it('has a non-empty catalog', () => {
expect(PRO_FEATURES.length).toBeGreaterThan(0);
diff --git a/src/renderer/src/lib/device.ts b/src/renderer/src/lib/device.ts
new file mode 100644
index 00000000..8176cd73
--- /dev/null
+++ b/src/renderer/src/lib/device.ts
@@ -0,0 +1,25 @@
+import {
+ deviceNoun as nounForPlatform,
+ isMac as isMacForPlatform,
+ type DevicePlatform,
+} from '@offgrid/core/shared/device';
+
+// Renderer-side convenience over the shared device rules. Resolves the platform
+// from the value the preload bridge exposes (`window.api.platform`) so callers
+// don't thread it through. Falls back to a non-Mac label ('device' / not-Mac) if
+// the bridge value is missing, so copy + gating degrade safely rather than throw.
+
+/** The current platform as seen by the renderer (single source for the wrappers). */
+export function currentPlatform(): DevicePlatform {
+ return (typeof window !== 'undefined' ? window.api?.platform : undefined) ?? 'unknown';
+}
+
+/** The user-facing name for this machine ('Mac' on macOS, else 'device'). */
+export function deviceNoun(opts?: { capitalize?: boolean }): string {
+ return nounForPlatform(currentPlatform(), opts);
+}
+
+/** True when running on macOS. The device flag for platform-gating Pro features. */
+export function isMac(): boolean {
+ return isMacForPlatform(currentPlatform());
+}
diff --git a/src/shared/__tests__/device.test.ts b/src/shared/__tests__/device.test.ts
new file mode 100644
index 00000000..407fdc9a
--- /dev/null
+++ b/src/shared/__tests__/device.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest';
+import { deviceNoun, isMac } from '../device';
+
+describe('deviceNoun', () => {
+ it('names macOS the Mac (brand proper noun)', () => {
+ expect(deviceNoun('darwin')).toBe('Mac');
+ });
+
+ it('names Windows the neutral "device"', () => {
+ expect(deviceNoun('win32')).toBe('device');
+ });
+
+ it('names Linux the neutral "device"', () => {
+ expect(deviceNoun('linux')).toBe('device');
+ });
+
+ it('falls back to "device" for any other/unknown platform', () => {
+ expect(deviceNoun('freebsd')).toBe('device');
+ expect(deviceNoun('unknown')).toBe('device');
+ expect(deviceNoun('')).toBe('device');
+ });
+
+ describe('capitalize option', () => {
+ it('capitalizes "device" -> "Device" for sentence-initial use', () => {
+ expect(deviceNoun('win32', { capitalize: true })).toBe('Device');
+ expect(deviceNoun('linux', { capitalize: true })).toBe('Device');
+ });
+
+ it('leaves "Mac" unchanged (already capitalized)', () => {
+ expect(deviceNoun('darwin', { capitalize: true })).toBe('Mac');
+ });
+
+ it('is a no-op when capitalize is false/omitted', () => {
+ expect(deviceNoun('win32', { capitalize: false })).toBe('device');
+ expect(deviceNoun('darwin')).toBe('Mac');
+ });
+ });
+});
+
+describe('isMac', () => {
+ it('is true only on darwin', () => {
+ expect(isMac('darwin')).toBe(true);
+ });
+
+ it('is false on every non-macOS platform', () => {
+ expect(isMac('win32')).toBe(false);
+ expect(isMac('linux')).toBe(false);
+ expect(isMac('freebsd')).toBe(false);
+ expect(isMac('unknown')).toBe(false);
+ expect(isMac('')).toBe(false);
+ });
+});
diff --git a/src/shared/device.ts b/src/shared/device.ts
new file mode 100644
index 00000000..86b4e7db
--- /dev/null
+++ b/src/shared/device.ts
@@ -0,0 +1,36 @@
+// The user-facing name for the machine Off Grid runs on. macOS keeps the brand
+// name "Mac"; every other platform (Windows, Linux, anything else) gets the
+// neutral "device". Single source of truth so copy never drifts between the
+// main process and the renderer — both call this instead of hardcoding "Mac".
+//
+// Pure + dependency-free (no electron, no node/DOM) so it loads in every bundle
+// and is unit-testable. Callers pass the platform: `process.platform` in main,
+// the preload-bridged value in the renderer (see src/renderer/src/lib/device.ts).
+
+export type DevicePlatform = NodeJS.Platform | string;
+
+/**
+ * Noun to show the user for their computer.
+ * - macOS (`'darwin'`) -> `'Mac'` (proper noun, always capitalized)
+ * - Windows / Linux / anything else -> `'device'`
+ *
+ * Pass `{ capitalize: true }` for sentence- or heading-initial use so `'device'`
+ * becomes `'Device'` (no effect on `'Mac'`, which is already capitalized).
+ */
+export function deviceNoun(platform: DevicePlatform, opts?: { capitalize?: boolean }): string {
+ const noun = platform === 'darwin' ? 'Mac' : 'device';
+ if (opts?.capitalize) {
+ return noun.charAt(0).toUpperCase() + noun.slice(1);
+ }
+ return noun;
+}
+
+/**
+ * The device flag: true on macOS. Use this to gate features that are only
+ * confirmed working on Mac — the Pro layer is macOS-tested only for now, so on
+ * Windows/Linux we show Pro subscribers a "coming soon" screen instead of the
+ * untested feature (see proCatalog.proFeatureComingSoon).
+ */
+export function isMac(platform: DevicePlatform): boolean {
+ return platform === 'darwin';
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index c66ce907..ef5a7d25 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,4 +1,5 @@
import { defineConfig } from 'vitest/config';
+import { resolve } from 'path';
// Unit + integration tests (fast, deterministic). The Playwright Electron E2E lives
// in e2e/ and runs via `npm run test:e2e`, NOT here.
@@ -10,6 +11,16 @@ import { defineConfig } from 'vitest/config';
// The 85% floor is enforced here and on pre-push. `all: true` means a new pure module
// with no test drags the number down, so untested logic cannot sneak in.
export default defineConfig({
+ // Mirror the renderer path aliases from electron.vite.config.ts so tests that
+ // import renderer/shared modules by alias (e.g. proCatalog -> @renderer/lib/device
+ // -> @offgrid/core/shared/device) resolve the same way the app build does.
+ resolve: {
+ alias: {
+ '@renderer': resolve('src/renderer/src'),
+ '@': resolve('src/renderer/src'),
+ '@offgrid/core': resolve('src'),
+ },
+ },
test: {
include: ['src/**/*.test.ts', 'pro/**/*.test.ts'],
exclude: ['e2e/**', 'node_modules/**', 'out/**'],