From d58a659c8f2b3a1361a0c594e2e10f1385664596 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 27 Jun 2026 14:43:40 +0530 Subject: [PATCH 01/19] feat(win): cross-platform binary resolution + Windows build pipeline Make the bundled-runtime resolvers Windows-aware and add a branch CI job that packages an NSIS installer, so a Windows build can actually be produced. - runtime-env: add exe() helper (appends .exe on win32) - llm/imagegen/rag-extractors: resolve llama-server/sd-cli/whisper-cli/ffmpeg via exe(); prepend binDir to PATH on Windows so co-located DLLs load - scripts/fetch-win-binaries.ps1: rewrite to pull win64 llama/whisper/sd/ffmpeg from each project's LATEST GitHub release (no more stale pins), laid out in the per-runtime subdirs the resolvers expect, with sd.exe->sd-cli.exe rename - .github/workflows/windows-build.yml: windows-2022 + Python 3.12, npm ci, fetch binaries, build, package --win, upload installer artifact (no version bump / no Release publish from the branch) - README: document the LFS prereq and the Windows build path mflux (MLX), coreml-sd, and ocr (Apple Vision) are macOS-only and degrade gracefully when their binaries are absent on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ULeJgUZHdRQsXPbieBYEZq --- .github/workflows/windows-build.yml | 69 +++++++++++++++++ README.md | 25 +++++++ scripts/fetch-win-binaries.ps1 | 111 +++++++++++++++++++++------- src/main/imagegen.ts | 4 +- src/main/llm.ts | 12 ++- src/main/rag/extractors.ts | 6 +- src/main/runtime-env.ts | 7 ++ 7 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/windows-build.yml diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 00000000..2e938f38 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,69 @@ +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 + steps: + - uses: actions/checkout@v4 + with: + 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. + + - 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 0a281d82..37d11d9c 100644 --- a/README.md +++ b/README.md @@ -214,12 +214,37 @@ Linux (AppImage/deb) is in progress. ```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/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1 index 8c52b424..b6848da1 100644 --- a/scripts/fetch-win-binaries.ps1 +++ b/scripts/fetch-win-binaries.ps1 @@ -1,51 +1,106 @@ # 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). +# +# Versions are resolved DYNAMICALLY from each project's latest GitHub release, so +# this script does not go stale. 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' +$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 the latest-release asset whose name matches $pattern. +function Get-LatestAssetUrl($repo, $pattern) { + $rel = Invoke-RestMethod -Headers $ghHeaders -Uri "https://api.github.com/repos/$repo/releases/latest" + $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. +function Expand-Asset($repo, $pattern) { + $url = Get-LatestAssetUrl $repo $pattern $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), CPU x64 baseline ----------------- +Write-Host '== llama.cpp ==' 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 + $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-cpu-x64\.zip$' + Copy-Runtime $x 'llama' | Out-Null } catch { Write-Warning "llama.cpp 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) ----------------------------------------------------- -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: $_" } - # --- stable-diffusion.cpp (image gen), avx2 x64 ------------------------------ -$SD = 'master-8847020' # TODO: confirm current stable-diffusion.cpp release +Write-Host '== stable-diffusion.cpp ==' 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 + $x = Expand-Asset 'leejet/stable-diffusion.cpp' 'bin-win-avx2-x64\.zip$' + $dest = Copy-Runtime $x 'sd' + # Upstream names the 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: $_" } -Write-Host "resources/bin now contains:" -Get-ChildItem -Path $bin | Select-Object Name | Format-Table -HideTableHeaders +# --- ffmpeg (GPL, win64) — single ffmpeg.exe flat in resources/bin ----------- +Write-Host '== ffmpeg ==' +try { + $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.Substring($bin.Length + 1))" } diff --git a/src/main/imagegen.ts b/src/main/imagegen.ts index f9d73be9..1b1d614b 100644 --- a/src/main/imagegen.ts +++ b/src/main/imagegen.ts @@ -10,11 +10,11 @@ import os from 'os'; import { llm } from './llm'; 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'; 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/llm.ts b/src/main/llm.ts index 6a1398d1..6ad01eda 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -4,7 +4,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"; export interface LlmSettings { temperature?: number; @@ -176,8 +176,8 @@ export class LLMService { // newer architectures like gemma4); fall back to the legacy bin/llama-server. const roots = binRoots(); const candidates = roots.flatMap((r) => [ - path.join(r, "llama", "llama-server"), - path.join(r, "llama-server"), + path.join(r, "llama", exe("llama-server")), + path.join(r, exe("llama-server")), ]); const serverPath = candidates.find((p) => fs.existsSync(p)) ?? ""; if (!serverPath) { @@ -241,7 +241,13 @@ export class LLMService { this.server = 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 ?? ''}` } + : {}), }, }); diff --git a/src/main/rag/extractors.ts b/src/main/rag/extractors.ts index 36d9948e..2dfcfdbb 100644 --- a/src/main/rag/extractors.ts +++ b/src/main/rag/extractors.ts @@ -16,7 +16,7 @@ import path from 'path'; import type { ExtractionBridges } from '@offgrid/rag'; import { llm } from '../llm'; import { getActiveModal } from '../active-models'; -import { binRoots, modelsDir } from '../runtime-env'; +import { binRoots, modelsDir, exe } from '../runtime-env'; const execFileAsync = promisify(execFile); @@ -33,13 +33,13 @@ function existing(paths: string[]): string | null { /** 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. */ 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/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; From 56f608bf6907e2f0f9bde74510edcfe384d2e36a Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 27 Jun 2026 14:51:16 +0530 Subject: [PATCH 02/19] fix(llm): guarantee model is loaded before ready + cross-platform orphan cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens against the "server up but /v1/models empty" bug (seen on macOS) and ensures it can't recur on Windows, where the old Unix-only cleanup was a no-op. Root cause: an orphaned llama-server from a previous run keeps holding port 8439. The new spawn can't bind, and waitForReady — which only probed /health — then talked to the ORPHAN (serving no/stale model), marking us ready with an empty model list. The orphan-killer used lsof/ps/SIGKILL (macOS/Linux only), so on Windows there was zero cleanup. - waitForReady: require /v1/models to list a model (not just /health 200) before declaring ready, and bail immediately if the server process exits (failed load) instead of waiting out the full timeout - killOrphansOnPort: cross-platform helper — lsof/ps on macOS+Linux, netstat/tasklist/taskkill on Windows; still only kills a process identified as our own llama-server - gateway /v1/models: fall back to the on-disk active model (LLMService .activeModelInfo()) when upstream hasn't loaded one — so the chat model is listed for an idle app / headless gateway, matching image/speech/STT behaviour Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ULeJgUZHdRQsXPbieBYEZq --- src/main/llm.ts | 109 ++++++++++++++++++++++++++++++--------- src/main/model-server.ts | 13 ++++- 2 files changed, 98 insertions(+), 24 deletions(-) diff --git a/src/main/llm.ts b/src/main/llm.ts index 6ad01eda..9ae47e47 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -148,6 +148,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) }; + } + async init(): Promise { if (this.paused) return; // don't respawn while paused for image generation if (this.initialized) return; @@ -218,25 +234,13 @@ 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 */ } - } - if (killed) await new Promise((r) => setTimeout(r, 400)); // let the port free - } catch { /* nothing on the port */ } + // 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 + } this.server = spawn(serverPath, args, { env: { @@ -271,16 +275,75 @@ export class LLMService { } } + // 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; + } + + // 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) diff --git a/src/main/model-server.ts b/src/main/model-server.ts index 0cb6823a..28780796 100644 --- a/src/main/model-server.ts +++ b/src/main/model-server.ts @@ -28,6 +28,7 @@ import { randomUUID } from 'crypto'; import { desktopExtraction } from './rag/extractors'; import * as tts from './tts'; import { generateImage, imageGenStatus, activeImageModel, type ImageGenParams } from './imagegen'; +import { llm } from './llm'; import { whisperModel } from './rag/extractors'; import { getActiveModal } from './active-models'; import { embeddings } from './embeddings'; @@ -554,10 +555,20 @@ 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[] = upData.map((m) => { + let text: Record[] = upData.map((m) => { const caps = Array.isArray(m.capabilities) ? (m.capabilities as string[]) : []; return { ...m, kind: caps.includes('multimodal') || caps.includes('vision') ? 'vision' : 'chat' }; }); + // 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 => ({ id, object: 'model', created: now, owned_by: 'off-grid', kind, ...extra }); From 5862dcdaba3b86f1cb0466ff29a8bb967a944d2b Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 27 Jun 2026 14:57:58 +0530 Subject: [PATCH 03/19] feat(models): dev control to stop/restart the local model server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a manual recovery control to the Models page for when the server gets into a bad state (e.g. up but no model loaded). Useful during development and as a quick fix without restarting the whole app. - llm: add restart() — clears paused, reloads the active model, waits until it's actually loaded; throws if it can't come back up - ipc: llm:stop and llm:restart handlers (alongside the existing llm:status) - preload: expose getServerStatus / stopServer / restartServer - ModelsScreen: header control with a live status dot (polls llm:status every 4s) plus Restart and Stop buttons with in-flight state and a result message Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ULeJgUZHdRQsXPbieBYEZq --- src/main/ipc.ts | 18 ++++ src/main/llm.ts | 11 +++ src/preload/index.ts | 4 + src/renderer/src/components/ModelsScreen.tsx | 98 +++++++++++++++++++- 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cb25fa8b..afc79333 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -628,6 +628,24 @@ ipcMain.handle('db:search-memories', async (_, query: string) => { }; }); + // Dev/recovery: manually stop or restart the local model server from the + // Models page. Restart fully reloads the active model (and clears any orphan + // holding the port), resolving only once the model is loaded. + ipcMain.handle('llm:stop', async () => { + const { llm } = await import('./llm'); + llm.stop(); + return { ok: true, ready: llm.isReady() }; + }); + ipcMain.handle('llm:restart', async () => { + const { llm } = await import('./llm'); + try { + await llm.restart(); + return { ok: true, ready: llm.isReady() }; + } catch (e) { + return { ok: false, ready: llm.isReady(), error: e instanceof Error ? e.message : String(e) }; + } + }); + // Cancel an in-flight streaming turn; chatStream resolves with the partial answer. ipcMain.on('rag:cancel', (_evt, streamId: string) => { streamControllers.get(streamId)?.abort(); diff --git a/src/main/llm.ts b/src/main/llm.ts index 9ae47e47..19c27ab4 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -599,6 +599,17 @@ export class LLMService { } } + /** Dev/recovery: fully stop the server and bring it back up with the active + * model freshly (re)loaded. Clears `paused` so a manual restart always + * recovers, kills any orphan on the port (via init), and resolves only once + * the model is actually loaded (waitForReady). Throws if it fails to come up. */ + async restart(): Promise { + this.paused = false; + this.reloadModel(); // kill server, re-read active-model.json, clear initialized + await this.init(); // respawn + wait until the model is loaded + if (!this.initialized) throw new Error("Server did not come back up — check the model is downloaded"); + } + /** Pause for image generation: free the server and block respawns until resumed. */ pause() { this.paused = true; diff --git a/src/preload/index.ts b/src/preload/index.ts index 61ebec67..9ed27ff1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -169,6 +169,10 @@ try { getActiveModel: () => ipcRenderer.invoke('models:get-active'), setActiveModalModel: (kind: string, modelId: string | null) => ipcRenderer.invoke('models:set-active-modal', kind, modelId), getActiveModalities: () => ipcRenderer.invoke('models:active-modalities'), + // Local model server control (dev/recovery from the Models page). + getServerStatus: () => ipcRenderer.invoke('llm:status'), + stopServer: () => ipcRenderer.invoke('llm:stop'), + restartServer: () => ipcRenderer.invoke('llm:restart'), onModelProgress: (callback: (data: any) => void) => { const subscription = (_: any, data: any) => callback(data) ipcRenderer.on('model:download-progress', subscription) diff --git a/src/renderer/src/components/ModelsScreen.tsx b/src/renderer/src/components/ModelsScreen.tsx index 3de80ea4..3b5ce61d 100644 --- a/src/renderer/src/components/ModelsScreen.tsx +++ b/src/renderer/src/components/ModelsScreen.tsx @@ -8,6 +8,8 @@ import { IconCheck, IconX, IconTrash, + IconRefresh, + IconPlayerStop, } from '@tabler/icons-react'; import { filterAndSort, @@ -159,6 +161,10 @@ export function ModelsScreen() { const [activeModel, setActiveModel] = useState(null); const [switching, setSwitching] = useState(null); const [switchError, setSwitchError] = useState(null); + // Local model-server control (dev/recovery). null = unknown until first probe. + const [serverReady, setServerReady] = useState(null); + const [serverBusy, setServerBusy] = useState<'restart' | 'stop' | null>(null); + const [serverMsg, setServerMsg] = useState(null); useEffect(() => { api.getModelCatalog?.().then((c: { kinds: string[]; models: ModelEntry[] }) => { @@ -183,6 +189,48 @@ export function ModelsScreen() { return off; }, []); + // Poll the model server's status so the indicator reflects external changes + // (a crash, a model switch). Light-touch: every 4s, plus an immediate probe. + useEffect(() => { + let alive = true; + const probe = (): void => { + api.getServerStatus?.().then((s: { ready?: boolean } | undefined) => { + if (alive && s) setServerReady(!!s.ready); + }).catch(() => { /* ignore */ }); + }; + probe(); + const t = setInterval(probe, 4000); + return () => { alive = false; clearInterval(t); }; + }, []); + + const restartServer = async (): Promise => { + setServerBusy('restart'); + setServerMsg(null); + try { + const res = await api.restartServer?.(); + setServerReady(!!res?.ready); + setServerMsg(res?.ok ? 'Server restarted.' : (res?.error || 'Restart failed.')); + } catch (e) { + setServerMsg(e instanceof Error ? e.message : 'Restart failed.'); + } finally { + setServerBusy(null); + } + }; + + const stopServer = async (): Promise => { + setServerBusy('stop'); + setServerMsg(null); + try { + const res = await api.stopServer?.(); + setServerReady(!!res?.ready); + setServerMsg('Server stopped.'); + } catch (e) { + setServerMsg(e instanceof Error ? e.message : 'Stop failed.'); + } finally { + setServerBusy(null); + } + }; + const cancelDownload = (id: string): void => { void api.cancelModelDownload?.(id); setProgress((p) => { const { [id]: _drop, ...rest } = p; return rest; }); @@ -300,10 +348,52 @@ export function ModelsScreen() { return (
-

Models

-

- Download models for any capability. Everything runs locally on your device. -

+
+
+

Models

+

+ Download models for any capability. Everything runs locally on your device. +

+
+ + {/* Local model-server control (dev/recovery): manually stop/restart the + llama-server if it gets into a bad state (e.g. up but no model loaded). */} +
+
+ + + Model server + + + +
+ {serverMsg && {serverMsg}} +
+
{/* Modality tabs — always visible; they also scope the search below. */}
From 5322c17e56f7b73692fa344bc23ecbe171efaf78 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 27 Jun 2026 15:32:14 +0530 Subject: [PATCH 04/19] fix(win): fetch-win-binaries crashed on cosmetic listing, failing after copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binaries downloaded and copied fine, but the final "now contains" listing used $_.FullName.Substring($bin.Length+1) where $bin still held the uncollapsed 'scripts\..\resources\bin' path — longer than the copied files' canonical paths, so Substring threw "startIndex cannot be larger than length of string" and, under ErrorActionPreference=Stop, exited the script 1 *after* a successful copy. The CI fetch step and local runs both went red, leaving llama-server.exe seemingly absent. - canonicalize $bin via [IO.Path]::GetFullPath so paths line up - list with a safe .Replace($bin,'') instead of Substring - add an explicit post-fetch check: llama-server.exe REQUIRED (hard fail with a clear message), whisper/sd/ffmpeg optional (warn only) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ULeJgUZHdRQsXPbieBYEZq --- scripts/fetch-win-binaries.ps1 | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1 index b6848da1..98eea901 100644 --- a/scripts/fetch-win-binaries.ps1 +++ b/scripts/fetch-win-binaries.ps1 @@ -20,6 +20,11 @@ $ProgressPreference = 'SilentlyContinue' # makes Invoke-WebRequest downloads fa $bin = Join-Path $PSScriptRoot '..\resources\bin' New-Item -ItemType Directory -Force -Path $bin | Out-Null +# 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 } @@ -103,4 +108,21 @@ try { Write-Host '' Write-Host 'resources/bin now contains (win64):' Get-ChildItem -Path $bin -Recurse -Include *.exe | - ForEach-Object { Write-Host " $($_.FullName.Substring($bin.Length + 1))" } + ForEach-Object { Write-Host " $($_.FullName.Replace($bin, '').TrimStart('\'))" } + +# 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 '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" From 10cd1fb59b01c9c991476db3663356724a425b85 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 11 Jul 2026 20:00:02 +0530 Subject: [PATCH 05/19] docs(win): add Windows support matrix + test plan/models Untracked working docs for the Windows-support effort. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016wZRqu5sTN3AR1x6csoZ65 --- docs/WINDOWS_SUPPORT.md | 93 ++++++++ docs/WINDOWS_TEST_MODELS.md | 148 ++++++++++++ docs/WINDOWS_TEST_PLAN.md | 436 ++++++++++++++++++++++++++++++++++++ 3 files changed, 677 insertions(+) create mode 100644 docs/WINDOWS_SUPPORT.md create mode 100644 docs/WINDOWS_TEST_MODELS.md create mode 100644 docs/WINDOWS_TEST_PLAN.md diff --git a/docs/WINDOWS_SUPPORT.md b/docs/WINDOWS_SUPPORT.md new file mode 100644 index 00000000..302ac03a --- /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 on this branch, not a verified run on Windows.** +> Nothing below has been executed on real Windows hardware yet — "needs testing" is the +> ceiling until someone runs the CI artifact 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` runner, builds + packages on push to this branch. Uploads the installer as a **workflow artifact only** (`--publish never`); does not cut a release. Never run on a real machine yet. | +| 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 — **versions resolved dynamically** (no longer stale). 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` code is cross-platform (`src/main/updater.ts`), and NSIS is a supported target. But `windows-build.yml` publishes nothing, so **no Windows update feed (`latest.yml`) is published** — auto-update won't function until a windows job is folded into `release.yml`. | + +--- + +## 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. **Nothing has run on Windows yet.** Every 🟢 above is "code looks right," not "verified." First real test = download the CI artifact and launch it on Windows 10/11. No *known-required* runtime code fix exists — the coding below is either shipping plumbing or contingent on what this first run breaks. +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. **Auto-update feed not published** for Windows — installs won't self-update until a windows job lands in `release.yml`. + +--- + +## 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..63c5c685 --- /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](#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. From fc112dcb6344725bec51c69660d9ee734dc5d840 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 11 Jul 2026 20:14:13 +0530 Subject: [PATCH 06/19] fix(win): pin bundled llama-server to b9838 to match the macOS engine Windows chat with MCP connectors failed with "failed to parse grammar" (and a follow-on 400) because the Windows llama-server.exe was fetched from llama.cpp's LATEST release while macOS builds from source at ref b9838 (scripts/build-llama.sh). Different engine versions handle the native tool-call schema->GBNF conversion differently, and the floating latest build rejected the grammar generated from the connectors' tool schemas. Pin fetch-win-binaries.ps1 to b9838 (env-overridable via LLAMA_REF) so both platforms run the identical engine. Generalized Get-LatestAssetUrl -> Get-AssetUrl with an optional tag; whisper/sd/ffmpeg stay on latest. Verified the b9838 release publishes llama-b9838-bin-win-cpu-x64.zip. Takes effect after re-running the fetch + repackaging the Windows build. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016wZRqu5sTN3AR1x6csoZ65 --- scripts/fetch-win-binaries.ps1 | 35 +++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1 index 98eea901..568a98d9 100644 --- a/scripts/fetch-win-binaries.ps1 +++ b/scripts/fetch-win-binaries.ps1 @@ -11,9 +11,14 @@ # 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). # -# Versions are resolved DYNAMICALLY from each project's latest GitHub release, so -# this script does not go stale. Set OFFGRID_GH_TOKEN (or GITHUB_TOKEN) to avoid -# the unauthenticated API rate limit (CI sets GITHUB_TOKEN automatically). +# 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 @@ -34,18 +39,23 @@ $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 the latest-release asset whose name matches $pattern. -function Get-LatestAssetUrl($repo, $pattern) { - $rel = Invoke-RestMethod -Headers $ghHeaders -Uri "https://api.github.com/repos/$repo/releases/latest" +# 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. -function Expand-Asset($repo, $pattern) { - $url = Get-LatestAssetUrl $repo $pattern +# 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') Write-Host " downloading $url" Invoke-WebRequest -Headers $ghHeaders -Uri $url -OutFile $zip @@ -64,9 +74,12 @@ function Copy-Runtime($srcDir, $destName) { } # --- llama.cpp (server + CLIs + ggml DLLs), CPU x64 baseline ----------------- -Write-Host '== llama.cpp ==' +# 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. +$LlamaRef = if ($env:LLAMA_REF) { $env:LLAMA_REF } else { 'b9838' } +Write-Host "== llama.cpp (pinned $LlamaRef) ==" try { - $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-cpu-x64\.zip$' + $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-cpu-x64\.zip$' $LlamaRef Copy-Runtime $x 'llama' | Out-Null } catch { Write-Warning "llama.cpp fetch failed: $_" } From fd1118317385c7fe09d619fdb57fe4609e3e4eca Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 11 Jul 2026 20:23:42 +0530 Subject: [PATCH 07/19] fix(tools): budget tool schemas to context + surface server errors (B2/B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the "chat 400s with many MCP connectors" problem the merge exposed. B3 — tool-schema budget (src/main/tools/tool-budget.ts, wired into toolChat): llama-server inlines every tool schema into the prompt AND compiles it to a grammar, so a large connector set overflowed the context window (22.8k tokens vs a 16384 ctx) and the server rejected the whole turn. budgetTools() now fits the payload into ~45% of the effective (RAM-clamped) context: it first PRUNES grammar-bloating, low-value schema keywords (numeric/string range constraints — the same ones that broke tool-call grammars — plus examples/format/long descriptions), then DROPS connector tools from the end only if still over, never below the built-ins. Never a silent cap: it logs what was dropped and tells the model via a system hint. B2 — actionable server errors (describeServerError in llm/http-post.ts): the two streaming request paths discarded the error body (res.resume()) and rejected with a bare "LLM Server Error: 400". They now read the small error body and map the common cases — context overflow, ungrammatical tool schema — to plain, user-fixable messages; other cases fall back to the server's own message. Verified: typecheck + OFFGRID_FORCE_CORE=1 build pass; budgetTools exercised on a synthetic oversized tool set (prunes, drops connectors, preserves built-ins, passes small sets through untouched). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016wZRqu5sTN3AR1x6csoZ65 --- src/main/llm.ts | 30 ++++++++++++-- src/main/llm/http-post.ts | 26 +++++++++++- src/main/tools.ts | 17 +++++++- src/main/tools/tool-budget.ts | 75 +++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/main/tools/tool-budget.ts diff --git a/src/main/llm.ts b/src/main/llm.ts index 20c19783..174daeb4 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -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, @@ -683,7 +689,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; @@ -754,7 +769,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; 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/tools.ts b/src/main/tools.ts index 2f397bdc..c305c770 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -312,7 +312,22 @@ 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(); + const toolBudget = Math.max(1024, Math.floor(ctx * 0.45)); + 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) }; +} From 4226098c6eb2bf362870f2de6c1f5fb790ac28de Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Sat, 11 Jul 2026 20:49:25 +0530 Subject: [PATCH 08/19] perf/fix(win): GPU (Vulkan) engine + CPU fallback, tighter tool budget, cosine arity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for "chat is slow / errors with connectors on" on Windows. 1) Windows GPU acceleration with a safe fallback. The bundled Windows llama-server was the CPU-only build, so -ngl was a no-op and prompt processing of the tool payload crawled on CPU. fetch-win-binaries.ps1 now ships the Vulkan (GPU) build as bin/llama (offloads to any Vulkan device incl. AMD/Intel iGPUs, and still runs on CPU when no device is present) plus a CPU-only build in bin/llama-cpu. llm.ts resolves an ORDERED engine list and _doInit falls through Vulkan -> CPU via a new launchServer() helper if the primary can't start (e.g. no Vulkan loader). A failed engine is torn down with its close handler neutralized so the fallback is never misread as a crash. macOS/Linux see a single engine — no behaviour change. 2) Absolute tool-token cap (tools.ts). gemma-4's sliding-window attention defeats the prompt cache, so the full tool prompt is re-processed every tool round; the old 45%-of-context budget allowed ~9k tool tokens. Cap at ~4k (min(ctx*0.4, 4000)) to roughly halve per-round prompt cost while keeping a useful connector set. 3) cosine_similarity arity (database.ts). It was registered with a rest param (...args), whose fn.length is 0, so better-sqlite3 registered a 0-arg function and every "cosine_similarity(embedding, ?)" call threw "wrong number of arguments". Declare the two params explicitly. (The observation_fts errors are a separate, harmless matter: that table is Pro-only and absent on core builds.) Verified: typecheck + OFFGRID_FORCE_CORE=1 build pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016wZRqu5sTN3AR1x6csoZ65 --- scripts/fetch-win-binaries.ps1 | 23 +++++-- src/main/database.ts | 8 ++- src/main/llm.ts | 112 ++++++++++++++++++++++----------- src/main/tools.ts | 8 ++- 4 files changed, 106 insertions(+), 45 deletions(-) diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1 index 568a98d9..a08ea178 100644 --- a/scripts/fetch-win-binaries.ps1 +++ b/scripts/fetch-win-binaries.ps1 @@ -73,15 +73,29 @@ function Copy-Runtime($srcDir, $destName) { return $dest } -# --- llama.cpp (server + CLIs + ggml DLLs), CPU x64 baseline ----------------- +# --- 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) ==" +Write-Host "== llama.cpp (pinned $LlamaRef): vulkan primary + cpu fallback ==" try { - $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-cpu-x64\.zip$' $LlamaRef + $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-vulkan-x64\.zip$' $LlamaRef Copy-Runtime $x 'llama' | Out-Null -} catch { Write-Warning "llama.cpp fetch failed: $_" } +} 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-cli.exe + DLLs) ------------------------------------ Write-Host '== whisper.cpp ==' @@ -132,6 +146,7 @@ if (-not (Test-Path -LiteralPath $llama)) { 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'))) { 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/llm.ts b/src/main/llm.ts index 174daeb4..82db67c9 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -355,34 +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.flatMap((r) => [ + 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")), - ]); - const serverPath = candidates.find((p) => fs.existsSync(p)) ?? ""; - if (!serverPath) { - console.error(`[LLMService] llama-server binary not found. Looked in:\n${candidates.join("\n")}`); + ]).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( @@ -416,20 +406,60 @@ export class LLMService { await new Promise((r) => setTimeout(r, 400)); // let the port free } - const 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 ?? ''}` } - : {}), - }, - }); + 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)); + } + } + console.error('[LLMService] all llama-server engines failed to start'); + } + + /** 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); @@ -441,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; @@ -472,9 +501,16 @@ 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; } } diff --git a/src/main/tools.ts b/src/main/tools.ts index c305c770..84a9706a 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -321,7 +321,13 @@ export async function toolChat( // answer); prune verbose schemas first, drop connector tools only if needed. const { budgetTools } = await import('./tools/tool-budget'); const ctx = llm.effectiveContextSize(); - const toolBudget = Math.max(1024, Math.floor(ctx * 0.45)); + // 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)`); From c11d8f8becafe9df3b64ddf08bc5f14e3f2105e8 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Wed, 15 Jul 2026 21:31:17 +0530 Subject: [PATCH 09/19] feat(win): device-aware copy + gate Pro to "coming soon" off macOS Two related Windows-support changes on one reusable platform helper (src/shared/device.ts, resolved in the renderer via lib/device.ts): 1. Naming: say "Mac" only on macOS, "device" on Windows/Linux. Swaps the device-context copy in SetupPanel/Onboarding/ModelsScreen/PermissionGate/ proCatalog + the main-process setup + llama-error OOM message. Code comments, the spoofed web User-Agent, and "macOS" refs left as-is. 2. Pro gating: Pro is macOS-tested only for now, so a Pro subscriber on a non-Mac platform gets a "coming soon" screen (license still works on Mac + phone) instead of the untested feature. Applies to the Pro feature tabs (App.tsx via proFeatureComingSoon) and the backend-dependent Settings sections (proactive delivery, learned prefs), while keeping plan management + identity. Free users unaffected (still see the upgrade upsell). Also: windows-build.yml checks out the private pro repo (mirrors release.yml) so the Windows exe bundles + can exercise Pro; vitest.config gains the renderer path aliases so alias-importing modules resolve in tests. Tests: device helpers (shared + renderer), proComingSoonHere / proFeatureComingSoon, platform-aware llama-error OOM case. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017mcAb498trk7GGDtCFQbyX --- .github/workflows/windows-build.yml | 22 ++++ src/main/__tests__/llama-error.test.ts | 7 ++ src/main/llama-error.ts | 9 +- src/main/setup.ts | 3 +- src/preload/index.ts | 3 + src/renderer/src/App.tsx | 13 ++- src/renderer/src/components/ModelsScreen.tsx | 5 +- src/renderer/src/components/Onboarding.tsx | 7 +- .../src/components/PermissionGate.tsx | 3 +- src/renderer/src/components/Settings.tsx | 48 +++++++-- .../src/components/pro/UpgradeScreen.tsx | 102 +++++++++++++----- src/renderer/src/components/pro/proCatalog.ts | 25 ++++- .../src/components/setup/SetupPanel.tsx | 9 +- src/renderer/src/env.d.ts | 3 + src/renderer/src/lib/__tests__/device.test.ts | 47 ++++++++ .../lib/__tests__/proCatalog.lookup.test.ts | 49 ++++++++- src/renderer/src/lib/device.ts | 25 +++++ src/shared/__tests__/device.test.ts | 52 +++++++++ src/shared/device.ts | 36 +++++++ vitest.config.ts | 11 ++ 20 files changed, 424 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/lib/__tests__/device.test.ts create mode 100644 src/renderer/src/lib/device.ts create mode 100644 src/shared/__tests__/device.test.ts create mode 100644 src/shared/device.ts diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 2e938f38..c3b11c8c 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -21,6 +21,11 @@ jobs: # 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: @@ -28,6 +33,23 @@ jobs: # 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 + - 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' 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/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/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/preload/index.ts b/src/preload/index.ts index bbc04e98..67c66c4f 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 6ccabf85..ec381682 100644 --- a/src/renderer/src/components/ModelsScreen.tsx +++ b/src/renderer/src/components/ModelsScreen.tsx @@ -18,6 +18,7 @@ import { IconStarFilled, } from '@tabler/icons-react'; import { StoragePanel } from './setup/StoragePanel'; +import { deviceNoun } from '@renderer/lib/device'; import { filterAndSort, parseParamCount, @@ -86,7 +87,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 }, ]; @@ -667,7 +668,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..c2529a17 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 } 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,58 @@ 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/**'], From d80b3da24c29bb773742126239b67c9a8f2b0a66 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Thu, 16 Jul 2026 12:05:22 +0530 Subject: [PATCH 10/19] fix(win): fetch a current sd asset (cpu x64), image binary was silently missing The Windows binary fetch matched `bin-win-avx2-x64.zip`, which upstream stable-diffusion.cpp no longer publishes. The sd fetch is optional (verify only WARNs), so Get-AssetUrl threw "no asset matching", the try/catch swallowed it, and the Windows package shipped with NO image binary -> runtime error "Image generation binary (sd-cli) not found in resources/bin/sd". Point it at the current `bin-win-cpu-x64.zip`. CPU on purpose: the default path is the one-shot sd-cli (resident sd-server is opt-in), which reports a single exit code and so can't tell "GPU binary won't load" from "generation failed" - no launch-failure ladder like llm.ts. The bundled binary must load unconditionally; only the CPU build does. Vulkan-primary + CPU-fallback is a future upgrade once the resident readiness seam can detect load failure. Add a source-reading regression guard so a stale/dead asset name can't silently ship again. macOS is unaffected (its sd binaries ship from Git LFS, not this script). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01U2S44L2JTARo75RQNRXH54 --- scripts/fetch-win-binaries.ps1 | 24 +++++++-- src/main/__tests__/fetch-win-binaries.test.ts | 52 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src/main/__tests__/fetch-win-binaries.test.ts diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1 index a08ea178..9feaed80 100644 --- a/scripts/fetch-win-binaries.ps1 +++ b/scripts/fetch-win-binaries.ps1 @@ -108,12 +108,28 @@ try { if (-not (Test-Path $wc) -and (Test-Path $mn)) { Copy-Item $mn $wc -Force } } catch { Write-Warning "whisper.cpp fetch failed: $_" } -# --- stable-diffusion.cpp (image gen), avx2 x64 ------------------------------ -Write-Host '== stable-diffusion.cpp ==' +# --- 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 = Expand-Asset 'leejet/stable-diffusion.cpp' 'bin-win-avx2-x64\.zip$' + $x = Expand-Asset 'leejet/stable-diffusion.cpp' 'bin-win-cpu-x64\.zip$' $dest = Copy-Runtime $x 'sd' - # Upstream names the binary sd.exe; the app resolves sd/sd-cli(.exe). + # 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 } 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'/); + }); +}); From 947d914115e5fb957c5d2fa5d650a1f8b8c72711 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Thu, 16 Jul 2026 15:08:32 +0530 Subject: [PATCH 11/19] fix(embeddings): pin transformers cacheDir to a writable dir (was re-downloading every run) 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 single file, not a directory), catches it non-fatally, and falls back to the in-memory buffer. Net effect: the ~23MB MiniLM embedding model is NEVER persisted, so every embedding operation re-downloads it from HuggingFace. On a slower/flakier link than the dev box that repeated full download surfaces as the reported "embeddings model timeout" when uploading a file to a project. Confirmed on a Windows machine: download, onnxruntime load, network, and proxy were ALL clean; the only failure was 8x ENOTDIR from FileCache.put per operation, with nothing cached to disk. So this is a cache-persistence bug, not a download/engine/ network one - bundling the model would NOT have fixed it. Point cacheDir at the same writable userData/models dir already used for localModelPath, so the model downloads once and is read from disk thereafter. Cross-platform: the same asar is read-only on macOS too (the signed .app), so a packaged Mac has the identical latent bug (masked by fast network). This fixes both; userData/models is writable on both platforms, so no regression. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01U2S44L2JTARo75RQNRXH54 --- .../__tests__/embeddings-cachedir.test.ts | 41 +++++++++++++++++++ src/main/embeddings.ts | 14 ++++++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/main/__tests__/embeddings-cachedir.test.ts 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/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; From e59c30ccdbc6d5b1daf1b0cd45743a617e828876 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Thu, 16 Jul 2026 16:18:17 +0530 Subject: [PATCH 12/19] fix(win): set the Windows app icon (was defaulting to the Electron icon) electron-builder.yml set mac.icon but left the win block with no icon key, so the Windows exe, NSIS installer, and window/taskbar fell back to the default Electron icon. Point win.icon at the existing 512x512 resources/icon.png (electron-builder auto-converts to a multi-size ICO), and extend the runtime BrowserWindow icon from Linux-only to Windows too. macOS is untouched (its own mac.icon + dock path stand). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01U2S44L2JTARo75RQNRXH54 --- electron-builder.yml | 4 ++++ src/main/index.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) 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/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 From bccc19437b990db7d096b773518e62e124fb39a0 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Thu, 16 Jul 2026 16:18:28 +0530 Subject: [PATCH 13/19] refactor(models): remove the dev-only stop/restart model-server control The Models page carried a dev/recovery control (status pill + Stop + Restart) added in 5862dcd. It is not meant for release, so remove it ahead of merging to main - the UI block, its state/handlers, and the now-orphaned IPC + preload methods it was the sole caller of (llm:status / llm:stop / llm:restart, getServerStatus / stopServer / restartServer). App.tsx's own model-server status pill is unaffected - it reads systemHealth, not these endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01U2S44L2JTARo75RQNRXH54 --- src/main/ipc.ts | 24 ------ src/preload/index.ts | 4 - src/renderer/src/components/ModelsScreen.tsx | 85 -------------------- 3 files changed, 113 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3aeac7ca..84319082 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -641,30 +641,6 @@ ipcMain.handle('db:search-memories', async (_, query: string) => { } }); - ipcMain.handle('llm:status', async () => { - const { llm } = await import('./llm'); - return { - ready: llm.isReady() - }; - }); - - // Dev/recovery: manually stop or restart the local model server from the - // Models page. Restart fully reloads the active model (and clears any orphan - // holding the port), resolving only once the model is loaded. - ipcMain.handle('llm:stop', async () => { - const { llm } = await import('./llm'); - llm.stop(); - return { ok: true, ready: llm.isReady() }; - }); - ipcMain.handle('llm:restart', async () => { - const { llm } = await import('./llm'); - try { - await llm.restart(); - return { ok: true, ready: llm.isReady() }; - } catch (e) { - return { ok: false, ready: llm.isReady(), error: e instanceof Error ? e.message : String(e) }; - } - }); // Cancel an in-flight streaming turn; chatStream resolves with the partial answer. ipcMain.on('rag:cancel', (_evt, streamId: string) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 67c66c4f..18eaf06c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -202,10 +202,6 @@ try { getActiveModelIds: () => ipcRenderer.invoke('models:active-ids'), setActiveModalModel: (kind: string, modelId: string | null) => ipcRenderer.invoke('models:set-active-modal', kind, modelId), getActiveModalities: () => ipcRenderer.invoke('models:active-modalities'), - // Local model server control (dev/recovery from the Models page). - getServerStatus: () => ipcRenderer.invoke('llm:status'), - stopServer: () => ipcRenderer.invoke('llm:stop'), - restartServer: () => ipcRenderer.invoke('llm:restart'), onModelProgress: (callback: (data: any) => void) => { const subscription = (_: any, data: any) => callback(data) ipcRenderer.on('model:download-progress', subscription) diff --git a/src/renderer/src/components/ModelsScreen.tsx b/src/renderer/src/components/ModelsScreen.tsx index ec381682..eba1700d 100644 --- a/src/renderer/src/components/ModelsScreen.tsx +++ b/src/renderer/src/components/ModelsScreen.tsx @@ -8,8 +8,6 @@ import { IconCheck, IconX, IconTrash, - IconRefresh, - IconPlayerStop, IconUpload, IconInfoCircle, IconExternalLink, @@ -128,10 +126,6 @@ export function ModelsScreen() { const refreshActive = (): void => { void api.getActiveModelIds?.().then((ids: string[]) => setActiveIds(new Set(ids ?? []))); }; const [switching, setSwitching] = useState(null); const [switchError, setSwitchError] = useState(null); - // Local model-server control (dev/recovery). null = unknown until first probe. - const [serverReady, setServerReady] = useState(null); - const [serverBusy, setServerBusy] = useState<'restart' | 'stop' | null>(null); - const [serverMsg, setServerMsg] = useState(null); const [ramGb, setRamGb] = useState(null); const [importing, setImporting] = useState(false); const [useCase, setUseCase] = useState('all'); @@ -187,48 +181,6 @@ export function ModelsScreen() { return off; }, []); - // Poll the model server's status so the indicator reflects external changes - // (a crash, a model switch). Light-touch: every 4s, plus an immediate probe. - useEffect(() => { - let alive = true; - const probe = (): void => { - api.getServerStatus?.().then((s: { ready?: boolean } | undefined) => { - if (alive && s) setServerReady(!!s.ready); - }).catch(() => { /* ignore */ }); - }; - probe(); - const t = setInterval(probe, 4000); - return () => { alive = false; clearInterval(t); }; - }, []); - - const restartServer = async (): Promise => { - setServerBusy('restart'); - setServerMsg(null); - try { - const res = await api.restartServer?.(); - setServerReady(!!res?.ready); - setServerMsg(res?.ok ? 'Server restarted.' : (res?.error || 'Restart failed.')); - } catch (e) { - setServerMsg(e instanceof Error ? e.message : 'Restart failed.'); - } finally { - setServerBusy(null); - } - }; - - const stopServer = async (): Promise => { - setServerBusy('stop'); - setServerMsg(null); - try { - const res = await api.stopServer?.(); - setServerReady(!!res?.ready); - setServerMsg('Server stopped.'); - } catch (e) { - setServerMsg(e instanceof Error ? e.message : 'Stop failed.'); - } finally { - setServerBusy(null); - } - }; - const cancelDownload = (id: string): void => { void api.cancelModelDownload?.(id); setProgress((p) => { const { [id]: _drop, ...rest } = p; return rest; }); @@ -448,43 +400,6 @@ export function ModelsScreen() {

Models

- {/* Local model-server control (dev/recovery): manually stop/restart the - llama-server if it gets into a bad state (e.g. up but no model loaded). */} -
-
- - - Model server - - - -
- {serverMsg && {serverMsg}} -
@@ -205,7 +205,7 @@ export function UpgradeScreen({ Get Pro

- One-time purchase. Runs entirely on your device — no subscription, no cloud, no account. + One-time purchase. Runs entirely on your device - no subscription, no cloud, no account.

{/* Guard kept so a pure-core build (no pro code) wouldn't show an inert box; diff --git a/src/renderer/src/components/pro/__tests__/UpgradeScreen.windows-notice.test.ts b/src/renderer/src/components/pro/__tests__/UpgradeScreen.windows-notice.test.ts index fa4079c8..f0ef5476 100644 --- a/src/renderer/src/components/pro/__tests__/UpgradeScreen.windows-notice.test.ts +++ b/src/renderer/src/components/pro/__tests__/UpgradeScreen.windows-notice.test.ts @@ -19,31 +19,45 @@ const SRC = fs.readFileSync( 'utf-8', ); +// Scope every assertion to the UPGRADE (buy) branch of the aside ternary, so a test +// can't pass if the notice or the buy CTA drifts into the coming-soon branch. The +// upgrade branch runs from the notice comment to the shared cross-sell block. +const UPGRADE_BRANCH = SRC.slice(SRC.indexOf('Off macOS, Pro is not yet tested'), SRC.indexOf('Cross-sell')); +// The coming-soon branch is everything in the aside before the upgrade branch. +const COMINGSOON_BRANCH = SRC.slice(SRC.indexOf('You have Pro'), SRC.indexOf('Off macOS, Pro is not yet tested')); + describe('UpgradeScreen - non-Mac "coming soon" notice on the buy screen', () => { it('imports the isMac platform helper', () => { expect(SRC).toMatch(/import\s*\{[^}]*\bisMac\b[^}]*\}\s*from\s*'@renderer\/lib\/device'/); }); - it('gates the notice on !isMac()', () => { - expect(SRC).toMatch(/!isMac\(\)\s*&&/); + it('scoping anchors exist (guards this test against a refactor of the variants)', () => { + expect(UPGRADE_BRANCH.length).toBeGreaterThan(0); + expect(COMINGSOON_BRANCH.length).toBeGreaterThan(0); + }); + + it('gates the notice on !isMac() within the upgrade branch', () => { + expect(UPGRADE_BRANCH).toMatch(/!isMac\(\)\s*&&/); }); - it('tells the user Pro is coming soon and works on Mac + phone', () => { - expect(SRC).toMatch(/Coming soon to your \{deviceNoun\(\)\}/); - expect(SRC).toMatch(/macOS-tested/); - expect(SRC).toMatch(/phone app/); + it('tells the user Pro is coming soon and works on Mac + phone (upgrade branch)', () => { + expect(UPGRADE_BRANCH).toMatch(/Coming soon to your \{deviceNoun\(\)\}/); + expect(UPGRADE_BRANCH).toMatch(/macOS-tested/); + expect(UPGRADE_BRANCH).toMatch(/phone app/); }); - it('keeps the buy CTA on the upgrade variant (not replaced by the notice)', () => { + it('keeps the buy CTA in the upgrade branch (not replaced by the notice)', () => { // The purchase path must remain: the license is valid on Mac + phone today. - expect(SRC).toMatch(/Get Pro/); - expect(SRC).toMatch(/PRO_PAY_URL/); + expect(UPGRADE_BRANCH).toMatch(/Get Pro/); + expect(UPGRADE_BRANCH).toMatch(/PRO_PAY_URL/); + // ...and the buy CTA must NOT have leaked into the coming-soon branch. + expect(COMINGSOON_BRANCH).not.toMatch(/PRO_PAY_URL/); }); it('follows brand voice: uses " - " not an em dash in the notice copy', () => { // Em dash is unambiguous - code never uses it, so scanning the block is safe. - const block = SRC.slice(SRC.indexOf('!isMac()'), SRC.indexOf('Unlock Pro')); - expect(block).not.toMatch(/—/); - expect(block).toMatch(/ - /); // the brand-approved separator + const notice = UPGRADE_BRANCH.slice(0, UPGRADE_BRANCH.indexOf('Unlock Pro')); + expect(notice).not.toMatch(/—/); + expect(notice).toMatch(/ - /); // the brand-approved separator }); });