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 @@

- Download (macOS) · + Download (macOS · Windows) · Features · getoffgridai.co · Get Pro @@ -23,6 +23,7 @@

platform + platform license local

@@ -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 */}

Models

- +
+ +
{/* Tabs */} @@ -580,7 +583,7 @@ export function ModelsScreen() { {ramGb && bytes > 0 && (

- {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) {
- +
@@ -219,7 +220,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 + + )}
@@ -139,29 +154,70 @@ export function UpgradeScreen({ feature }: { feature?: ProFeature }): React.Reac
- {/* 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. */}
- 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/**'],